From c4fb693035a3fbb7f949d5c013c9e167321fc37e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 28 Jun 2021 10:54:25 +0200 Subject: [PATCH 01/65] RFC: Kibana `preboot` lifecycle stage. (#99318) --- rfcs/images/0019_lifecycle_preboot.png | Bin 0 -> 109099 bytes rfcs/text/0019_lifecycle_preboot.md | 261 +++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 rfcs/images/0019_lifecycle_preboot.png create mode 100644 rfcs/text/0019_lifecycle_preboot.md diff --git a/rfcs/images/0019_lifecycle_preboot.png b/rfcs/images/0019_lifecycle_preboot.png new file mode 100644 index 0000000000000000000000000000000000000000..73eeb901eb33f5d38fdb8d4a90f0388b9e0ab241 GIT binary patch literal 109099 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%0DtX{nClzq*DFa4iS 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
DcS2GAnlJy}cnCZG>*kjc94ujR>MOf+4 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 { + setup(core: CorePrebootSetup, plugins: TPluginsSetup): TSetup; + stop?(): void; +} +``` + +To differentiate preboot and standard plugins we'll introduce a new _optional_ `type` property in the plugin manifest. The property can have only two possible values: `preboot` for `preboot` plugins and `standard` for the standard ones. If `type` is omitted, the `standard` value will be assumed. + +```json5 +// NOTE(azasypkin): all other existing properties have been omitted for brevity. +{ + "type": "preboot", // 'preboot' | 'standard' | undefined +} +``` + +The Plugins service will split plugins into two separate groups during discovery to use them separately at the `preboot`, `setup`, and `start` stages. The Core contract that preboot plugins will receive during their `setup` will be different from the one standard plugins receive, and will only include the functionality that is currently required for the interactive setup mode. We'll discuss this functionality in details in the following sections: + +```ts +export interface CorePrebootSetup { + elasticsearch: ElasticsearchServicePrebootSetup; + http: HttpServicePrebootSetup; + preboot: PrebootServiceSetup; +} +``` + +### 3.2.2 HTTP service + +We'll change HTTP service to initialize and start preboot HTTP server (formerly known as `Not Ready` server) in the new `preboot` method instead of `setup`. The returned `InternalHttpServicePrebootSetup` contract will presumably be very similar to the existing `InternalHttpServiceSetup` contract, but will only include APIs we currently need to support interactive setup mode: + +```ts +// NOTE(azasypkin): some existing properties have been omitted for brevity. +export interface InternalHttpServicePrebootSetup + extends Pick { + server: HttpServerSetup['server']; + externalUrl: ExternalUrlConfig; + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} +``` + +The only part of this contract that will be available to the preboot plugins via `CorePrebootSetup` is the API to register HTTP routes on the already running preboot HTTP server: + +```ts +export interface HttpServicePrebootSetup { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} +``` + +The Core HTTP context available to handlers of the routes registered on the preboot HTTP server will only expose the `uiSettings` service. As explained in the [UI Settings service section](#324-ui-settings-service), this service will only give access to the **default Core** UI settings and their overrides set through Kibana configuration, if any. +```ts +// NOTE(azasypkin): the fact that the client is lazily initialized has been omitted for brevity. +export interface PrebootCoreRouteHandlerContext { + readonly uiSettings: { client: IUiSettingsClient }; +} +``` + +The authentication and authorization components are not available at the `preboot` stage, and hence all preboot HTTP server routes can be freely accessed by anyone with access to the network Kibana is exposed to. + +Just as today, Kibana will shut the preboot HTTP server down as soon as it's ready to start the main HTTP server. + +### 3.2.3 Elasticsearch service + +As mentioned in the [Motivation section](#2-motivation), the main goal of the interactive setup mode is to give the user a hassle-free way to configure Kibana connection to an Elasticsearch cluster. That means that users might provide certain connection information, and Kibana preboot plugins should be able to construct a new Elasticsearch client using this information to verify it and potentially call Elasticsearch APIs. + +To support this use case we'll add a new `preboot` method to the Elasticsearch service that will return the following contract, and make it available to the preboot plugins via `CorePrebootSetup`: + +```ts +export interface ElasticsearchServicePrebootSetup { + readonly createClient: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; +} +``` + +The Elasticsearch clients created with `createClient` rely on the default Kibana Elasticsearch configuration and any configuration overrides specified by the consumer. + +__NOTE:__ We may need to expose a full or portion of Elasticsearch config to the preboot plugins for them to check if the user has already configured Elasticsearch connection. There are other ways to check that without direct access to the configuration though. + +### 3.2.4 UI Settings service + +We'll introduce a new `preboot` method in the UI Settings service that will produce a UI Settings client instance. Since during the `preboot` stage Kibana can access neither user information nor Saved Objects, this client will only give access to the **default Core** UI settings and their overrides set through Kibana configuration, if any: + +```ts +export interface InternalUiSettingsServicePrebootSetup { + defaultsClient(): IUiSettingsClient; +} +``` + +UI Settings service isn't strictly necessary during the `preboot` stage, but many Kibana Core components rely on it explicitly and implicitly, which justifies this simple change. + +### 3.2.5 Rendering service + +We'll introduce a new `preboot` method in the Rendering service that will register Kibana main UI bootstrap template route on the preboot HTTP server as it does for the main HTTP server today. The main difference is that bootstrap UI will only reference bundles of the preboot plugins and will rely on the default UI settings. + +### 3.2.6 I18n service + +We'll introduce a new `preboot` method in the I18n service to only include translations for the Core itself and preboot plugins in the translations bundle loaded with the preboot UI bootstrap template. This would potentially allow us to switch locale during interactive setup mode if there is such a need in the future. + +### 3.2.7 Environment service + +There are no changes required in the Environment service itself, but we'll expose one additional property from its `setup` contract to the plugins: the paths to the known configuration files. The interactive setup mode should be able to figure out to which configuration file Kibana should save any changes users might need to make. + +### 3.2.8 Core app service + +We'll introduce a new `preboot` method in the Core app service to register routes on the preboot HTTP server necessary for the rendering of the Kibana preboot applications. Most of the routes will be the same as for the main HTTP server, but there are three notable exceptions: + +1. JS bundles routes will only include those exposed by the preboot plugins + +2. Default route for the preboot HTTP server will be hardcoded to the root path (`/`) since we cannot rely on the default value of the `defaultRoute` UI setting (`/app/home`) + +3. Main application route (`/app/{id}/{any*}`) will be replaced with the catch-all route (`/{path*}`). The reason is that if the user tries to access Kibana with a legit standard application URL (e.g. `/app/discover/?parameters`) while Kibana is still at the `preboot` stage, they will end up with `Application is not found` error. Instead, with the catch-all route, Kibana will capture the original URL in the `next` query string parameter and redirect the user to the root (e.g. `/?next=%2Fapp%2Fdiscover%2F%3Fparameters`). This will allow us to automatically redirect the user back to the original URL as soon as Kibana is ready. The main drawback and limitation of this approach are that there can be only one root-level preboot application. We can lift this limitation in the future if we have to though, for example, to support post-preboot Saved Objects migration UI or something similar. + +Serving a proper Kibana application on the root route of the preboot HTTP server implies that we'll also have a chance to replace the static `Kibana server is not ready yet` string with a more helpful and user-friendly application. Such application may potentially display a certain set of Kibana status information. + +### 3.2.9 Preboot service + +To support interactive applications at the `preboot` stage we should allow preboot plugins to pause Kibana startup sequence. This functionality will be exposed by the new Preboot service, and will be available to the preboot plugins via `CorePrebootSetup`. Preboot plugins will be able to provide a promise to hold `setup` and/or `start` for as long as needed, and also let Kibana know if it has to reload configuration before it enters the `setup` stage. + +```ts +export interface PrebootServiceSetup { + readonly isSetupOnHold: () => boolean; + readonly holdSetupUntilResolved: ( + reason: string, + promise: Promise<{ shouldReloadConfig: boolean } | void> + ) => void; + readonly isStartOnHold: () => boolean; + readonly holdStartUntilResolved: ( + reason: string, + promise: Promise + ) => void +} +``` + +Preboot service will provide a pair of helper `isSetupOnHold` and `isStartOnHold` methods that would allow consumers to check if `setup` or `start` are on hold before they are blocked on waiting. + +Internal Preboot service contract will also expose `waitUntilCanSetup` and `waitUntilCanStart` methods that bootstrap process can use to know when it can proceed to `setup` and `start` stages. If any of these methods returns a `Promise` that is rejected, Kibana will shut down. + +```ts +// NOTE(azasypkin): some existing properties have been omitted for brevity. +export interface InternalPrebootServiceSetup { + readonly waitUntilCanSetup: () => Promise<{ shouldReloadConfig: boolean } | void>; + readonly waitUntilCanStart: () => Promise; +} +``` + +### 3.2.10 Bootstrap + +We'll update Kibana bootstrap sequence to include `preboot` stage and to conditionally reload configuration before proceeding to `setup` and `start` stages: + +```ts +// NOTE(azasypkin): some functionality and checks have been omitted for brevity. +const { preboot } = await root.preboot(); + +const { shouldReloadConfig } = await preboot.waitUntilCanSetup(); +if (shouldReloadConfig) { + await reloadConfiguration('pre-boot request'); +} +await root.setup(); + +await preboot.waitUntilCanStart(); +await root.start(); +``` + +It's not yet clear if we need to adjust the base path proxy to account for this new lifecycle stage (see [unresolved question 2](#82-development-mode-and-basepath-proxy)). + +# 4. Drawbacks + +The main drawback is that proposed changes affect quite a few Kibana Core services that may impose a risk of breaking something in the critical parts of Kibana. + +# 5. Alternatives + +The most viable alternative to support interactive setup mode for Kibana was a standalone application that would be completely separated from Kibana. We ruled out this option since we won't be able to leverage existing and battle-tested Core services, UI components, and development tools. This would make the long-term maintenance burden unreasonably high. + +# 6. Adoption strategy + +The new `preboot` stage doesn't need an adoption strategy since it's intended for internal platform use only. + +# 7. How we teach this + +The new `preboot` stage shouldn't need much knowledge sharing since it's intended for internal platform use only and doesn't affect the standard plugins. All new services, methods, and contracts will be sufficiently documented in the code. + +# 8. Unresolved questions + +## 8.1 Lifecycle stage name + +Is `preboot` the right name for this new lifecycle stage? Do we have a better alternative? + +## 8.2 Development mode and basepath proxy + +Currently, the base path proxy blocks any requests to Kibana until it receives `SERVER_LISTENING` message. Kibana's main process sends this message only after `start`, but we should change that to support interactive preboot applications. It's not yet clear how big the impact of this change will be. + +# 9. Resolved questions + +## 9.1 Core client-side changes + +The server-side part of the `preboot` plugins will follow a new `PrebootPlugin` interface that doesn't have a `start` method, but the client-side part will stay the same as for standard plugins. This significantly simplifies implementation and doesn't introduce any known technical issues, but, unfortunately, brings some inconsistency to the codebase. We agreed that it's tolerable assuming we define a dedicated client-side `PrebootPlugin` interface that would hide from `CoreStart` all services that are unavailable to the preboot plugins (e.g., Saved Objects service). \ No newline at end of file From bfadab632463fc7b9e8ad50b51d0980829bd06a1 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 28 Jun 2021 12:05:46 +0200 Subject: [PATCH 02/65] [Lens] Improve pie suggestions (#102755) --- .../pie_visualization/suggestions.test.ts | 77 ++++++++++++++++++- .../public/pie_visualization/suggestions.ts | 18 +++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 5ff233b9e11e..a527a3c86454 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -6,7 +6,7 @@ */ import { PaletteOutput } from 'src/plugins/charts/public'; -import { DataType } from '../types'; +import { DataType, SuggestionRequest } from '../types'; import { suggestions } from './suggestions'; import { PieVisualizationState } from './types'; @@ -354,6 +354,81 @@ describe('suggestions', () => { ); }); + it('should score higher for more groups', () => { + const config: SuggestionRequest = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }; + const twoGroupsResults = suggestions(config); + config.table.columns.splice(1, 1); + const oneGroupResults = suggestions(config); + + expect(Math.max(...twoGroupsResults.map((suggestion) => suggestion.score))).toBeGreaterThan( + Math.max(...oneGroupResults.map((suggestion) => suggestion.score)) + ); + }); + + it('should score higher for more groups for each subvis with passed-in subvis id', () => { + const config: SuggestionRequest = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + subVisualizationId: 'donut', + }; + const twoGroupsResults = suggestions(config); + config.table.columns.splice(1, 1); + const oneGroupResults = suggestions(config); + // collect scores for one or two groups for each sub vis + const scores: Record = {}; + twoGroupsResults.forEach((r) => { + scores[r.state.shape] = { ...(scores[r.state.shape] || {}), two: r.score }; + }); + oneGroupResults.forEach((r) => { + scores[r.state.shape] = { ...(scores[r.state.shape] || {}), one: r.score }; + }); + expect(Object.keys(scores).length).toEqual(2); + Object.values(scores).forEach(({ one, two }) => { + expect(two).toBeGreaterThan(one); + }); + }); + it('should keep passed in palette', () => { const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; const results = suggestions({ diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 7240203267d7..644f0a0cd8aa 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -51,9 +51,10 @@ export function suggestions({ const results: Array> = []; - if (groups.length <= MAX_PIE_BUCKETS) { - let newShape: PieVisualizationState['shape'] = 'donut'; - if (groups.length !== 1) { + if (groups.length <= MAX_PIE_BUCKETS && subVisualizationId !== 'treemap') { + let newShape: PieVisualizationState['shape'] = + (subVisualizationId as PieVisualizationState['shape']) || 'donut'; + if (groups.length !== 1 && !subVisualizationId) { newShape = 'pie'; } @@ -108,7 +109,10 @@ export function suggestions({ }); } - if (groups.length <= MAX_TREEMAP_BUCKETS) { + if ( + groups.length <= MAX_TREEMAP_BUCKETS && + (!subVisualizationId || subVisualizationId === 'treemap') + ) { results.push({ title: i18n.translate('xpack.lens.pie.treemapSuggestionLabel', { defaultMessage: 'As Treemap', @@ -149,7 +153,11 @@ export function suggestions({ } return [...results] - .sort((a, b) => a.score - b.score) + .map((suggestion) => ({ + ...suggestion, + score: suggestion.score + 0.05 * groups.length, + })) + .sort((a, b) => b.score - a.score) .map((suggestion) => ({ ...suggestion, hide: incompleteConfiguration || suggestion.hide, From 96c4350289adace86b877e6836be0029a062f662 Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Mon, 28 Jun 2021 08:13:41 -0400 Subject: [PATCH 03/65] Remove post-installation redirect for integrations (#103179) When installation integrations via the browse -> add integration flow in the integrations UI, we will no longer redirect the user back to the integration details page. Closes #100978 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../sections/epm/screens/detail/index.tsx | 67 ++----------------- 1 file changed, 5 insertions(+), 62 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index cf6007026afe..e840da142cfb 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -6,7 +6,7 @@ */ import type { ReactEventHandler } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Redirect, Route, Switch, useLocation, useParams, useHistory } from 'react-router-dom'; +import { Redirect, Route, Switch, useLocation, useParams } from 'react-router-dom'; import styled from 'styled-components'; import { EuiBetaBadge, @@ -31,12 +31,7 @@ import { useBreadcrumbs, useStartServices, } from '../../../../hooks'; -import { - PLUGIN_ID, - INTEGRATIONS_PLUGIN_ID, - INTEGRATIONS_ROUTING_PATHS, - pagePathGetters, -} from '../../../../constants'; +import { PLUGIN_ID, INTEGRATIONS_ROUTING_PATHS, pagePathGetters } from '../../../../constants'; import { useCapabilities, useGetPackageInfoByKey, @@ -44,11 +39,7 @@ import { useAgentPolicyContext, } from '../../../../hooks'; import { pkgKeyFromPackageInfo } from '../../../../services'; -import type { - CreatePackagePolicyRouteState, - DetailViewPanelName, - PackageInfo, -} from '../../../../types'; +import type { DetailViewPanelName, PackageInfo } from '../../../../types'; import { InstallStatus } from '../../../../types'; import { Error, Loading } from '../../../../components'; import type { WithHeaderLayoutProps } from '../../../../layouts'; @@ -89,8 +80,7 @@ export function Detail() { const { pkgkey, panel } = useParams(); const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; - const history = useHistory(); - const { pathname, search, hash } = useLocation(); + const { search } = useLocation(); const queryParams = useMemo(() => new URLSearchParams(search), [search]); const integration = useMemo(() => queryParams.get('integration'), [queryParams]); const services = useStartServices(); @@ -212,66 +202,19 @@ export function Detail() { (ev) => { ev.preventDefault(); - // The object below, given to `createHref` is explicitly accessing keys of `location` in order - // to ensure that dependencies to this `useCallback` is set correctly (because `location` is mutable) - const currentPath = history.createHref({ - pathname, - search, - hash, - }); - const path = pagePathGetters.add_integration_to_policy({ pkgkey, ...(integration ? { integration } : {}), ...(agentPolicyIdFromContext ? { agentPolicyId: agentPolicyIdFromContext } : {}), })[1]; - let redirectToPath: CreatePackagePolicyRouteState['onSaveNavigateTo'] & - CreatePackagePolicyRouteState['onCancelNavigateTo']; - - if (agentPolicyIdFromContext) { - redirectToPath = [ - PLUGIN_ID, - { - path: `#${ - pagePathGetters.policy_details({ - policyId: agentPolicyIdFromContext, - })[1] - }`, - }, - ]; - } else { - redirectToPath = [ - INTEGRATIONS_PLUGIN_ID, - { - path: currentPath, - }, - ]; - } - - const redirectBackRouteState: CreatePackagePolicyRouteState = { - onSaveNavigateTo: redirectToPath, - onCancelNavigateTo: redirectToPath, - onCancelUrl: currentPath, - }; - services.application.navigateToApp(PLUGIN_ID, { // Necessary because of Fleet's HashRouter. Can be changed when // https://github.com/elastic/kibana/issues/96134 is resolved path: `#${path}`, - state: redirectBackRouteState, }); }, - [ - history, - hash, - pathname, - search, - pkgkey, - integration, - services.application, - agentPolicyIdFromContext, - ] + [pkgkey, integration, services.application, agentPolicyIdFromContext] ); const headerRightContent = useMemo( From f89dc9cc31aa139f47d83133bdc10145ab7c0468 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 28 Jun 2021 14:16:08 +0200 Subject: [PATCH 04/65] sanitize drilldown (#103299) --- .../application/components/vis_types/table/vis.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js index faf6fef0aa54..8f19644132d3 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -8,6 +8,7 @@ import _, { isArray, last, get } from 'lodash'; import React, { Component } from 'react'; +import { parse as parseUrl } from 'url'; import PropTypes from 'prop-types'; import { RedirectAppLinks } from '../../../../../../kibana_react/public'; import { createTickFormatter } from '../../lib/tick_formatter'; @@ -33,6 +34,14 @@ function getColor(rules, colorKey, value) { return color; } +function sanitizeUrl(url) { + // eslint-disable-next-line no-script-url + if (parseUrl(url).protocol === 'javascript:') { + return ''; + } + return url; +} + class TableVis extends Component { constructor(props) { super(props); @@ -52,7 +61,7 @@ class TableVis extends Component { let rowDisplay = model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key; if (model.drilldown_url) { const url = replaceVars(model.drilldown_url, {}, { key: row.key }); - rowDisplay = {rowDisplay}; + rowDisplay = {rowDisplay}; } const columns = row.series .filter((item) => item) From 2ff2a6fa5029671cb5cc8d68a53bdf0a63d75ad1 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Mon, 28 Jun 2021 09:47:32 -0400 Subject: [PATCH 05/65] Adding tooltip to rules that are disabled due to license (#103295) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/alerts_list.test.tsx | 120 ++++++++++++++++++ .../alerts_list/components/alerts_list.tsx | 37 ++++-- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index 01e63f2c6081..311166f09e46 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -69,6 +69,7 @@ const alertTypeFromApi = { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, minimumLicenseRequired: 'basic', + enabledInLicense: true, authorizedConsumers: { [ALERTS_FEATURE_ID]: { read: true, all: true }, }, @@ -520,3 +521,122 @@ describe('alerts_list with show only capability', () => { // TODO: check delete button }); }); + +describe('alerts_list with disabled itmes', () => { + let wrapper: ReactWrapper; + + async function setup() { + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + name: 'test alert', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + { + id: '2', + name: 'test alert 2', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type_disabled_by_license', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + ], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + + loadAlertTypes.mockResolvedValue([ + alertTypeFromApi, + { + id: 'test_alert_type_disabled_by_license', + name: 'some alert type that is not allowed', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + producer: ALERTS_FEATURE_ID, + minimumLicenseRequired: 'platinum', + enabledInLicense: false, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + }, + }, + ]); + loadAllActions.mockResolvedValue([]); + + alertTypeRegistry.has.mockReturnValue(false); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + it('renders rules list with disabled indicator if disabled due to license', async () => { + await setup(); + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + expect(wrapper.find('EuiTableRow').at(0).prop('className')).toEqual(''); + expect(wrapper.find('EuiTableRow').at(1).prop('className')).toEqual( + 'actAlertsList__tableRowDisabled' + ); + expect(wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').length).toBe( + 1 + ); + expect( + wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().type + ).toEqual('questionInCircle'); + expect( + wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().content + ).toEqual('This rule type requires a Platinum license.'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 1fb688c4dd6b..1c1633ff4a72 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -18,6 +18,7 @@ import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem, + EuiIconTip, EuiSpacer, EuiLink, EuiEmptyPrompt, @@ -63,6 +64,7 @@ import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import './alerts_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import { ManageLicenseModal } from './manage_license_modal'; +import { checkAlertTypeEnabled } from '../../../lib/check_alert_type_enabled'; const ENTER_KEY = 13; @@ -318,15 +320,32 @@ export const AlertsList: React.FunctionComponent = () => { width: '35%', 'data-test-subj': 'alertsTableCell-name', render: (name: string, alert: AlertTableItem) => { - return ( - { - history.push(routeToRuleDetails.replace(`:ruleId`, alert.id)); - }} - > - {name} - + const ruleType = alertTypesState.data.get(alert.alertTypeId); + const checkEnabledResult = checkAlertTypeEnabled(ruleType); + const link = ( + <> + { + history.push(routeToRuleDetails.replace(`:ruleId`, alert.id)); + }} + > + {name} + + + ); + return checkEnabledResult.isEnabled ? ( + link + ) : ( + <> + {link} + + ); }, }, From 4f45535c90bdf2f273e9def0b9d0cf2efcf8fa39 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 28 Jun 2021 15:55:53 +0200 Subject: [PATCH 06/65] [Exploratory view] Update types names (#103214) --- .../apm/service_latency_config.ts | 53 ------ .../configurations/constants/constants.ts | 2 + .../configurations/constants/url_constants.ts | 1 + .../configurations/lens_attributes.test.ts | 57 +++++-- .../configurations/lens_attributes.ts | 154 +++++++++--------- .../metrics/cpu_usage_config.ts | 38 ----- .../metrics/memory_usage_config.ts | 38 ----- .../metrics/network_activity_config.ts | 37 ----- .../mobile/device_distribution_config.ts | 17 +- .../mobile/distribution_config.ts | 55 +++---- .../mobile/kpi_over_time_config.ts | 77 ++++----- .../rum/core_web_vitals_config.ts | 127 +++++++-------- .../rum/data_distribution_config.ts | 48 ++---- .../rum/kpi_over_time_config.ts | 58 +++---- .../synthetics/data_distribution_config.ts | 34 ++-- .../synthetics/kpi_over_time_config.ts | 53 +++--- .../test_data/sample_attribute_kpi.ts | 71 ++++++++ .../exploratory_view/configurations/utils.ts | 2 + .../hooks/use_lens_attributes.ts | 26 +-- .../hooks/use_series_storage.tsx | 4 +- .../columns/report_breakdowns.test.tsx | 6 +- .../columns/report_breakdowns.tsx | 10 +- .../columns/report_definition_col.test.tsx | 8 +- .../columns/report_definition_col.tsx | 56 ++++--- .../columns/report_definition_field.tsx | 16 +- .../columns/report_filters.test.tsx | 2 +- .../series_builder/columns/report_filters.tsx | 14 +- .../columns/report_types_col.test.tsx | 2 +- .../columns/report_types_col.tsx | 12 +- ...rt_field.tsx => report_metric_options.tsx} | 18 +- .../series_builder/series_builder.tsx | 17 +- .../series_editor/chart_edit_options.tsx | 12 +- .../series_editor/columns/breakdowns.test.tsx | 8 +- .../series_editor/columns/breakdowns.tsx | 10 +- .../series_editor/columns/chart_options.tsx | 13 +- .../series_editor/columns/filter_expanded.tsx | 4 +- .../series_editor/columns/series_filter.tsx | 20 +-- .../series_editor/selected_filters.test.tsx | 4 +- .../series_editor/selected_filters.tsx | 10 +- .../series_editor/series_editor.tsx | 16 +- .../shared/exploratory_view/types.ts | 33 ++-- 41 files changed, 543 insertions(+), 700 deletions(-) delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts rename x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/{custom_report_field.tsx => report_metric_options.tsx} (66%) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts deleted file mode 100644 index 7c3abba3e5b0..000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/apm/service_latency_config.ts +++ /dev/null @@ -1,53 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels } from '../constants'; -import { buildPhraseFilter } from '../utils'; -import { TRANSACTION_DURATION } from '../constants/elasticsearch_fieldnames'; - -export function getServiceLatencyLensConfig({ indexPattern }: ConfigProps): DataSeries { - return { - reportType: 'kpi-over-time', - defaultSeriesType: 'line', - seriesTypes: ['line', 'bar'], - xAxisColumn: { - sourceField: '@timestamp', - }, - yAxisColumns: [ - { - operationType: 'average', - sourceField: 'transaction.duration.us', - label: 'Latency', - }, - ], - hasOperationType: true, - defaultFilters: [ - 'user_agent.name', - 'user_agent.os.name', - 'client.geo.country_name', - 'user_agent.device.name', - ], - breakdowns: [ - 'user_agent.name', - 'user_agent.os.name', - 'client.geo.country_name', - 'user_agent.device.name', - ], - filters: buildPhraseFilter('transaction.type', 'request', indexPattern), - labels: { ...FieldLabels, [TRANSACTION_DURATION]: 'Latency' }, - reportDefinitions: [ - { - field: 'service.name', - required: true, - }, - { - field: 'service.environment', - }, - ], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index 01e8d023ae96..52faa2dccaea 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -96,3 +96,5 @@ export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN'; export const FILTER_RECORDS = 'FILTER_RECORDS'; export const TERMS_COLUMN = 'TERMS_COLUMN'; export const OPERATION_COLUMN = 'operation'; + +export const REPORT_METRIC_FIELD = 'REPORT_METRIC_FIELD'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts index b5a5169216b7..6f990015fbc6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts @@ -13,4 +13,5 @@ export enum URL_KEYS { BREAK_DOWN = 'bd', FILTERS = 'ft', REPORT_DEFINITIONS = 'rdf', + SELECTED_METRIC = 'mt', } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 5189a529bda8..72b4bd7919c3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -9,8 +9,14 @@ import { LayerConfig, LensAttributes } from './lens_attributes'; import { mockAppIndexPattern, mockIndexPattern } from '../rtl_helpers'; import { getDefaultConfigs } from './default_configs'; import { sampleAttribute } from './test_data/sample_attribute'; -import { LCP_FIELD, USER_AGENT_NAME } from './constants/elasticsearch_fieldnames'; +import { + LCP_FIELD, + TRANSACTION_DURATION, + USER_AGENT_NAME, +} from './constants/elasticsearch_fieldnames'; import { buildExistsFilter, buildPhrasesFilter } from './utils'; +import { sampleAttributeKpi } from './test_data/sample_attribute_kpi'; +import { REPORT_METRIC_FIELD } from './constants'; describe('Lens Attribute', () => { mockAppIndexPattern(); @@ -21,12 +27,12 @@ describe('Lens Attribute', () => { indexPattern: mockIndexPattern, }); - reportViewConfig.filters?.push(...buildExistsFilter('transaction.type', mockIndexPattern)); + reportViewConfig.baseFilters?.push(...buildExistsFilter('transaction.type', mockIndexPattern)); let lnsAttr: LensAttributes; const layerConfig: LayerConfig = { - reportConfig: reportViewConfig, + seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', indexPattern: mockIndexPattern, @@ -42,6 +48,27 @@ describe('Lens Attribute', () => { expect(lnsAttr.getJSON()).toEqual(sampleAttribute); }); + it('should return expected json for kpi report type', function () { + const seriesConfigKpi = getDefaultConfigs({ + reportType: 'kpi-over-time', + dataType: 'ux', + indexPattern: mockIndexPattern, + }); + + const lnsAttrKpi = new LensAttributes([ + { + seriesConfig: seriesConfigKpi, + seriesType: 'line', + operationType: 'count', + indexPattern: mockIndexPattern, + reportDefinitions: { 'service.name': ['elastic-co'] }, + time: { from: 'now-15m', to: 'now' }, + }, + ]); + + expect(lnsAttrKpi.getJSON()).toEqual(sampleAttributeKpi); + }); + it('should return main y axis', function () { expect(lnsAttr.getMainYAxis(layerConfig)).toEqual({ dataType: 'number', @@ -72,7 +99,7 @@ describe('Lens Attribute', () => { }); it('should return expected field type for custom field with default value', function () { - expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric', layerConfig))).toEqual( + expect(JSON.stringify(lnsAttr.getFieldMeta(REPORT_METRIC_FIELD, layerConfig))).toEqual( JSON.stringify({ fieldMeta: { count: 0, @@ -92,7 +119,7 @@ describe('Lens Attribute', () => { it('should return expected field type for custom field with passed value', function () { const layerConfig1: LayerConfig = { - reportConfig: reportViewConfig, + seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', indexPattern: mockIndexPattern, @@ -102,20 +129,20 @@ describe('Lens Attribute', () => { lnsAttr = new LensAttributes([layerConfig1]); - expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric', layerConfig1))).toEqual( + expect(JSON.stringify(lnsAttr.getFieldMeta(REPORT_METRIC_FIELD, layerConfig1))).toEqual( JSON.stringify({ fieldMeta: { count: 0, - name: LCP_FIELD, + name: TRANSACTION_DURATION, type: 'number', - esTypes: ['scaled_float'], + esTypes: ['long'], scripted: false, searchable: true, aggregatable: true, readFromDocValues: true, }, - fieldName: LCP_FIELD, - columnLabel: 'Largest contentful paint', + fieldName: TRANSACTION_DURATION, + columnLabel: 'Page load time', }) ); }); @@ -269,7 +296,7 @@ describe('Lens Attribute', () => { describe('Layer breakdowns', function () { it('should return breakdown column', function () { const layerConfig1: LayerConfig = { - reportConfig: reportViewConfig, + seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', indexPattern: mockIndexPattern, @@ -322,7 +349,7 @@ describe('Lens Attribute', () => { 'x-axis-column-layer0': { dataType: 'number', isBucketed: true, - label: 'Largest contentful paint', + label: 'Page load time', operationType: 'range', params: { maxBars: 'auto', @@ -330,7 +357,7 @@ describe('Lens Attribute', () => { type: 'histogram', }, scale: 'interval', - sourceField: 'transaction.marks.agent.largestContentfulPaint', + sourceField: 'transaction.duration.us', }, 'y-axis-column-layer0': { dataType: 'number', @@ -353,12 +380,12 @@ describe('Lens Attribute', () => { describe('Layer Filters', function () { it('should return expected filters', function () { - reportViewConfig.filters?.push( + reportViewConfig.baseFilters?.push( ...buildPhrasesFilter('service.name', ['elastic', 'kibana'], mockIndexPattern) ); const layerConfig1: LayerConfig = { - reportConfig: reportViewConfig, + seriesConfig: reportViewConfig, seriesType: 'line', operationType: 'count', indexPattern: mockIndexPattern, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 208e8d8ba43c..eaf9c1c884a9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -29,8 +29,14 @@ import { } from '../../../../../../lens/public'; import { urlFiltersToKueryString } from '../utils/stringify_kueries'; import { ExistsFilter, IndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { FieldLabels, FILTER_RECORDS, USE_BREAK_DOWN_COLUMN, TERMS_COLUMN } from './constants'; -import { ColumnFilter, DataSeries, UrlFilter, URLReportDefinition } from '../types'; +import { + FieldLabels, + FILTER_RECORDS, + USE_BREAK_DOWN_COLUMN, + TERMS_COLUMN, + REPORT_METRIC_FIELD, +} from './constants'; +import { ColumnFilter, SeriesConfig, UrlFilter, URLReportDefinition } from '../types'; import { PersistableFilter } from '../../../../../../lens/common'; import { parseAbsoluteDate } from '../series_date_picker/date_range_picker'; @@ -47,54 +53,47 @@ function buildNumberColumn(sourceField: string) { }; } -export const parseCustomFieldName = ( - sourceField: string, - reportViewConfig: DataSeries, - selectedDefinitions: URLReportDefinition -) => { - let fieldName = sourceField; +export const parseCustomFieldName = (seriesConfig: SeriesConfig, selectedMetricField?: string) => { let columnType; let columnFilters; let timeScale; let columnLabel; - const rdf = reportViewConfig.reportDefinitions ?? []; - - const customField = rdf.find(({ field }) => field === fieldName); - - if (customField) { - if (selectedDefinitions[fieldName]) { - fieldName = selectedDefinitions[fieldName][0]; - if (customField?.options) { - const currField = customField?.options?.find( - ({ field, id }) => field === fieldName || id === fieldName - ); - columnType = currField?.columnType; - columnFilters = currField?.columnFilters; - timeScale = currField?.timeScale; - columnLabel = currField?.label; - } - } else if (customField.options?.[0].field || customField.options?.[0].id) { - fieldName = customField.options?.[0].field || customField.options?.[0].id; - columnType = customField.options?.[0].columnType; - columnFilters = customField.options?.[0].columnFilters; - timeScale = customField.options?.[0].timeScale; - columnLabel = customField.options?.[0].label; + const metricOptions = seriesConfig.metricOptions ?? []; + + if (selectedMetricField) { + if (metricOptions) { + const currField = metricOptions.find( + ({ field, id }) => field === selectedMetricField || id === selectedMetricField + ); + columnType = currField?.columnType; + columnFilters = currField?.columnFilters; + timeScale = currField?.timeScale; + columnLabel = currField?.label; } + } else if (metricOptions?.[0].field || metricOptions?.[0].id) { + const firstMetricOption = metricOptions?.[0]; + + selectedMetricField = firstMetricOption.field || firstMetricOption.id; + columnType = firstMetricOption.columnType; + columnFilters = firstMetricOption.columnFilters; + timeScale = firstMetricOption.timeScale; + columnLabel = firstMetricOption.label; } - return { fieldName, columnType, columnFilters, timeScale, columnLabel }; + return { fieldName: selectedMetricField!, columnType, columnFilters, timeScale, columnLabel }; }; export interface LayerConfig { filters?: UrlFilter[]; - reportConfig: DataSeries; + seriesConfig: SeriesConfig; breakdown?: string; seriesType?: SeriesType; operationType?: OperationType; reportDefinitions: URLReportDefinition; time: { to: string; from: string }; indexPattern: IndexPattern; + selectedMetricField?: string; } export class LensAttributes { @@ -105,9 +104,9 @@ export class LensAttributes { constructor(layerConfigs: LayerConfig[]) { this.layers = {}; - layerConfigs.forEach(({ reportConfig, operationType }) => { + layerConfigs.forEach(({ seriesConfig, operationType }) => { if (operationType) { - reportConfig.yAxisColumns.forEach((yAxisColumn) => { + seriesConfig.yAxisColumns.forEach((yAxisColumn) => { if (typeof yAxisColumn.operationType !== undefined) { yAxisColumn.operationType = operationType as FieldBasedIndexPatternColumn['operationType']; } @@ -150,12 +149,12 @@ export class LensAttributes { getNumberRangeColumn( sourceField: string, - reportViewConfig: DataSeries, + seriesConfig: SeriesConfig, label?: string ): RangeIndexPatternColumn { return { sourceField, - label: reportViewConfig.labels[sourceField] ?? label, + label: seriesConfig.labels[sourceField] ?? label, dataType: 'number', operationType: 'range', isBucketed: true, @@ -171,22 +170,22 @@ export class LensAttributes { getCardinalityColumn({ sourceField, label, - reportViewConfig, + seriesConfig, }: { sourceField: string; label?: string; - reportViewConfig: DataSeries; + seriesConfig: SeriesConfig; }) { return this.getNumberOperationColumn({ sourceField, operationType: 'unique_count', label, - reportViewConfig, + seriesConfig, }); } getNumberColumn({ - reportViewConfig, + seriesConfig, label, sourceField, columnType, @@ -196,7 +195,7 @@ export class LensAttributes { columnType?: string; operationType?: string; label?: string; - reportViewConfig: DataSeries; + seriesConfig: SeriesConfig; }) { if (columnType === 'operation' || operationType) { if ( @@ -209,26 +208,26 @@ export class LensAttributes { sourceField, operationType, label, - reportViewConfig, + seriesConfig, }); } if (operationType?.includes('th')) { - return this.getPercentileNumberColumn(sourceField, operationType, reportViewConfig!); + return this.getPercentileNumberColumn(sourceField, operationType, seriesConfig!); } } - return this.getNumberRangeColumn(sourceField, reportViewConfig!, label); + return this.getNumberRangeColumn(sourceField, seriesConfig!, label); } getNumberOperationColumn({ sourceField, label, - reportViewConfig, + seriesConfig, operationType, }: { sourceField: string; operationType: 'average' | 'median' | 'sum' | 'unique_count'; label?: string; - reportViewConfig: DataSeries; + seriesConfig: SeriesConfig; }): | AvgIndexPatternColumn | MedianIndexPatternColumn @@ -239,7 +238,7 @@ export class LensAttributes { label: i18n.translate('xpack.observability.expView.columns.operation.label', { defaultMessage: '{operationType} of {sourceField}', values: { - sourceField: label || reportViewConfig.labels[sourceField], + sourceField: label || seriesConfig.labels[sourceField], operationType: capitalize(operationType), }, }), @@ -250,13 +249,13 @@ export class LensAttributes { getPercentileNumberColumn( sourceField: string, percentileValue: string, - reportViewConfig: DataSeries + seriesConfig: SeriesConfig ): PercentileIndexPatternColumn { return { ...buildNumberColumn(sourceField), label: i18n.translate('xpack.observability.expView.columns.label', { defaultMessage: '{percentileValue} percentile of {sourceField}', - values: { sourceField: reportViewConfig.labels[sourceField], percentileValue }, + values: { sourceField: seriesConfig.labels[sourceField], percentileValue }, }), operationType: 'percentile', params: { percentile: Number(percentileValue.split('th')[0]) }, @@ -295,13 +294,13 @@ export class LensAttributes { } getXAxis(layerConfig: LayerConfig, layerId: string) { - const { xAxisColumn } = layerConfig.reportConfig; + const { xAxisColumn } = layerConfig.seriesConfig; if (xAxisColumn?.sourceField === USE_BREAK_DOWN_COLUMN) { return this.getBreakdownColumn({ layerId, indexPattern: layerConfig.indexPattern, - sourceField: layerConfig.breakdown || layerConfig.reportConfig.breakdowns[0], + sourceField: layerConfig.breakdown || layerConfig.seriesConfig.breakdownFields[0], }); } @@ -333,6 +332,7 @@ export class LensAttributes { timeScale, columnFilters, } = this.getFieldMeta(sourceField, layerConfig); + const { type: fieldType } = fieldMeta ?? {}; if (columnType === TERMS_COLUMN) { @@ -356,14 +356,14 @@ export class LensAttributes { columnType, operationType, label: columnLabel || label, - reportViewConfig: layerConfig.reportConfig, + seriesConfig: layerConfig.seriesConfig, }); } if (operationType === 'unique_count') { return this.getCardinalityColumn({ sourceField: fieldName, label: columnLabel || label, - reportViewConfig: layerConfig.reportConfig, + seriesConfig: layerConfig.seriesConfig, }); } @@ -378,32 +378,26 @@ export class LensAttributes { sourceField: string; layerConfig: LayerConfig; }) { - return parseCustomFieldName( - sourceField, - layerConfig.reportConfig, - layerConfig.reportDefinitions - ); + return parseCustomFieldName(layerConfig.seriesConfig, sourceField); } getFieldMeta(sourceField: string, layerConfig: LayerConfig) { - const { - fieldName, - columnType, - columnLabel, - columnFilters, - timeScale, - } = this.getCustomFieldName({ - sourceField, - layerConfig, - }); - - const fieldMeta = layerConfig.indexPattern.getFieldByName(fieldName); + if (sourceField === REPORT_METRIC_FIELD) { + const { fieldName, columnType, columnLabel, columnFilters, timeScale } = parseCustomFieldName( + layerConfig.seriesConfig, + layerConfig.selectedMetricField + ); + const fieldMeta = layerConfig.indexPattern.getFieldByName(fieldName!); + return { fieldMeta, fieldName, columnType, columnLabel, columnFilters, timeScale }; + } else { + const fieldMeta = layerConfig.indexPattern.getFieldByName(sourceField); - return { fieldMeta, fieldName, columnType, columnLabel, columnFilters, timeScale }; + return { fieldMeta, fieldName: sourceField }; + } } getMainYAxis(layerConfig: LayerConfig) { - const { sourceField, operationType, label } = layerConfig.reportConfig.yAxisColumns[0]; + const { sourceField, operationType, label } = layerConfig.seriesConfig.yAxisColumns[0]; if (sourceField === 'Records' || !sourceField) { return this.getRecordsColumn(label); @@ -420,7 +414,7 @@ export class LensAttributes { getChildYAxises(layerConfig: LayerConfig) { const lensColumns: Record = {}; - const yAxisColumns = layerConfig.reportConfig.yAxisColumns; + const yAxisColumns = layerConfig.seriesConfig.yAxisColumns; // 1 means there is only main y axis if (yAxisColumns.length === 1) { return lensColumns; @@ -460,7 +454,7 @@ export class LensAttributes { const { filters, time: { from, to }, - reportConfig: { filters: layerFilters, reportType }, + seriesConfig: { baseFilters: layerFilters, reportType }, } = layerConfig; let baseFilters = ''; if (reportType !== 'kpi-over-time' && totalLayers > 1) { @@ -522,7 +516,7 @@ export class LensAttributes { } getTimeShift(mainLayerConfig: LayerConfig, layerConfig: LayerConfig, index: number) { - if (index === 0 || mainLayerConfig.reportConfig.reportType !== 'kpi-over-time') { + if (index === 0 || mainLayerConfig.seriesConfig.reportType !== 'kpi-over-time') { return null; } @@ -603,16 +597,16 @@ export class LensAttributes { ...Object.keys(this.getChildYAxises(layerConfig)), ], layerId: `layer${index}`, - seriesType: layerConfig.seriesType || layerConfig.reportConfig.defaultSeriesType, - palette: layerConfig.reportConfig.palette, - yConfig: layerConfig.reportConfig.yConfig || [ + seriesType: layerConfig.seriesType || layerConfig.seriesConfig.defaultSeriesType, + palette: layerConfig.seriesConfig.palette, + yConfig: layerConfig.seriesConfig.yConfig || [ { forAccessor: `y-axis-column-layer${index}` }, ], xAccessor: `x-axis-column-layer${index}`, ...(layerConfig.breakdown ? { splitAccessor: `breakdown-column-layer${index}` } : {}), })), - ...(this.layerConfigs[0].reportConfig.yTitle - ? { yTitle: this.layerConfigs[0].reportConfig.yTitle } + ...(this.layerConfigs[0].seriesConfig.yTitle + ? { yTitle: this.layerConfigs[0].seriesConfig.yTitle } : {}), }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts deleted file mode 100644 index 2d44e122af82..000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/cpu_usage_config.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DataSeries, ConfigProps } from '../../types'; -import { FieldLabels } from '../constants'; - -export function getCPUUsageLensConfig({}: ConfigProps): DataSeries { - return { - reportType: 'kpi-over-time', - defaultSeriesType: 'line', - seriesTypes: ['line', 'bar'], - xAxisColumn: { - sourceField: '@timestamp', - }, - yAxisColumns: [ - { - operationType: 'average', - sourceField: 'system.cpu.user.pct', - label: 'CPU Usage %', - }, - ], - hasOperationType: true, - defaultFilters: [], - breakdowns: ['host.hostname'], - filters: [], - labels: { ...FieldLabels, 'host.hostname': 'Host name' }, - reportDefinitions: [ - { - field: 'agent.hostname', - required: true, - }, - ], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts deleted file mode 100644 index deaa551dce65..000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/memory_usage_config.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DataSeries, ConfigProps } from '../../types'; -import { FieldLabels } from '../constants'; - -export function getMemoryUsageLensConfig({}: ConfigProps): DataSeries { - return { - reportType: 'kpi-over-time', - defaultSeriesType: 'line', - seriesTypes: ['line', 'bar'], - xAxisColumn: { - sourceField: '@timestamp', - }, - yAxisColumns: [ - { - operationType: 'average', - sourceField: 'system.memory.used.pct', - label: 'Memory Usage %', - }, - ], - hasOperationType: true, - defaultFilters: [], - breakdowns: ['host.hostname'], - filters: [], - labels: { ...FieldLabels, 'host.hostname': 'Host name' }, - reportDefinitions: [ - { - field: 'host.hostname', - required: true, - }, - ], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts deleted file mode 100644 index d27cdba207d6..000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/metrics/network_activity_config.ts +++ /dev/null @@ -1,37 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DataSeries, ConfigProps } from '../../types'; -import { FieldLabels } from '../constants'; - -export function getNetworkActivityLensConfig({}: ConfigProps): DataSeries { - return { - reportType: 'kpi-over-time', - defaultSeriesType: 'line', - seriesTypes: ['line', 'bar'], - xAxisColumn: { - sourceField: '@timestamp', - }, - yAxisColumns: [ - { - operationType: 'average', - sourceField: 'system.memory.used.pct', - }, - ], - hasOperationType: true, - defaultFilters: [], - breakdowns: ['host.hostname'], - filters: [], - labels: { ...FieldLabels, 'host.hostname': 'Host name' }, - reportDefinitions: [ - { - field: 'host.hostname', - required: true, - }, - ], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts index e1cb5a0370fb..98979b9922a8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; +import { ConfigProps, SeriesConfig } from '../../types'; import { FieldLabels, USE_BREAK_DOWN_COLUMN } from '../constants'; import { buildPhraseFilter } from '../utils'; import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels'; import { MobileFields } from './mobile_fields'; -export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): DataSeries { +export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { return { reportType: 'device-data-distribution', defaultSeriesType: 'bar', @@ -28,9 +28,9 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps) }, ], hasOperationType: false, - defaultFilters: Object.keys(MobileFields), - breakdowns: Object.keys(MobileFields), - filters: [ + filterFields: Object.keys(MobileFields), + breakdownFields: Object.keys(MobileFields), + baseFilters: [ ...buildPhraseFilter('agent.name', 'iOS/swift', indexPattern), ...buildPhraseFilter('processor.event', 'transaction', indexPattern), ], @@ -39,11 +39,6 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps) ...MobileFields, [SERVICE_NAME]: MOBILE_APP, }, - reportDefinitions: [ - { - field: SERVICE_NAME, - required: true, - }, - ], + definitionFields: [SERVICE_NAME], }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts index 62dd38e55a32..b9894347d96c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; import { buildPhrasesFilter } from '../utils'; import { METRIC_SYSTEM_CPU_USAGE, @@ -19,13 +19,13 @@ import { import { CPU_USAGE, MEMORY_USAGE, MOBILE_APP, RESPONSE_LATENCY } from '../constants/labels'; import { MobileFields } from './mobile_fields'; -export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): DataSeries { +export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { return { reportType: 'data-distribution', defaultSeriesType: 'bar', seriesTypes: ['line', 'bar'], xAxisColumn: { - sourceField: 'performance.metric', + sourceField: REPORT_METRIC_FIELD, }, yAxisColumns: [ { @@ -33,9 +33,9 @@ export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): D }, ], hasOperationType: false, - defaultFilters: Object.keys(MobileFields), - breakdowns: Object.keys(MobileFields), - filters: [ + filterFields: Object.keys(MobileFields), + breakdownFields: Object.keys(MobileFields), + baseFilters: [ ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], indexPattern), ], labels: { @@ -43,38 +43,25 @@ export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): D ...MobileFields, [SERVICE_NAME]: MOBILE_APP, }, - reportDefinitions: [ + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ { - field: SERVICE_NAME, - required: true, + label: RESPONSE_LATENCY, + field: TRANSACTION_DURATION, + id: TRANSACTION_DURATION, + columnType: OPERATION_COLUMN, }, { - field: SERVICE_ENVIRONMENT, - required: true, + label: MEMORY_USAGE, + field: METRIC_SYSTEM_MEMORY_USAGE, + id: METRIC_SYSTEM_MEMORY_USAGE, + columnType: OPERATION_COLUMN, }, { - field: 'performance.metric', - custom: true, - options: [ - { - label: RESPONSE_LATENCY, - field: TRANSACTION_DURATION, - id: TRANSACTION_DURATION, - columnType: OPERATION_COLUMN, - }, - { - label: MEMORY_USAGE, - field: METRIC_SYSTEM_MEMORY_USAGE, - id: METRIC_SYSTEM_MEMORY_USAGE, - columnType: OPERATION_COLUMN, - }, - { - label: CPU_USAGE, - field: METRIC_SYSTEM_CPU_USAGE, - id: METRIC_SYSTEM_CPU_USAGE, - columnType: OPERATION_COLUMN, - }, - ], + label: CPU_USAGE, + field: METRIC_SYSTEM_CPU_USAGE, + id: METRIC_SYSTEM_CPU_USAGE, + columnType: OPERATION_COLUMN, }, ], }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts index 9a2e86a8f796..945a631078a3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; import { buildPhrasesFilter } from '../utils'; import { METRIC_SYSTEM_CPU_USAGE, @@ -24,7 +24,7 @@ import { } from '../constants/labels'; import { MobileFields } from './mobile_fields'; -export function getMobileKPIConfig({ indexPattern }: ConfigProps): DataSeries { +export function getMobileKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { return { reportType: 'kpi-over-time', defaultSeriesType: 'line', @@ -34,14 +34,14 @@ export function getMobileKPIConfig({ indexPattern }: ConfigProps): DataSeries { }, yAxisColumns: [ { - sourceField: 'business.kpi', + sourceField: REPORT_METRIC_FIELD, operationType: 'median', }, ], hasOperationType: true, - defaultFilters: Object.keys(MobileFields), - breakdowns: Object.keys(MobileFields), - filters: [ + filterFields: Object.keys(MobileFields), + breakdownFields: Object.keys(MobileFields), + baseFilters: [ ...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], indexPattern), ], labels: { @@ -52,50 +52,37 @@ export function getMobileKPIConfig({ indexPattern }: ConfigProps): DataSeries { [METRIC_SYSTEM_MEMORY_USAGE]: MEMORY_USAGE, [METRIC_SYSTEM_CPU_USAGE]: CPU_USAGE, }, - reportDefinitions: [ + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ { - field: SERVICE_NAME, - required: true, + label: RESPONSE_LATENCY, + field: TRANSACTION_DURATION, + id: TRANSACTION_DURATION, + columnType: OPERATION_COLUMN, }, { - field: SERVICE_ENVIRONMENT, - required: true, - }, - { - field: 'business.kpi', - custom: true, - options: [ - { - label: RESPONSE_LATENCY, - field: TRANSACTION_DURATION, - id: TRANSACTION_DURATION, - columnType: OPERATION_COLUMN, - }, - { - field: RECORDS_FIELD, - id: RECORDS_FIELD, - label: TRANSACTIONS_PER_MINUTE, - columnFilters: [ - { - language: 'kuery', - query: `processor.event: transaction`, - }, - ], - timeScale: 'm', - }, + field: RECORDS_FIELD, + id: RECORDS_FIELD, + label: TRANSACTIONS_PER_MINUTE, + columnFilters: [ { - label: MEMORY_USAGE, - field: METRIC_SYSTEM_MEMORY_USAGE, - id: METRIC_SYSTEM_MEMORY_USAGE, - columnType: OPERATION_COLUMN, - }, - { - label: CPU_USAGE, - field: METRIC_SYSTEM_CPU_USAGE, - id: METRIC_SYSTEM_CPU_USAGE, - columnType: OPERATION_COLUMN, + language: 'kuery', + query: `processor.event: transaction`, }, ], + timeScale: 'm', + }, + { + label: MEMORY_USAGE, + field: METRIC_SYSTEM_MEMORY_USAGE, + id: METRIC_SYSTEM_MEMORY_USAGE, + columnType: OPERATION_COLUMN, + }, + { + label: CPU_USAGE, + field: METRIC_SYSTEM_CPU_USAGE, + id: METRIC_SYSTEM_CPU_USAGE, + columnType: OPERATION_COLUMN, }, ], }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts index e34d8b0dcfdd..1d04a9b38950 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts @@ -6,8 +6,13 @@ */ import { euiPaletteForStatus } from '@elastic/eui'; -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, FILTER_RECORDS, USE_BREAK_DOWN_COLUMN } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { + FieldLabels, + FILTER_RECORDS, + REPORT_METRIC_FIELD, + USE_BREAK_DOWN_COLUMN, +} from '../constants'; import { buildPhraseFilter } from '../utils'; import { CLIENT_GEO_COUNTRY_NAME, @@ -27,7 +32,7 @@ import { SERVICE_ENVIRONMENT, } from '../constants/elasticsearch_fieldnames'; -export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): DataSeries { +export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesConfig { const statusPallete = euiPaletteForStatus(3); return { @@ -39,20 +44,20 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): DataSerie }, yAxisColumns: [ { - sourceField: 'core.web.vitals', + sourceField: REPORT_METRIC_FIELD, label: 'Good', }, { - sourceField: 'core.web.vitals', + sourceField: REPORT_METRIC_FIELD, label: 'Average', }, { - sourceField: 'core.web.vitals', + sourceField: REPORT_METRIC_FIELD, label: 'Poor', }, ], hasOperationType: false, - defaultFilters: [ + filterFields: [ { field: TRANSACTION_URL, isNegated: false, @@ -69,7 +74,7 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): DataSerie nested: USER_AGENT_VERSION, }, ], - breakdowns: [ + breakdownFields: [ SERVICE_NAME, USER_AGENT_NAME, USER_AGENT_OS, @@ -77,79 +82,67 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): DataSerie USER_AGENT_DEVICE, URL_FULL, ], - filters: [ + baseFilters: [ ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), ], labels: { ...FieldLabels, [SERVICE_NAME]: 'Web Application' }, - reportDefinitions: [ + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ { - field: SERVICE_NAME, - required: true, + id: LCP_FIELD, + label: 'Largest contentful paint', + columnType: FILTER_RECORDS, + columnFilters: [ + { + language: 'kuery', + query: `${LCP_FIELD} < 2500`, + }, + { + language: 'kuery', + query: `${LCP_FIELD} > 2500 and ${LCP_FIELD} < 4000`, + }, + { + language: 'kuery', + query: `${LCP_FIELD} > 4000`, + }, + ], }, { - field: SERVICE_ENVIRONMENT, + label: 'First input delay', + id: FID_FIELD, + columnType: FILTER_RECORDS, + columnFilters: [ + { + language: 'kuery', + query: `${FID_FIELD} < 100`, + }, + { + language: 'kuery', + query: `${FID_FIELD} > 100 and ${FID_FIELD} < 300`, + }, + { + language: 'kuery', + query: `${FID_FIELD} > 300`, + }, + ], }, { - field: 'core.web.vitals', - custom: true, - options: [ + label: 'Cumulative layout shift', + id: CLS_FIELD, + columnType: FILTER_RECORDS, + columnFilters: [ { - id: LCP_FIELD, - label: 'Largest contentful paint', - columnType: FILTER_RECORDS, - columnFilters: [ - { - language: 'kuery', - query: `${LCP_FIELD} < 2500`, - }, - { - language: 'kuery', - query: `${LCP_FIELD} > 2500 and ${LCP_FIELD} < 4000`, - }, - { - language: 'kuery', - query: `${LCP_FIELD} > 4000`, - }, - ], + language: 'kuery', + query: `${CLS_FIELD} < 0.1`, }, { - label: 'First input delay', - id: FID_FIELD, - columnType: FILTER_RECORDS, - columnFilters: [ - { - language: 'kuery', - query: `${FID_FIELD} < 100`, - }, - { - language: 'kuery', - query: `${FID_FIELD} > 100 and ${FID_FIELD} < 300`, - }, - { - language: 'kuery', - query: `${FID_FIELD} > 300`, - }, - ], + language: 'kuery', + query: `${CLS_FIELD} > 0.1 and ${CLS_FIELD} < 0.25`, }, { - label: 'Cumulative layout shift', - id: CLS_FIELD, - columnType: FILTER_RECORDS, - columnFilters: [ - { - language: 'kuery', - query: `${CLS_FIELD} < 0.1`, - }, - { - language: 'kuery', - query: `${CLS_FIELD} > 0.1 and ${CLS_FIELD} < 0.25`, - }, - { - language: 'kuery', - query: `${CLS_FIELD} > 0.25`, - }, - ], + language: 'kuery', + query: `${CLS_FIELD} > 0.25`, }, ], }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts index 812f1b2e4cf3..b171edf2901d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, RECORDS_FIELD } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; import { buildPhraseFilter } from '../utils'; import { CLIENT_GEO_COUNTRY_NAME, @@ -39,13 +39,13 @@ import { WEB_APPLICATION_LABEL, } from '../constants/labels'; -export function getRumDistributionConfig({ indexPattern }: ConfigProps): DataSeries { +export function getRumDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { return { reportType: 'data-distribution', defaultSeriesType: 'line', seriesTypes: [], xAxisColumn: { - sourceField: 'performance.metric', + sourceField: REPORT_METRIC_FIELD, }, yAxisColumns: [ { @@ -54,7 +54,7 @@ export function getRumDistributionConfig({ indexPattern }: ConfigProps): DataSer }, ], hasOperationType: false, - defaultFilters: [ + filterFields: [ { field: TRANSACTION_URL, isNegated: false, @@ -67,34 +67,22 @@ export function getRumDistributionConfig({ indexPattern }: ConfigProps): DataSer nested: USER_AGENT_VERSION, }, ], - breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE], - reportDefinitions: [ + breakdownFields: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE], + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { label: PAGE_LOAD_TIME_LABEL, id: TRANSACTION_DURATION, field: TRANSACTION_DURATION }, { - field: SERVICE_NAME, - required: true, - }, - { - field: SERVICE_ENVIRONMENT, - }, - { - field: 'performance.metric', - custom: true, - options: [ - { label: PAGE_LOAD_TIME_LABEL, id: TRANSACTION_DURATION, field: TRANSACTION_DURATION }, - { - label: BACKEND_TIME_LABEL, - id: TRANSACTION_TIME_TO_FIRST_BYTE, - field: TRANSACTION_TIME_TO_FIRST_BYTE, - }, - { label: FCP_LABEL, id: FCP_FIELD, field: FCP_FIELD }, - { label: TBT_LABEL, id: TBT_FIELD, field: TBT_FIELD }, - { label: LCP_LABEL, id: LCP_FIELD, field: LCP_FIELD }, - { label: FID_LABEL, id: FID_FIELD, field: FID_FIELD }, - { label: CLS_LABEL, id: CLS_FIELD, field: CLS_FIELD }, - ], + label: BACKEND_TIME_LABEL, + id: TRANSACTION_TIME_TO_FIRST_BYTE, + field: TRANSACTION_TIME_TO_FIRST_BYTE, }, + { label: FCP_LABEL, id: FCP_FIELD, field: FCP_FIELD }, + { label: TBT_LABEL, id: TBT_FIELD, field: TBT_FIELD }, + { label: LCP_LABEL, id: LCP_FIELD, field: LCP_FIELD }, + { label: FID_LABEL, id: FID_FIELD, field: FID_FIELD }, + { label: CLS_LABEL, id: CLS_FIELD, field: CLS_FIELD }, ], - filters: [ + baseFilters: [ ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), ], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts index 12d66c55c7d0..5899b16d12b4 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; import { buildPhraseFilter } from '../utils'; import { CLIENT_GEO_COUNTRY_NAME, @@ -39,7 +39,7 @@ import { WEB_APPLICATION_LABEL, } from '../constants/labels'; -export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): DataSeries { +export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): SeriesConfig { return { defaultSeriesType: 'bar_stacked', seriesTypes: [], @@ -49,12 +49,12 @@ export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): DataSerie }, yAxisColumns: [ { - sourceField: 'business.kpi', + sourceField: REPORT_METRIC_FIELD, operationType: 'median', }, ], hasOperationType: false, - defaultFilters: [ + filterFields: [ { field: TRANSACTION_URL, isNegated: false, @@ -67,44 +67,32 @@ export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): DataSerie nested: USER_AGENT_VERSION, }, ], - breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE], - filters: [ + breakdownFields: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE], + baseFilters: [ ...buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern), ...buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern), ], labels: { ...FieldLabels, [SERVICE_NAME]: WEB_APPLICATION_LABEL }, - reportDefinitions: [ + definitionFields: [SERVICE_NAME, SERVICE_ENVIRONMENT], + metricOptions: [ + { field: RECORDS_FIELD, id: RECORDS_FIELD, label: PAGE_VIEWS_LABEL }, { - field: SERVICE_NAME, - required: true, + label: PAGE_LOAD_TIME_LABEL, + field: TRANSACTION_DURATION, + id: TRANSACTION_DURATION, + columnType: OPERATION_COLUMN, }, { - field: SERVICE_ENVIRONMENT, - }, - { - field: 'business.kpi', - custom: true, - options: [ - { field: RECORDS_FIELD, id: RECORDS_FIELD, label: PAGE_VIEWS_LABEL }, - { - label: PAGE_LOAD_TIME_LABEL, - field: TRANSACTION_DURATION, - id: TRANSACTION_DURATION, - columnType: OPERATION_COLUMN, - }, - { - label: BACKEND_TIME_LABEL, - field: TRANSACTION_TIME_TO_FIRST_BYTE, - id: TRANSACTION_TIME_TO_FIRST_BYTE, - columnType: OPERATION_COLUMN, - }, - { label: FCP_LABEL, field: FCP_FIELD, id: FCP_FIELD, columnType: OPERATION_COLUMN }, - { label: TBT_LABEL, field: TBT_FIELD, id: TBT_FIELD, columnType: OPERATION_COLUMN }, - { label: LCP_LABEL, field: LCP_FIELD, id: LCP_FIELD, columnType: OPERATION_COLUMN }, - { label: FID_LABEL, field: FID_FIELD, id: FID_FIELD, columnType: OPERATION_COLUMN }, - { label: CLS_LABEL, field: CLS_FIELD, id: CLS_FIELD, columnType: OPERATION_COLUMN }, - ], + label: BACKEND_TIME_LABEL, + field: TRANSACTION_TIME_TO_FIRST_BYTE, + id: TRANSACTION_TIME_TO_FIRST_BYTE, + columnType: OPERATION_COLUMN, }, + { label: FCP_LABEL, field: FCP_FIELD, id: FCP_FIELD, columnType: OPERATION_COLUMN }, + { label: TBT_LABEL, field: TBT_FIELD, id: TBT_FIELD, columnType: OPERATION_COLUMN }, + { label: LCP_LABEL, field: LCP_FIELD, id: LCP_FIELD, columnType: OPERATION_COLUMN }, + { label: FID_LABEL, field: FID_FIELD, id: FID_FIELD, columnType: OPERATION_COLUMN }, + { label: CLS_LABEL, field: CLS_FIELD, id: CLS_FIELD, columnType: OPERATION_COLUMN }, ], }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts index b958c0dd7152..9783f63f5b90 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts @@ -5,18 +5,21 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, RECORDS_FIELD } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; import { buildExistsFilter } from '../utils'; import { MONITORS_DURATION_LABEL, PINGS_LABEL } from '../constants/labels'; -export function getSyntheticsDistributionConfig({ series, indexPattern }: ConfigProps): DataSeries { +export function getSyntheticsDistributionConfig({ + series, + indexPattern, +}: ConfigProps): SeriesConfig { return { reportType: 'data-distribution', defaultSeriesType: series?.seriesType || 'line', seriesTypes: [], xAxisColumn: { - sourceField: 'performance.metric', + sourceField: REPORT_METRIC_FIELD, }, yAxisColumns: [ { @@ -25,8 +28,8 @@ export function getSyntheticsDistributionConfig({ series, indexPattern }: Config }, ], hasOperationType: false, - defaultFilters: ['monitor.type', 'observer.geo.name', 'tags'], - breakdowns: [ + filterFields: ['monitor.type', 'observer.geo.name', 'tags'], + breakdownFields: [ 'observer.geo.name', 'monitor.name', 'monitor.id', @@ -34,21 +37,10 @@ export function getSyntheticsDistributionConfig({ series, indexPattern }: Config 'tags', 'url.port', ], - filters: [...buildExistsFilter('summary.up', indexPattern)], - reportDefinitions: [ - { - field: 'monitor.name', - }, - { - field: 'url.full', - }, - { - field: 'performance.metric', - custom: true, - options: [ - { label: 'Monitor duration', id: 'monitor.duration.us', field: 'monitor.duration.us' }, - ], - }, + baseFilters: [...buildExistsFilter('summary.up', indexPattern)], + definitionFields: ['monitor.name', 'url.full'], + metricOptions: [ + { label: 'Monitor duration', id: 'monitor.duration.us', field: 'monitor.duration.us' }, ], labels: { ...FieldLabels, 'monitor.duration.us': MONITORS_DURATION_LABEL }, }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts index 3e9284543636..6bf280e93eb1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts @@ -5,15 +5,15 @@ * 2.0. */ -import { ConfigProps, DataSeries } from '../../types'; -import { FieldLabels, OPERATION_COLUMN } from '../constants'; +import { ConfigProps, SeriesConfig } from '../../types'; +import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD } from '../constants'; import { buildExistsFilter } from '../utils'; import { DOWN_LABEL, MONITORS_DURATION_LABEL, UP_LABEL } from '../constants/labels'; import { MONITOR_DURATION_US } from '../constants/field_names/synthetics'; const SUMMARY_UP = 'summary.up'; const SUMMARY_DOWN = 'summary.down'; -export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): DataSeries { +export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { return { reportType: 'kpi-over-time', defaultSeriesType: 'bar_stacked', @@ -23,45 +23,34 @@ export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): DataSerie }, yAxisColumns: [ { - sourceField: 'business.kpi', + sourceField: REPORT_METRIC_FIELD, operationType: 'median', }, ], hasOperationType: false, - defaultFilters: ['observer.geo.name', 'monitor.type', 'tags'], - breakdowns: ['observer.geo.name', 'monitor.type'], - filters: [...buildExistsFilter('summary.up', indexPattern)], + filterFields: ['observer.geo.name', 'monitor.type', 'tags'], + breakdownFields: ['observer.geo.name', 'monitor.type'], + baseFilters: [...buildExistsFilter('summary.up', indexPattern)], palette: { type: 'palette', name: 'status' }, - reportDefinitions: [ + definitionFields: ['monitor.name', 'url.full'], + metricOptions: [ { - field: 'monitor.name', + label: MONITORS_DURATION_LABEL, + field: MONITOR_DURATION_US, + id: MONITOR_DURATION_US, + columnType: OPERATION_COLUMN, }, { - field: 'url.full', + field: SUMMARY_UP, + id: SUMMARY_UP, + label: UP_LABEL, + columnType: OPERATION_COLUMN, }, { - field: 'business.kpi', - custom: true, - options: [ - { - label: MONITORS_DURATION_LABEL, - field: MONITOR_DURATION_US, - id: MONITOR_DURATION_US, - columnType: OPERATION_COLUMN, - }, - { - field: SUMMARY_UP, - id: SUMMARY_UP, - label: UP_LABEL, - columnType: OPERATION_COLUMN, - }, - { - field: SUMMARY_DOWN, - id: SUMMARY_DOWN, - label: DOWN_LABEL, - columnType: OPERATION_COLUMN, - }, - ], + field: SUMMARY_DOWN, + id: SUMMARY_DOWN, + label: DOWN_LABEL, + columnType: OPERATION_COLUMN, }, ], labels: { ...FieldLabels }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts new file mode 100644 index 000000000000..7f066caf66bf --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const sampleAttributeKpi = { + title: 'Prefilled from exploratory view app', + description: '', + visualizationType: 'lnsXY', + references: [ + { id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' }, + { id: 'apm-*', name: 'indexpattern-datasource-layer-layer0', type: 'index-pattern' }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + layer0: { + columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0'], + columns: { + 'x-axis-column-layer0': { + sourceField: '@timestamp', + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + }, + 'y-axis-column-layer0': { + dataType: 'number', + isBucketed: false, + label: 'Page views', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + filter: { + query: 'transaction.type: page-load and processor.event: transaction', + language: 'kuery', + }, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + fittingFunction: 'Linear', + curveType: 'CURVE_MONOTONE_X', + axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + preferredSeriesType: 'line', + layers: [ + { + accessors: ['y-axis-column-layer0'], + layerId: 'layer0', + seriesType: 'line', + yConfig: [{ forAccessor: 'y-axis-column-layer0' }], + xAccessor: 'x-axis-column-layer0', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 9b1e7ec141ca..f7df2939d990 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -21,6 +21,7 @@ export function convertToShortUrl(series: SeriesUrl) { filters, reportDefinitions, dataType, + selectedMetricField, ...restSeries } = series; @@ -32,6 +33,7 @@ export function convertToShortUrl(series: SeriesUrl) { [URL_KEYS.FILTERS]: filters, [URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions, [URL_KEYS.DATA_TYPE]: dataType, + [URL_KEYS.SELECTED_METRIC]: selectedMetricField, ...restSeries, }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index 11487afe28e9..d14a26d13d92 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -12,25 +12,16 @@ import { LayerConfig, LensAttributes } from '../configurations/lens_attributes'; import { useSeriesStorage } from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; -import { DataSeries, SeriesUrl, UrlFilter } from '../types'; +import { SeriesUrl, UrlFilter } from '../types'; import { useAppIndexPatternContext } from './use_app_index_pattern'; -export const getFiltersFromDefs = ( - reportDefinitions: SeriesUrl['reportDefinitions'], - dataViewConfig: DataSeries -) => { - const rdfFilters = Object.entries(reportDefinitions ?? {}).map(([field, value]) => { +export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitions']) => { + return Object.entries(reportDefinitions ?? {}).map(([field, value]) => { return { field, values: value, }; }) as UrlFilter[]; - - // let's filter out custom fields - return rdfFilters.filter(({ field }) => { - const rdf = dataViewConfig.reportDefinitions.find(({ field: fd }) => field === fd); - return !rdf?.custom; - }); }; export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => { @@ -49,25 +40,26 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null const seriesT = allSeries[seriesIdT]; const indexPattern = indexPatterns?.[seriesT?.dataType]; if (indexPattern && seriesT.reportType && !isEmpty(seriesT.reportDefinitions)) { - const reportViewConfig = getDefaultConfigs({ + const seriesConfig = getDefaultConfigs({ reportType: seriesT.reportType, dataType: seriesT.dataType, indexPattern, }); const filters: UrlFilter[] = (seriesT.filters ?? []).concat( - getFiltersFromDefs(seriesT.reportDefinitions, reportViewConfig) + getFiltersFromDefs(seriesT.reportDefinitions) ); layerConfigs.push({ filters, indexPattern, - reportConfig: reportViewConfig, + seriesConfig, + time: seriesT.time, breakdown: seriesT.breakdown, - operationType: seriesT.operationType, seriesType: seriesT.seriesType, + operationType: seriesT.operationType, reportDefinitions: seriesT.reportDefinitions ?? {}, - time: seriesT.time, + selectedMetricField: seriesT.selectedMetricField, }); } }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index e9ae43950d47..7e9b69a276d0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -110,7 +110,7 @@ export function useSeriesStorage() { } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { - const { dt, op, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; + const { dt, op, st, rt, bd, ft, time, rdf, mt, ...restSeries } = newValue; return { operationType: op, reportType: rt!, @@ -120,6 +120,7 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { time: time!, reportDefinitions: rdf, dataType: dt!, + selectedMetricField: mt, ...restSeries, }; } @@ -132,6 +133,7 @@ interface ShortUrlSeries { [URL_KEYS.BREAK_DOWN]?: string; [URL_KEYS.FILTERS]?: UrlFilter[]; [URL_KEYS.REPORT_DEFINITIONS]?: URLReportDefinition; + [URL_KEYS.SELECTED_METRIC]?: string; time?: { to: string; from: string; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 203382afc162..a5e5ad3900de 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -21,7 +21,7 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should render properly', function () { - render(); + render(); screen.getByText('Select an option: , is selected'); screen.getAllByText('Browser family'); @@ -29,7 +29,7 @@ describe('Series Builder ReportBreakdowns', function () { it('should set new series breakdown on change', function () { const { setSeries } = render( - + ); const btn = screen.getByRole('button', { @@ -51,7 +51,7 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should set undefined on new series on no select breakdown', function () { const { setSeries } = render( - + ); const btn = screen.getByRole('button', { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx index e95cd894df5f..fa2d01691ce1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx @@ -7,19 +7,19 @@ import React from 'react'; import { Breakdowns } from '../../series_editor/columns/breakdowns'; -import { DataSeries } from '../../types'; +import { SeriesConfig } from '../../types'; export function ReportBreakdowns({ seriesId, - dataViewSeries, + seriesConfig, }: { - dataViewSeries: DataSeries; + seriesConfig: SeriesConfig; seriesId: string; }) { return ( ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 2e5c674b9fad..cac1eccada31 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -21,7 +21,7 @@ describe('Series Builder ReportDefinitionCol', function () { mockAppIndexPattern(); const seriesId = 'test-series-id'; - const dataViewSeries = getDefaultConfigs({ + const seriesConfig = getDefaultConfigs({ reportType: 'data-distribution', indexPattern: mockIndexPattern, dataType: 'ux', @@ -41,7 +41,7 @@ describe('Series Builder ReportDefinitionCol', function () { mockUseValuesList([{ label: 'elastic-co', count: 10 }]); it('should render properly', async function () { - render(, { + render(, { initSeries, }); @@ -52,7 +52,7 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should render selected report definitions', async function () { - render(, { + render(, { initSeries, }); @@ -63,7 +63,7 @@ describe('Series Builder ReportDefinitionCol', function () { it('should be able to remove selected definition', async function () { const { setSeries } = render( - , + , { initSeries } ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index 47962af0d4bc..0c620abf56e8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -9,39 +9,40 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import styled from 'styled-components'; import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { CustomReportField } from '../custom_report_field'; -import { DataSeries, URLReportDefinition } from '../../types'; +import { ReportMetricOptions } from '../report_metric_options'; +import { SeriesConfig } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; import { OperationTypeSelect } from './operation_type_select'; import { DatePickerCol } from './date_picker_col'; import { parseCustomFieldName } from '../../configurations/lens_attributes'; import { ReportDefinitionField } from './report_definition_field'; -function getColumnType(dataView: DataSeries, selectedDefinition: URLReportDefinition) { - const { reportDefinitions } = dataView; - const customColumn = reportDefinitions.find((item) => item.custom); - if (customColumn?.field && selectedDefinition[customColumn?.field]) { - const { columnType } = parseCustomFieldName(customColumn.field, dataView, selectedDefinition); +function getColumnType(seriesConfig: SeriesConfig, selectedMetricField?: string) { + const { columnType } = parseCustomFieldName(seriesConfig, selectedMetricField); - return columnType; - } - return null; + return columnType; } export function ReportDefinitionCol({ - dataViewSeries, + seriesConfig, seriesId, }: { - dataViewSeries: DataSeries; + seriesConfig: SeriesConfig; seriesId: string; }) { const { getSeries, setSeries } = useSeriesStorage(); const series = getSeries(seriesId); - const { reportDefinitions: selectedReportDefinitions = {} } = series ?? {}; + const { reportDefinitions: selectedReportDefinitions = {}, selectedMetricField } = series ?? {}; - const { reportDefinitions, defaultSeriesType, hasOperationType, yAxisColumns } = dataViewSeries; + const { + definitionFields, + defaultSeriesType, + hasOperationType, + yAxisColumns, + metricOptions, + } = seriesConfig; const onChange = (field: string, value?: string[]) => { if (!value?.[0]) { @@ -58,7 +59,7 @@ export function ReportDefinitionCol({ } }; - const columnType = getColumnType(dataViewSeries, selectedReportDefinitions); + const columnType = getColumnType(seriesConfig, selectedMetricField); return ( @@ -66,20 +67,21 @@ export function ReportDefinitionCol({ - {reportDefinitions.map(({ field, custom, options }) => ( + {definitionFields.map((field) => ( - {!custom ? ( - - ) : ( - - )} + ))} + {metricOptions && ( + + + + )} {(hasOperationType || columnType === 'operation') && ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx index 51f4edaae93d..61f6f85dbeaf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -15,16 +15,16 @@ import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearc import { PersistableFilter } from '../../../../../../../lens/common'; import { ExistsFilter } from '../../../../../../../../../src/plugins/data/common/es_query/filters'; import { buildPhrasesFilter } from '../../configurations/utils'; -import { DataSeries } from '../../types'; +import { SeriesConfig } from '../../types'; interface Props { seriesId: string; field: string; - dataSeries: DataSeries; + seriesConfig: SeriesConfig; onChange: (field: string, value?: string[]) => void; } -export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { +export function ReportDefinitionField({ seriesId, field, seriesConfig, onChange }: Props) { const { getSeries } = useSeriesStorage(); const series = getSeries(seriesId); @@ -33,11 +33,11 @@ export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: const { reportDefinitions: selectedReportDefinitions = {} } = series; - const { labels, filters, reportDefinitions } = dataSeries; + const { labels, baseFilters, definitionFields } = seriesConfig; const queryFilters = useMemo(() => { const filtersN: ESFilter[] = []; - (filters ?? []).forEach((qFilter: PersistableFilter | ExistsFilter) => { + (baseFilters ?? []).forEach((qFilter: PersistableFilter | ExistsFilter) => { if (qFilter.query) { filtersN.push(qFilter.query); } @@ -48,8 +48,8 @@ export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: }); if (!isEmpty(selectedReportDefinitions)) { - reportDefinitions.forEach(({ field: fieldT, custom }) => { - if (!custom && indexPattern && selectedReportDefinitions?.[fieldT] && fieldT !== field) { + definitionFields.forEach((fieldT) => { + if (indexPattern && selectedReportDefinitions?.[fieldT] && fieldT !== field) { const values = selectedReportDefinitions?.[fieldT]; const valueFilter = buildPhrasesFilter(fieldT, values, indexPattern)[0]; filtersN.push(valueFilter.query); @@ -59,7 +59,7 @@ export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: return filtersN; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(filters)]); + }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(baseFilters)]); return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index f35639388aac..0b183b5f20c0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -21,7 +21,7 @@ describe('Series Builder ReportFilters', function () { }); it('should render properly', function () { - render(); + render(); screen.getByText('Add filter'); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx index 4571ecfe252e..d5938c5387e8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx @@ -7,23 +7,23 @@ import React from 'react'; import { SeriesFilter } from '../../series_editor/columns/series_filter'; -import { DataSeries } from '../../types'; +import { SeriesConfig } from '../../types'; export function ReportFilters({ - dataViewSeries, + seriesConfig, seriesId, }: { - dataViewSeries: DataSeries; + seriesConfig: SeriesConfig; seriesId: string; }) { return ( ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index f7cfe06c0d92..07048d47b2bc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -38,7 +38,7 @@ describe('ReportTypesCol', function () { expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: 'user_agent.name', dataType: 'ux', - reportDefinitions: {}, + selectedMetricField: undefined, reportType: 'kpi-over-time', time: { from: 'now-15m', to: 'now' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index 64c7b48c668b..396f8c4f1deb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -15,7 +15,7 @@ import { ReportViewType, SeriesUrl } from '../../types'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { ReportTypeItem, SELECT_DATA_TYPE } from '../series_builder'; +import { ReportTypeItem } from '../series_builder'; interface Props { seriesId: string; @@ -30,7 +30,12 @@ export function ReportTypesCol({ seriesId, reportTypes }: Props) { const { loading, hasData } = useAppIndexPatternContext(restSeries.dataType); if (!restSeries.dataType) { - return {SELECT_DATA_TYPE}; + return ( + + ); } if (!loading && !hasData) { @@ -72,8 +77,7 @@ export function ReportTypesCol({ seriesId, reportTypes }: Props) { setSeries(seriesId, { ...restSeries, reportType, - operationType: undefined, - reportDefinitions: {}, + selectedMetricField: undefined, time: restSeries?.time ?? DEFAULT_TIME, }); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx similarity index 66% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx index 201df9628e13..a2a3e34c2183 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx @@ -8,28 +8,26 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; import { useSeriesStorage } from '../hooks/use_series_storage'; -import { ReportDefinition } from '../types'; +import { SeriesConfig } from '../types'; interface Props { - field: string; seriesId: string; defaultValue?: string; - options: ReportDefinition['options']; + options: SeriesConfig['metricOptions']; } -export function CustomReportField({ field, seriesId, options: opts }: Props) { +export function ReportMetricOptions({ seriesId, options: opts }: Props) { const { getSeries, setSeries } = useSeriesStorage(); const series = getSeries(seriesId); - const { reportDefinitions: rtd = {} } = series; - const onChange = (value: string) => { - setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: [value] } }); + setSeries(seriesId, { + ...series, + selectedMetricField: value, + }); }; - const { reportDefinitions } = series; - const options = opts ?? []; return ( @@ -41,7 +39,7 @@ export function CustomReportField({ field, seriesId, options: opts }: Props) { value: fd || id, inputDisplay: label, }))} - valueOfSelected={reportDefinitions?.[field]?.[0] || options?.[0].field || options?.[0].id} + valueOfSelected={series.selectedMetricField || options?.[0].field || options?.[0].id} onChange={(value) => onChange(value)} /> ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index e596eb6be354..684cf3a210a5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -17,7 +17,7 @@ import { EuiSwitch, } from '@elastic/eui'; import { rgba } from 'polished'; -import { AppDataType, DataSeries, ReportViewType, SeriesUrl } from '../types'; +import { AppDataType, SeriesConfig, ReportViewType, SeriesUrl } from '../types'; import { DataTypesCol } from './columns/data_types_col'; import { ReportTypesCol } from './columns/report_types_col'; import { ReportDefinitionCol } from './columns/report_definition_col'; @@ -66,7 +66,7 @@ export const ReportTypes: Record = { interface BuilderItem { id: string; series: SeriesUrl; - seriesConfig?: DataSeries; + seriesConfig?: SeriesConfig; } export function SeriesBuilder({ @@ -142,7 +142,7 @@ export function SeriesBuilder({ return loading ? ( LOADING_VIEW ) : reportType ? ( - + ) : ( SELECT_REPORT_TYPE ); @@ -159,7 +159,7 @@ export function SeriesBuilder({ field: 'id', render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) => reportType && seriesConfig ? ( - + ) : null, }, { @@ -170,7 +170,7 @@ export function SeriesBuilder({ field: 'id', render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) => reportType && seriesConfig ? ( - + ) : null, }, ...(multiSeries @@ -301,10 +301,3 @@ export const SELECT_REPORT_TYPE = i18n.translate( defaultMessage: 'No report type selected', } ); - -export const SELECT_DATA_TYPE = i18n.translate( - 'xpack.observability.expView.seriesBuilder.selectDataType', - { - defaultMessage: 'No data type selected', - } -); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx index a0d2fd86482a..207a53e13f1a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx @@ -8,22 +8,22 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Breakdowns } from './columns/breakdowns'; -import { DataSeries } from '../types'; +import { SeriesConfig } from '../types'; import { ChartOptions } from './columns/chart_options'; interface Props { - series: DataSeries; + seriesConfig: SeriesConfig; seriesId: string; - breakdowns: string[]; + breakdownFields: string[]; } -export function ChartEditOptions({ series, seriesId, breakdowns }: Props) { +export function ChartEditOptions({ seriesConfig, seriesId, breakdownFields }: Props) { return ( - + - + ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index d180bf4529c2..84568e1c5068 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -23,8 +23,8 @@ describe('Breakdowns', function () { render( ); @@ -37,8 +37,8 @@ describe('Breakdowns', function () { const { setSeries } = render( , { initSeries } ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx index cf24cb31951b..2237935d466a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx @@ -10,15 +10,15 @@ import { EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; -import { DataSeries } from '../../types'; +import { SeriesConfig } from '../../types'; interface Props { seriesId: string; breakdowns: string[]; - reportViewConfig: DataSeries; + seriesConfig: SeriesConfig; } -export function Breakdowns({ reportViewConfig, seriesId, breakdowns = [] }: Props) { +export function Breakdowns({ seriesConfig, seriesId, breakdowns = [] }: Props) { const { setSeries, getSeries } = useSeriesStorage(); const series = getSeries(seriesId); @@ -40,11 +40,11 @@ export function Breakdowns({ reportViewConfig, seriesId, breakdowns = [] }: Prop } }; - const hasUseBreakdownColumn = reportViewConfig.xAxisColumn.sourceField === USE_BREAK_DOWN_COLUMN; + const hasUseBreakdownColumn = seriesConfig.xAxisColumn.sourceField === USE_BREAK_DOWN_COLUMN; const items = breakdowns.map((breakdown) => ({ id: breakdown, - label: reportViewConfig.labels[breakdown], + label: seriesConfig.labels[breakdown], })); if (!hasUseBreakdownColumn) { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx index 08664ac75eb8..f2a6377fd9b7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx @@ -7,22 +7,25 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { DataSeries } from '../../types'; +import { SeriesConfig } from '../../types'; import { OperationTypeSelect } from '../../series_builder/columns/operation_type_select'; import { SeriesChartTypesSelect } from '../../series_builder/columns/chart_types'; interface Props { - series: DataSeries; + seriesConfig: SeriesConfig; seriesId: string; } -export function ChartOptions({ series, seriesId }: Props) { +export function ChartOptions({ seriesConfig, seriesId }: Props) { return ( - + - {series.hasOperationType && ( + {seriesConfig.hasOperationType && ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index 0f0cec0fbfcf..6f9d8efdc068 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -14,7 +14,7 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; import { map } from 'lodash'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { DataSeries, UrlFilter } from '../../types'; +import { SeriesConfig, UrlFilter } from '../../types'; import { FilterValueButton } from './filter_value_btn'; import { useValuesList } from '../../../../../hooks/use_values_list'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; @@ -29,7 +29,7 @@ interface Props { isNegated?: boolean; goBack: () => void; nestedField?: string; - filters: DataSeries['filters']; + filters: SeriesConfig['baseFilters']; } export function FilterExpanded({ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index b7e20b341b57..02144c6929b3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -16,16 +16,16 @@ import { EuiFlexGroup, } from '@elastic/eui'; import { FilterExpanded } from './filter_expanded'; -import { DataSeries } from '../../types'; +import { SeriesConfig } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; - defaultFilters: DataSeries['defaultFilters']; - filters: DataSeries['filters']; - series: DataSeries; + filterFields: SeriesConfig['filterFields']; + baseFilters: SeriesConfig['baseFilters']; + seriesConfig: SeriesConfig; isNew?: boolean; labels?: Record; } @@ -38,18 +38,18 @@ export interface Field { } export function SeriesFilter({ - series, + seriesConfig, isNew, seriesId, - defaultFilters = [], - filters, + filterFields = [], + baseFilters, labels, }: Props) { const [isPopoverVisible, setIsPopoverVisible] = useState(false); const [selectedField, setSelectedField] = useState(); - const options: Field[] = defaultFilters.map((field) => { + const options: Field[] = filterFields.map((field) => { if (typeof field === 'string') { return { label: labels?.[field] ?? FieldLabels[field], field }; } @@ -111,7 +111,7 @@ export function SeriesFilter({ goBack={() => { setSelectedField(undefined); }} - filters={filters} + filters={baseFilters} /> ) : null; @@ -122,7 +122,7 @@ export function SeriesFilter({ return ( - + , { initSeries }); + render(, { + initSeries, + }); await waitFor(() => { screen.getByText('Chrome'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index 33496e617a3a..5d2ce6ba8495 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -9,28 +9,28 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSeriesStorage } from '../hooks/use_series_storage'; import { FilterLabel } from '../components/filter_label'; -import { DataSeries, UrlFilter } from '../types'; +import { SeriesConfig, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { useSeriesFilters } from '../hooks/use_series_filters'; import { getFiltersFromDefs } from '../hooks/use_lens_attributes'; interface Props { seriesId: string; - series: DataSeries; + seriesConfig: SeriesConfig; isNew?: boolean; } -export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) { +export function SelectedFilters({ seriesId, isNew, seriesConfig }: Props) { const { getSeries } = useSeriesStorage(); const series = getSeries(seriesId); const { reportDefinitions = {} } = series; - const { labels } = dataSeries; + const { labels } = seriesConfig; const filters: UrlFilter[] = series.filters ?? []; - let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions, dataSeries); + let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions); // we don't want to display report definition filters in new series view if (isNew) { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index bcceeb204a31..c3cc8484d175 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; -import { DataSeries } from '../types'; +import { SeriesConfig } from '../types'; import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; @@ -19,7 +19,7 @@ import { SeriesActions } from './columns/series_actions'; import { ChartEditOptions } from './chart_edit_options'; interface EditItem { - seriesConfig: DataSeries; + seriesConfig: SeriesConfig; id: string; } @@ -48,10 +48,10 @@ export function SeriesEditor() { width: '15%', render: (seriesId: string, { seriesConfig, id }: EditItem) => ( ), }, @@ -64,8 +64,8 @@ export function SeriesEditor() { render: (seriesId: string, { seriesConfig, id }: EditItem) => ( ), }, @@ -123,7 +123,7 @@ export function SeriesEditor() { rowHeader="firstName" columns={columns} noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.seriesNotFound', { - defaultMessage: 'No series found, please add a series.', + defaultMessage: 'No series found. Please add a series.', })} cellProps={{ style: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index e8fccc5baab3..ad7c654c9a16 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -37,31 +37,27 @@ export interface ColumnFilter { query: string; } -export interface ReportDefinition { - field: string; - required?: boolean; - custom?: boolean; - options?: Array<{ - id: string; - field?: string; - label: string; - description?: string; - columnType?: 'range' | 'operation' | 'FILTER_RECORDS' | 'TERMS_COLUMN'; - columnFilters?: ColumnFilter[]; - timeScale?: string; - }>; +export interface MetricOption { + id: string; + field?: string; + label: string; + description?: string; + columnType?: 'range' | 'operation' | 'FILTER_RECORDS' | 'TERMS_COLUMN'; + columnFilters?: ColumnFilter[]; + timeScale?: string; } -export interface DataSeries { +export interface SeriesConfig { reportType: ReportViewType; xAxisColumn: Partial | Partial; yAxisColumns: Array>; - breakdowns: string[]; + breakdownFields: string[]; defaultSeriesType: SeriesType; - defaultFilters: Array; + filterFields: Array; seriesTypes: SeriesType[]; - filters?: PersistableFilter[] | ExistsFilter[]; - reportDefinitions: ReportDefinition[]; + baseFilters?: PersistableFilter[] | ExistsFilter[]; + definitionFields: string[]; + metricOptions?: MetricOption[]; labels: Record; hasOperationType: boolean; palette?: PaletteOutput; @@ -83,6 +79,7 @@ export interface SeriesUrl { operationType?: OperationType; dataType: AppDataType; reportDefinitions?: URLReportDefinition; + selectedMetricField?: string; isNew?: boolean; } From 6ba26db8d32c28750695632b8b2e275e566cfbeb Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Mon, 28 Jun 2021 16:24:13 +0200 Subject: [PATCH 07/65] Add warning toast when server.publicBaseUrl not configured correctly (#85344) --- ...-plugin-core-public.doclinksstart.links.md | 1 + ...kibana-plugin-core-public.doclinksstart.md | 2 +- src/core/public/core_app/core_app.ts | 14 ++- src/core/public/core_app/errors/index.ts | 1 + .../core_app/errors/public_base_url.test.tsx | 114 ++++++++++++++++++ .../core_app/errors/public_base_url.tsx | 88 ++++++++++++++ src/core/public/core_system.ts | 2 +- .../public/doc_links/doc_links_service.ts | 2 + src/core/public/http/http_service.mock.ts | 7 +- src/core/public/public.api.md | 1 + 10 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/core/public/core_app/errors/public_base_url.test.tsx create mode 100644 src/core/public/core_app/errors/public_base_url.tsx diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index b10ad949c494..63d791db452d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -8,6 +8,7 @@ ```typescript readonly links: { + readonly settings: string; readonly canvas: { readonly guide: string; }; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index c020f57faa88..947eece49813 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
} | | diff --git a/src/core/public/core_app/core_app.ts b/src/core/public/core_app/core_app.ts index aa0223dbe08a..00532b9150ae 100644 --- a/src/core/public/core_app/core_app.ts +++ b/src/core/public/core_app/core_app.ts @@ -18,8 +18,13 @@ import type { CoreContext } from '../core_system'; import type { NotificationsSetup, NotificationsStart } from '../notifications'; import type { IUiSettingsClient } from '../ui_settings'; import type { InjectedMetadataSetup } from '../injected_metadata'; -import { renderApp as renderErrorApp, setupUrlOverflowDetection } from './errors'; +import { + renderApp as renderErrorApp, + setupPublicBaseUrlConfigWarning, + setupUrlOverflowDetection, +} from './errors'; import { renderApp as renderStatusApp } from './status'; +import { DocLinksStart } from '../doc_links'; interface SetupDeps { application: InternalApplicationSetup; @@ -30,6 +35,7 @@ interface SetupDeps { interface StartDeps { application: InternalApplicationStart; + docLinks: DocLinksStart; http: HttpStart; notifications: NotificationsStart; uiSettings: IUiSettingsClient; @@ -40,7 +46,7 @@ export class CoreApp { constructor(private readonly coreContext: CoreContext) {} - public setup({ http, application, injectedMetadata, notifications }: SetupDeps) { + public setup({ application, http, injectedMetadata, notifications }: SetupDeps) { application.register(this.coreContext.coreId, { id: 'error', title: 'App Error', @@ -68,7 +74,7 @@ export class CoreApp { }); } - public start({ application, http, notifications, uiSettings }: StartDeps) { + public start({ application, docLinks, http, notifications, uiSettings }: StartDeps) { if (!application.history) { return; } @@ -79,6 +85,8 @@ export class CoreApp { toasts: notifications.toasts, uiSettings, }); + + setupPublicBaseUrlConfigWarning({ docLinks, http, notifications }); } public stop() { diff --git a/src/core/public/core_app/errors/index.ts b/src/core/public/core_app/errors/index.ts index 02666103de34..e991fa455ab3 100644 --- a/src/core/public/core_app/errors/index.ts +++ b/src/core/public/core_app/errors/index.ts @@ -8,3 +8,4 @@ export { renderApp } from './error_application'; export { setupUrlOverflowDetection, URL_MAX_LENGTH } from './url_overflow'; +export { setupPublicBaseUrlConfigWarning } from './public_base_url'; diff --git a/src/core/public/core_app/errors/public_base_url.test.tsx b/src/core/public/core_app/errors/public_base_url.test.tsx new file mode 100644 index 000000000000..d1fb5a5093f1 --- /dev/null +++ b/src/core/public/core_app/errors/public_base_url.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { docLinksServiceMock } from '../../doc_links/doc_links_service.mock'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { notificationServiceMock } from '../../notifications/notifications_service.mock'; + +import { setupPublicBaseUrlConfigWarning } from './public_base_url'; + +describe('publicBaseUrl warning', () => { + const docLinks = docLinksServiceMock.createStartContract(); + const notifications = notificationServiceMock.createStartContract(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('does not show any toast on localhost', () => { + const http = httpServiceMock.createStartContract(); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'localhost', + } as Location, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + + it('does not show any toast on 127.0.0.1', () => { + const http = httpServiceMock.createStartContract(); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: '127.0.0.1', + } as Location, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + + it('does not show toast if configured correctly', () => { + const http = httpServiceMock.createStartContract({ publicBaseUrl: 'http://myhost.com' }); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'myhost.com', + toString() { + return 'http://myhost.com/'; + }, + } as Location, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + + describe('config missing toast', () => { + it('adds toast if publicBaseUrl is missing', () => { + const http = httpServiceMock.createStartContract({ publicBaseUrl: undefined }); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'myhost.com', + toString() { + return 'http://myhost.com/'; + }, + } as Location, + }); + + expect(notifications.toasts.addWarning).toHaveBeenCalledWith({ + title: 'Configuration missing', + text: expect.any(Function), + }); + }); + + it('does not add toast if storage key set', () => { + const http = httpServiceMock.createStartContract({ publicBaseUrl: undefined }); + + setupPublicBaseUrlConfigWarning({ + docLinks, + notifications, + http, + location: { + hostname: 'myhost.com', + toString() { + return 'http://myhost.com/'; + }, + } as Location, + storage: { + getItem: (id: string) => 'true', + } as Storage, + }); + + expect(notifications.toasts.addWarning).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/public/core_app/errors/public_base_url.tsx b/src/core/public/core_app/errors/public_base_url.tsx new file mode 100644 index 000000000000..263367a4cb09 --- /dev/null +++ b/src/core/public/core_app/errors/public_base_url.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import type { HttpStart, NotificationsStart } from '../..'; +import type { DocLinksStart } from '../../doc_links'; +import { mountReactNode } from '../../utils'; + +/** Only exported for tests */ +export const MISSING_CONFIG_STORAGE_KEY = `core.warnings.publicBaseUrlMissingDismissed`; + +interface Deps { + docLinks: DocLinksStart; + http: HttpStart; + notifications: NotificationsStart; + // Exposed for easier testing + storage?: Storage; + location?: Location; +} + +export const setupPublicBaseUrlConfigWarning = ({ + docLinks, + http, + notifications, + storage = window.localStorage, + location = window.location, +}: Deps) => { + if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { + return; + } + + const missingWarningSeen = storage.getItem(MISSING_CONFIG_STORAGE_KEY) === 'true'; + if (missingWarningSeen || http.basePath.publicBaseUrl) { + return; + } + + const toast = notifications.toasts.addWarning({ + title: i18n.translate('core.ui.publicBaseUrlWarning.configMissingTitle', { + defaultMessage: 'Configuration missing', + }), + text: mountReactNode( + <> +

+ server.publicBaseUrl, + }} + />{' '} + + + +

+ + + + { + notifications.toasts.remove(toast); + storage.setItem(MISSING_CONFIG_STORAGE_KEY, 'true'); + }} + id="mute" + > + + + + + + ), + }); +}; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 9a28bf45df92..e5dcd8f817a0 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -202,7 +202,7 @@ export class CoreSystem { }); const deprecations = this.deprecations.start({ http }); - this.coreApp.start({ application, http, notifications, uiSettings }); + this.coreApp.start({ application, docLinks, http, notifications, uiSettings }); const core: InternalCoreStart = { application, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 502b22a6f8e8..43c21b37ee29 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -29,6 +29,7 @@ export class DocLinksService { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL, links: { + settings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/settings.html`, canvas: { guide: `${KIBANA_DOCS}canvas.html`, }, @@ -426,6 +427,7 @@ export interface DocLinksStart { readonly DOC_LINK_VERSION: string; readonly ELASTIC_WEBSITE_URL: string; readonly links: { + readonly settings: string; readonly canvas: { readonly guide: string; }; diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 61f501c844f3..fff99d84a76a 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -18,7 +18,10 @@ export type HttpSetupMock = jest.Mocked & { anonymousPaths: jest.Mocked; }; -const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ +const createServiceMock = ({ + basePath = '', + publicBaseUrl, +}: { basePath?: string; publicBaseUrl?: string } = {}): HttpSetupMock => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -27,7 +30,7 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: new BasePath(basePath), + basePath: new BasePath(basePath, undefined, publicBaseUrl), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index ca95b253f9cd..32897f10425d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -487,6 +487,7 @@ export interface DocLinksStart { readonly ELASTIC_WEBSITE_URL: string; // (undocumented) readonly links: { + readonly settings: string; readonly canvas: { readonly guide: string; }; From 648841429c716a78a7ca2e300fef2b11d7112648 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Mon, 28 Jun 2021 07:35:03 -0700 Subject: [PATCH 08/65] Update health.asciidoc --- docs/api/task-manager/health.asciidoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/api/task-manager/health.asciidoc b/docs/api/task-manager/health.asciidoc index 22006725da00..7418d44bbfd3 100644 --- a/docs/api/task-manager/health.asciidoc +++ b/docs/api/task-manager/health.asciidoc @@ -6,17 +6,20 @@ Retrieve the health status of the {kib} Task Manager. +[float] [[task-manager-api-health-request]] ==== Request `GET :/api/task_manager/_health` +[float] [[task-manager-api-health-codes]] ==== Response code `200`:: Indicates a successful call. +[float] [[task-manager-api-health-example]] ==== Example From 91fc3cc2b9a797e2ada23a89217214d18921b9f6 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 28 Jun 2021 10:39:23 -0400 Subject: [PATCH 09/65] [Security Solution][Endpoint] Refresh action and comments on the Case Details view when Isolation actions are created (#103160) * Expose new Prop from `CaseComponent` that exposes data refresh callbacks * Refresh case actions and comments if isolation was created successfully --- x-pack/plugins/cases/common/ui/types.ts | 17 +++++ .../public/components/case_view/index.tsx | 72 +++++++++++++++++-- .../public/containers/use_get_case.test.tsx | 13 ++++ .../cases/public/containers/use_get_case.tsx | 61 +++++++++------- .../containers/use_get_case_user_actions.tsx | 6 +- .../cases/components/case_view/index.tsx | 13 ++-- .../endpoint_host_isolation_cases_context.tsx | 36 ++++++++++ .../components/host_isolation/index.tsx | 4 ++ .../components/host_isolation/isolate.tsx | 8 ++- .../components/host_isolation/unisolate.tsx | 12 +++- .../alerts/use_host_isolation.tsx | 1 + .../side_panel/event_details/index.tsx | 11 +++ .../endpoint/routes/actions/isolation.ts | 32 +++++---- 13 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 1dbb633e32ad..3edbd3443ffc 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -23,6 +23,23 @@ export type StatusAllType = typeof StatusAll; export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; +/** + * The type for the `refreshRef` prop (a `React.Ref`) defined by the `CaseViewComponentProps`. + * + * @example + * const refreshRef = useRef(null); + * return + */ +export type CaseViewRefreshPropInterface = null | { + /** + * Refreshes the all of the user actions/comments in the view's timeline + * (note: this also triggers a silent `refreshCase()`) + */ + refreshUserActionsAndComments: () => Promise; + /** Refreshes the Case information only */ + refreshCase: () => Promise; +}; + export type Comment = CommentRequest & { associationType: AssociationType; id: string; diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index 9c6e9442c8f5..d5b535b8ddad 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; import { @@ -16,11 +16,19 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector, Ecs } from '../../../common'; +import { + CaseStatuses, + CaseAttributes, + CaseType, + Case, + CaseConnector, + Ecs, + CaseViewRefreshPropInterface, +} from '../../../common'; import { HeaderPage } from '../header_page'; import { EditableTitle } from '../header_page/editable_title'; import { TagList } from '../tag_list'; -import { useGetCase } from '../../containers/use_get_case'; +import { UseGetCase, useGetCase } from '../../containers/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../containers/use_update_case'; @@ -42,6 +50,7 @@ import { getConnectorById } from '../utils'; import { DoesNotExist } from './does_not_exist'; const gutterTimeline = '70px'; // seems to be a timeline reference from the original file + export interface CaseViewComponentProps { allCasesNavigation: CasesNavigation; caseDetailsNavigation: CasesNavigation; @@ -54,12 +63,18 @@ export interface CaseViewComponentProps { subCaseId?: string; useFetchAlertData: (alertIds: string[]) => [boolean, Record]; userCanCrud: boolean; + /** + * A React `Ref` that Exposes data refresh callbacks. + * **NOTE**: Do not hold on to the `.current` object, as it could become stale + */ + refreshRef?: MutableRefObject; } export interface CaseViewProps extends CaseViewComponentProps { onCaseDataSuccess?: (data: Case) => void; timelineIntegration?: CasesTimelineIntegration; } + export interface OnUpdateFields { key: keyof Case; value: Case[keyof Case]; @@ -78,13 +93,14 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` const MyEuiHorizontalRule = styled(EuiHorizontalRule)` margin-left: 48px; + &.euiHorizontalRule--full { width: calc(100% - 48px); } `; export interface CaseComponentProps extends CaseViewComponentProps { - fetchCase: () => void; + fetchCase: UseGetCase['fetchCase']; caseData: Case; updateCase: (newCase: Case) => void; } @@ -105,6 +121,7 @@ export const CaseComponent = React.memo( updateCase, useFetchAlertData, userCanCrud, + refreshRef, }) => { const [initLoadingData, setInitLoadingData] = useState(true); const init = useRef(true); @@ -124,6 +141,51 @@ export const CaseComponent = React.memo( subCaseId, }); + // Set `refreshRef` if needed + useEffect(() => { + let isStale = false; + + if (refreshRef) { + refreshRef.current = { + refreshCase: async () => { + // Do nothing if component (or instance of this render cycle) is stale + if (isStale) { + return; + } + + await fetchCase(); + }, + refreshUserActionsAndComments: async () => { + // Do nothing if component (or instance of this render cycle) is stale + // -- OR -- + // it is already loading + if (isStale || isLoadingUserActions) { + return; + } + + await Promise.all([ + fetchCase(true), + fetchCaseUserActions(caseId, caseData.connector.id, subCaseId), + ]); + }, + }; + + return () => { + isStale = true; + refreshRef.current = null; + }; + } + }, [ + caseData.connector.id, + caseId, + fetchCase, + fetchCaseUserActions, + isLoadingUserActions, + refreshRef, + subCaseId, + updateCase, + ]); + // Update Fields const onUpdateField = useCallback( ({ key, value, onSuccess, onError }: OnUpdateFields) => { @@ -491,6 +553,7 @@ export const CaseView = React.memo( timelineIntegration, useFetchAlertData, userCanCrud, + refreshRef, }: CaseViewProps) => { const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId); if (isError) { @@ -528,6 +591,7 @@ export const CaseView = React.memo( updateCase={updateCase} useFetchAlertData={useFetchAlertData} userCanCrud={userCanCrud} + refreshRef={refreshRef} /> diff --git a/x-pack/plugins/cases/public/containers/use_get_case.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx index 75d9ac74a8cc..c88f530709c8 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx @@ -89,6 +89,19 @@ describe('useGetCase', () => { }); }); + it('set isLoading to false when refetching case "silent"ly', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCase(true); + + expect(result.current.isLoading).toBe(false); + }); + }); + it('unhappy path', async () => { const spyOnGetCase = jest.spyOn(api, 'getCase'); spyOnGetCase.mockImplementation(() => { diff --git a/x-pack/plugins/cases/public/containers/use_get_case.tsx b/x-pack/plugins/cases/public/containers/use_get_case.tsx index 7b59f8e06b7a..b9326ad057c9 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.tsx @@ -19,7 +19,7 @@ interface CaseState { } type Action = - | { type: 'FETCH_INIT' } + | { type: 'FETCH_INIT'; payload: { silent: boolean } } | { type: 'FETCH_SUCCESS'; payload: Case } | { type: 'FETCH_FAILURE' } | { type: 'UPDATE_CASE'; payload: Case }; @@ -29,7 +29,10 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { case 'FETCH_INIT': return { ...state, - isLoading: true, + // If doing a silent fetch, then don't set `isLoading`. This helps + // with preventing screen flashing when wanting to refresh the actions + // and comments + isLoading: !action.payload?.silent, isError: false, }; case 'FETCH_SUCCESS': @@ -56,7 +59,11 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; export interface UseGetCase extends CaseState { - fetchCase: () => void; + /** + * @param [silent] When set to `true`, the `isLoading` property will not be set to `true` + * while doing the API call + */ + fetchCase: (silent?: boolean) => Promise; updateCase: (newCase: Case) => void; } @@ -74,33 +81,35 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { dispatch({ type: 'UPDATE_CASE', payload: newCase }); }, []); - const callFetch = useCallback(async () => { - try { - isCancelledRef.current = false; - abortCtrlRef.current.abort(); - abortCtrlRef.current = new AbortController(); - dispatch({ type: 'FETCH_INIT' }); + const callFetch = useCallback( + async (silent: boolean = false) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT', payload: { silent } }); - const response = await (subCaseId - ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) - : getCase(caseId, true, abortCtrlRef.current.signal)); + const response = await (subCaseId + ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) + : getCase(caseId, true, abortCtrlRef.current.signal)); - if (!isCancelledRef.current) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!isCancelledRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + dispatch({ type: 'FETCH_FAILURE' }); } - dispatch({ type: 'FETCH_FAILURE' }); } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [caseId, subCaseId]); + }, + [caseId, subCaseId, toasts] + ); useEffect(() => { callFetch(); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx index 66aa93154b31..edafa1b9a10a 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx @@ -51,7 +51,11 @@ export const initialData: CaseUserActionsState = { }; export interface UseGetCaseUserActions extends CaseUserActionsState { - fetchCaseUserActions: (caseId: string, caseConnectorId: string, subCaseId?: string) => void; + fetchCaseUserActions: ( + caseId: string, + caseConnectorId: string, + subCaseId?: string + ) => Promise; } const getExternalService = (value: string): CaseExternalService | null => diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 71bc241f65e1..dec2d409b020 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { SearchResponse } from 'elasticsearch'; import { isEmpty } from 'lodash'; @@ -19,7 +19,7 @@ import { useFormatUrl, } from '../../../common/components/link_to'; import { Ecs } from '../../../../common/ecs'; -import { Case } from '../../../../../cases/common'; +import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common'; import { TimelineId } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; import { KibanaServices, useKibana } from '../../../common/lib/kibana'; @@ -38,6 +38,7 @@ import { SEND_ALERT_TO_TIMELINE } from './translations'; import { useInsertTimeline } from '../use_insert_timeline'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; +import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; interface Props { caseId: string; @@ -176,9 +177,13 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = }) ); }, [dispatch]); + + const refreshRef = useRef(null); + return ( - <> + {casesUi.getCaseView({ + refreshRef, allCasesNavigation: { href: formattedAllCasesLink, onClick: async (e) => { @@ -247,7 +252,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = userCanCrud, })} - + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx new file mode 100644 index 000000000000..8ae9d98a3142 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { MutableRefObject, useContext } from 'react'; +import { CaseViewRefreshPropInterface } from '../../../../../../cases/common'; + +/** + * React Context that can hold the `Ref` that is created an passed to `CaseViewProps['refreshRef`]`, enabling + * child components to trigger a refresh of a case. + */ +export const CaseDetailsRefreshContext = React.createContext | null>( + null +); + +/** + * Returns the closes CaseDetails Refresh interface if any. Used in conjuction with `CaseDetailsRefreshContext` component + * + * @example + * // Higher-order component + * const refreshRef = useRef(null); + * return .... + * + * // Now, use the hook from a hild component that was rendered inside of `` + * const caseDetailsRefresh = useWithCaseDetailsRefresh(); + * ... + * if (caseDetailsRefresh) { + * caseDetailsRefresh.refreshUserActionsAndComments(); + * } + */ +export const useWithCaseDetailsRefresh = (): Readonly | undefined => { + return useContext(CaseDetailsRefreshContext)?.current; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index ef311a7ca43b..36443cc91f4e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -20,10 +20,12 @@ export const HostIsolationPanel = React.memo( ({ details, cancelCallback, + successCallback, isolateAction, }: { details: Maybe; cancelCallback: () => void; + successCallback?: () => void; isolateAction: string; }) => { const endpointId = useMemo(() => { @@ -92,6 +94,7 @@ export const HostIsolationPanel = React.memo( cases={associatedCases} casesInfo={casesInfo} cancelCallback={cancelCallback} + successCallback={successCallback} /> ) : ( ); } diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx index b209c2f9c6e2..75dd850c30f4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx @@ -24,12 +24,14 @@ export const IsolateHost = React.memo( cases, casesInfo, cancelCallback, + successCallback, }: { endpointId: string; hostName: string; cases: ReactNode; casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; + successCallback?: () => void; }) => { const [comment, setComment] = useState(''); const [isIsolated, setIsIsolated] = useState(false); @@ -43,7 +45,11 @@ export const IsolateHost = React.memo( const confirmHostIsolation = useCallback(async () => { const hostIsolated = await isolateHost(); setIsIsolated(hostIsolated); - }, [isolateHost]); + + if (hostIsolated && successCallback) { + successCallback(); + } + }, [isolateHost, successCallback]); const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index ad8e8eaddb39..2b810dc16eec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -24,12 +24,14 @@ export const UnisolateHost = React.memo( cases, casesInfo, cancelCallback, + successCallback, }: { endpointId: string; hostName: string; cases: ReactNode; casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; + successCallback?: () => void; }) => { const [comment, setComment] = useState(''); const [isUnIsolated, setIsUnIsolated] = useState(false); @@ -41,9 +43,13 @@ export const UnisolateHost = React.memo( const { loading, unIsolateHost } = useHostUnisolation({ endpointId, comment, caseIds }); const confirmHostUnIsolation = useCallback(async () => { - const hostIsolated = await unIsolateHost(); - setIsUnIsolated(hostIsolated); - }, [unIsolateHost]); + const hostUnIsolated = await unIsolateHost(); + setIsUnIsolated(hostUnIsolated); + + if (hostUnIsolated && successCallback) { + successCallback(); + } + }, [successCallback, unIsolateHost]); const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx index 12426e05ba52..70d1d5ab5e19 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx @@ -12,6 +12,7 @@ import { createHostIsolation } from './api'; interface HostIsolationStatus { loading: boolean; + /** Boolean return will indicate if isolation action was created successful */ isolateHost: () => Promise; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 395538610f56..c4b19863ce7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -33,6 +33,7 @@ import { import { ALERT_DETAILS } from './translations'; import { useIsolationPrivileges } from '../../../../common/hooks/endpoint/use_isolate_privileges'; import { endpointAlertCheck } from '../../../../common/utils/endpoint_alert_check'; +import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -121,6 +122,15 @@ const EventDetailsPanelComponent: React.FC = ({ ); }, [showAlertDetails, isolateAction]); + const caseDetailsRefresh = useWithCaseDetailsRefresh(); + + const handleIsolationActionSuccess = useCallback(() => { + // If a case details refresh ref is defined, then refresh actions and comments + if (caseDetailsRefresh) { + caseDetailsRefresh.refreshUserActionsAndComments(); + } + }, [caseDetailsRefresh]); + if (!expandedEvent?.eventId) { return null; } @@ -139,6 +149,7 @@ const EventDetailsPanelComponent: React.FC = ({ ) : ( diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 50fe2ffe2cea..785434aa17ec 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -61,6 +61,7 @@ export const isolationRequestHandler = function ( TypeOf, SecuritySolutionRequestHandlerContext > { + // eslint-disable-next-line complexity return async (context, req, res) => { if ( (!req.body.agent_ids || req.body.agent_ids.length === 0) && @@ -100,14 +101,14 @@ export const isolationRequestHandler = function ( } agentIDs = [...new Set(agentIDs)]; // dedupe + const casesClient = await endpointContext.service.getCasesClient(req); + // convert any alert IDs into cases let caseIDs: string[] = req.body.case_ids?.slice() || []; if (req.body.alert_ids && req.body.alert_ids.length > 0) { const newIDs: string[][] = await Promise.all( req.body.alert_ids.map(async (a: string) => { - const cases: CasesByAlertId = await ( - await endpointContext.service.getCasesClient(req) - ).cases.getCasesByAlertID({ + const cases: CasesByAlertId = await casesClient.cases.getCasesByAlertID({ alertID: a, options: { owner: APP_ID }, }); @@ -167,16 +168,21 @@ export const isolationRequestHandler = function ( commentLines.push(`\n\nWith Comment:\n> ${req.body.comment}`); } - caseIDs.forEach(async (caseId) => { - (await endpointContext.service.getCasesClient(req)).attachments.add({ - caseId, - comment: { - comment: commentLines.join('\n'), - type: CommentType.user, - owner: APP_ID, - }, - }); - }); + // Update all cases with a comment + if (caseIDs.length > 0) { + await Promise.all( + caseIDs.map((caseId) => + casesClient.attachments.add({ + caseId, + comment: { + comment: commentLines.join('\n'), + type: CommentType.user, + owner: APP_ID, + }, + }) + ) + ); + } return res.ok({ body: { From 22d5c90855a9e4c23fec00b7d29ac13322eb353d Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 28 Jun 2021 15:58:41 +0100 Subject: [PATCH 10/65] chore(NA): moving @kbn/spec-to-console into bazel (#103470) * chore(NA): moving @kbn/spec-to-console into bazel * chore(NA): fix licenses --- .../monorepo-packages.asciidoc | 1 + package.json | 1 + packages/BUILD.bazel | 1 + packages/kbn-spec-to-console/BUILD.bazel | 55 +++++++++++++++++++ .../cluster_health_autocomplete.json | 0 .../__fixtures__}/cluster_health_spec.json | 0 .../kbn-spec-to-console/lib/convert.test.js | 4 +- packages/kbn-spec-to-console/package.json | 5 +- scripts/spec_to_console.js | 2 +- yarn.lock | 4 ++ 10 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 packages/kbn-spec-to-console/BUILD.bazel rename packages/kbn-spec-to-console/{test/fixtures => lib/__fixtures__}/cluster_health_autocomplete.json (100%) rename packages/kbn-spec-to-console/{test/fixtures => lib/__fixtures__}/cluster_health_spec.json (100%) diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 217645b90381..0ee4c0919289 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -103,6 +103,7 @@ yarn kbn watch-bazel - @kbn/securitysolution-utils - @kbn/server-http-tools - @kbn/server-route-repository +- @kbn/spec-to-console - @kbn/std - @kbn/storybook - @kbn/telemetry-utils diff --git a/package.json b/package.json index ceb178d06851..b589153d2af9 100644 --- a/package.json +++ b/package.json @@ -470,6 +470,7 @@ "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:bazel-bin/packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", + "@kbn/spec-to-console": "link:bazel-bin/packages/kbn-spec-to-console", "@kbn/storybook": "link:bazel-bin/packages/kbn-storybook", "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 1094a2def3e7..225a41a5fd8b 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -48,6 +48,7 @@ filegroup( "//packages/kbn-securitysolution-hook-utils:build", "//packages/kbn-server-http-tools:build", "//packages/kbn-server-route-repository:build", + "//packages/kbn-spec-to-console:build", "//packages/kbn-std:build", "//packages/kbn-storybook:build", "//packages/kbn-telemetry-tools:build", diff --git a/packages/kbn-spec-to-console/BUILD.bazel b/packages/kbn-spec-to-console/BUILD.bazel new file mode 100644 index 000000000000..8a083be9fce9 --- /dev/null +++ b/packages/kbn-spec-to-console/BUILD.bazel @@ -0,0 +1,55 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-spec-to-console" +PKG_REQUIRE_NAME = "@kbn/spec-to-console" + +SOURCE_FILES = glob( + [ + "bin/**/*", + "lib/**/*", + "index.js" + ], + exclude = [ + "**/*.test.*", + "**/__fixtures__/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-spec-to-console/test/fixtures/cluster_health_autocomplete.json b/packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_autocomplete.json similarity index 100% rename from packages/kbn-spec-to-console/test/fixtures/cluster_health_autocomplete.json rename to packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_autocomplete.json diff --git a/packages/kbn-spec-to-console/test/fixtures/cluster_health_spec.json b/packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_spec.json similarity index 100% rename from packages/kbn-spec-to-console/test/fixtures/cluster_health_spec.json rename to packages/kbn-spec-to-console/lib/__fixtures__/cluster_health_spec.json diff --git a/packages/kbn-spec-to-console/lib/convert.test.js b/packages/kbn-spec-to-console/lib/convert.test.js index 6d6b6ba364d3..14cb2dd7b6c0 100644 --- a/packages/kbn-spec-to-console/lib/convert.test.js +++ b/packages/kbn-spec-to-console/lib/convert.test.js @@ -8,8 +8,8 @@ const convert = require('./convert'); -const clusterHealthSpec = require('../test/fixtures/cluster_health_spec'); -const clusterHealthAutocomplete = require('../test/fixtures/cluster_health_autocomplete'); +const clusterHealthSpec = require('./__fixtures__/cluster_health_spec'); +const clusterHealthAutocomplete = require('./__fixtures__/cluster_health_autocomplete'); test('convert', () => { expect(convert(clusterHealthSpec)).toEqual(clusterHealthAutocomplete); diff --git a/packages/kbn-spec-to-console/package.json b/packages/kbn-spec-to-console/package.json index c6cf8cf9ec46..b4e488db7f4d 100644 --- a/packages/kbn-spec-to-console/package.json +++ b/packages/kbn-spec-to-console/package.json @@ -1,11 +1,12 @@ { - "name": "spec-to-console", - "version": "0.0.0", + "name": "@kbn/spec-to-console", + "version": "1.0.0", "description": "ES REST spec -> Console autocomplete", "main": "index.js", "directories": { "lib": "lib" }, + "private": true, "scripts": { "format": "../../node_modules/.bin/prettier **/*.js --write" }, diff --git a/scripts/spec_to_console.js b/scripts/spec_to_console.js index cbb152f55f8f..37e246323a11 100644 --- a/scripts/spec_to_console.js +++ b/scripts/spec_to_console.js @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -require('../packages/kbn-spec-to-console/bin/spec_to_console'); +require('@kbn/spec-to-console/bin/spec_to_console'); diff --git a/yarn.lock b/yarn.lock index 9d7569b6ab4f..2ea799810e3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,6 +2768,10 @@ version "0.0.0" uid "" +"@kbn/spec-to-console@link:bazel-bin/packages/kbn-spec-to-console": + version "0.0.0" + uid "" + "@kbn/std@link:bazel-bin/packages/kbn-std": version "0.0.0" uid "" From 8fa4421cac0e52302121fa57880bc2e6a2a4f791 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 28 Jun 2021 18:13:31 +0300 Subject: [PATCH 11/65] Update TSVB docs with guidelines on how to group by multiple fields (#103224) * Update TSVB FAQ section with guidelines on how to group by multiple fields * Apply text suggestion --- .../images/tsvb_group_by_multiple_fields.png | Bin 0 -> 146657 bytes docs/user/dashboard/tsvb.asciidoc | 12 ++++++++++++ 2 files changed, 12 insertions(+) create mode 100644 docs/user/dashboard/images/tsvb_group_by_multiple_fields.png diff --git a/docs/user/dashboard/images/tsvb_group_by_multiple_fields.png b/docs/user/dashboard/images/tsvb_group_by_multiple_fields.png new file mode 100644 index 0000000000000000000000000000000000000000..3f23189f7725424129a356848c170daec82d2957 GIT binary patch literal 146657 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 z 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 Q7Xd%V%`8lF4_^%bKZRKwg#Z8m literal 0 HcmV?d00001 diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index b69df7c7d26d..93ee3627bd8a 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -166,3 +166,15 @@ For other types of month over month calculations, use <> o Calculating the duration between the start and end of an event is unsupported in *TSVB* because *TSVB* requires correlation between different time periods. *TSVB* requires that the duration is pre-calculated. + +[float] +===== How do I group on multiple fields? + +To group with multiple fields, create runtime fields in the index pattern you are visualizing. + +. Create a runtime field. Refer to <> for more information. ++ +[role="screenshot"] +image::images/tsvb_group_by_multiple_fields.png[Group by multiple fields] + +. Create a new TSVB visualization and group by this field. \ No newline at end of file From 43a897e640477b69c42b1fa551d855c4d2e6d5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Mon, 28 Jun 2021 17:24:26 +0200 Subject: [PATCH 12/65] [Security Solution][Endpoint] The refresh button triggers always a refresh action (#103118) * Triggers search even if the query hasn't changed * Add await on async function call * refactor, use forceSearch store flag instead of new actin type to force search * fix error when refreshing trusted apps * Fix ts-error by adding ts-ignore '{ type: "LoadingResourceState"; previousState: ImmutableObject | ImmutableObject> | ImmutableObject<...> | ImmutableObject<...>; }' is not assignable to type 'ImmutableObject<{ forceRefresh: boolean; }>'. Object literal may only specify known properties, and 'type' does not exist in type 'ImmutableObject<{ forceRefresh: boolean; }>' --- .../pages/event_filters/store/action.ts | 9 +++++- .../pages/event_filters/store/middleware.ts | 2 +- .../pages/event_filters/store/reducer.test.ts | 31 +++++++++++++++++++ .../pages/event_filters/store/reducer.ts | 13 ++++++++ .../pages/event_filters/store/selector.ts | 3 +- .../event_filters/store/selectors.test.ts | 5 --- .../view/event_filters_list_page.tsx | 10 ++++-- .../state/trusted_apps_list_page_state.ts | 1 + .../pages/trusted_apps/store/action.ts | 9 +++++- .../pages/trusted_apps/store/builders.ts | 1 + .../pages/trusted_apps/store/middleware.ts | 3 +- .../pages/trusted_apps/store/reducer.test.ts | 25 +++++++++++++++ .../pages/trusted_apps/store/reducer.ts | 11 +++++++ .../pages/trusted_apps/store/selectors.ts | 17 +++++----- .../trusted_apps/view/trusted_apps_page.tsx | 15 +++++++-- 15 files changed, 131 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts index 016170686c7d..588eb9275ad2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts @@ -66,6 +66,12 @@ export type EventFiltersFormStateChanged = Action<'eventFiltersFormStateChanged' payload: AsyncResourceState; }; +export type EventFiltersForceRefresh = Action<'eventFiltersForceRefresh'> & { + payload: { + forceRefresh: boolean; + }; +}; + export type EventFiltersPageAction = | EventFiltersListPageDataChanged | EventFiltersListPageDataExistsChanged @@ -81,4 +87,5 @@ export type EventFiltersPageAction = | EventFilterForDeletion | EventFilterDeletionReset | EventFilterDeleteSubmit - | EventFilterDeleteStatusChanged; + | EventFilterDeleteStatusChanged + | EventFiltersForceRefresh; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts index c1ade4e2cade..ad9e3d32a3f4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts @@ -232,9 +232,9 @@ const refreshListDataIfNeeded: MiddlewareActionHandler = async (store, eventFilt dispatch({ type: 'eventFiltersListPageDataChanged', payload: { - type: 'LoadingResourceState', // Ignore will be fixed with when AsyncResourceState is refactored (#830) // @ts-ignore + type: 'LoadingResourceState', previousState: getCurrentListPageDataState(state), }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts index 5366b6dcf155..2bfc6b437883 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts @@ -168,4 +168,35 @@ describe('event filters reducer', () => { }); }); }); + + describe('ForceRefresh', () => { + it('sets the force refresh state to true', () => { + const result = eventFiltersPageReducer( + { + ...initialState, + listPage: { ...initialState.listPage, forceRefresh: false }, + }, + { type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } } + ); + + expect(result).toStrictEqual({ + ...initialState, + listPage: { ...initialState.listPage, forceRefresh: true }, + }); + }); + it('sets the force refresh state to false', () => { + const result = eventFiltersPageReducer( + { + ...initialState, + listPage: { ...initialState.listPage, forceRefresh: true }, + }, + { type: 'eventFiltersForceRefresh', payload: { forceRefresh: false } } + ); + + expect(result).toStrictEqual({ + ...initialState, + listPage: { ...initialState.listPage, forceRefresh: false }, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts index 28292bdb1ed1..b6e853ca4bf0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts @@ -30,6 +30,7 @@ import { EventFilterForDeletion, EventFilterDeletionReset, EventFilterDeleteStatusChanged, + EventFiltersForceRefresh, } from './action'; import { initialEventFiltersPageState } from './builders'; @@ -220,6 +221,16 @@ const handleEventFilterDeleteStatusChanges: CaseReducer = (state, action) => { + return { + ...state, + listPage: { + ...state.listPage, + forceRefresh: action.payload.forceRefresh, + }, + }; +}; + export const eventFiltersPageReducer: StateReducer = ( state = initialEventFiltersPageState(), action @@ -237,6 +248,8 @@ export const eventFiltersPageReducer: StateReducer = ( return eventFiltersUpdateSuccess(state, action); case 'userChangedUrl': return userChangedUrl(state, action); + case 'eventFiltersForceRefresh': + return handleEventFilterForceRefresh(state, action); } // actions only handled if we're on the List Page diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts index fef6ccb99a17..2fa196a053f7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts @@ -184,8 +184,7 @@ export const listDataNeedsRefresh: EventFiltersSelector = createSelecto return ( forceRefresh || location.page_index + 1 !== currentQuery.page || - location.page_size !== currentQuery.perPage || - location.filter !== currentQuery.filter + location.page_size !== currentQuery.perPage ); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts index 9d2d3c394c41..be3de3017d1f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts @@ -250,11 +250,6 @@ describe('event filters selectors', () => { initialState.location.page_index = 10; expect(listDataNeedsRefresh(initialState)).toBe(true); }); - - it('should should return true if filter param differ from last api call', () => { - initialState.location.filter = 'query'; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); }); describe('getFormEntry()', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 0975104f0229..1f3b721fd51e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -177,9 +177,13 @@ export const EventFiltersListPage = memo(() => { [navigateCallback] ); - const handleOnSearch = useCallback((query: string) => navigateCallback({ filter: query }), [ - navigateCallback, - ]); + const handleOnSearch = useCallback( + (query: string) => { + dispatch({ type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } }); + navigateCallback({ filter: query }); + }, + [navigateCallback, dispatch] + ); return ( ; location: TrustedAppsListPageLocation; active: boolean; + forceRefresh: boolean; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index 34f48142c703..a3f804ed6cd7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -68,6 +68,12 @@ export type TrustedAppsPoliciesStateChanged = Action<'trustedAppsPoliciesStateCh payload: AsyncResourceState; }; +export type TrustedAppForceRefresh = Action<'trustedAppForceRefresh'> & { + payload: { + forceRefresh: boolean; + }; +}; + export type TrustedAppsPageAction = | TrustedAppsListDataOutdated | TrustedAppsListResourceStateChanged @@ -82,4 +88,5 @@ export type TrustedAppsPageAction = | TrustedAppCreationDialogConfirmed | TrustedAppsExistResponse | TrustedAppsPoliciesStateChanged - | TrustedAppCreationDialogClosed; + | TrustedAppCreationDialogClosed + | TrustedAppForceRefresh; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 5a22badec9af..988d3d6e828c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -59,4 +59,5 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ filter: '', }, active: false, + forceRefresh: false, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 878938aa20e1..da6394a9ab89 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -80,6 +80,7 @@ const refreshListIfNeeded = async ( trustedAppsService: TrustedAppsService ) => { if (needsRefreshOfListData(store.getState())) { + store.dispatch({ type: 'trustedAppForceRefresh', payload: { forceRefresh: false } }); store.dispatch( createTrustedAppsListResourceStateChangedAction({ type: 'LoadingResourceState', @@ -395,11 +396,11 @@ const fetchEditTrustedAppIfNeeded = async ( dispatch({ type: 'trustedAppCreationEditItemStateChanged', payload: { - type: 'LoadingResourceState', // No easy way to get around this that I can see. `previousState` does not // seem to allow everything that `editItem` state can hold, so not even sure if using // type guards would work here // @ts-ignore + type: 'LoadingResourceState', previousState: editItemState(currentState)!, }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 58193eea3de5..42659e5cc349 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -191,4 +191,29 @@ describe('reducer', () => { expect(result).toStrictEqual(initialState); }); }); + + describe('TrustedAppsForceRefresh', () => { + it('sets the force refresh state to true', () => { + const result = trustedAppsPageReducer( + { + ...initialState, + forceRefresh: false, + }, + { type: 'trustedAppForceRefresh', payload: { forceRefresh: true } } + ); + + expect(result).toStrictEqual({ ...initialState, forceRefresh: true }); + }); + it('sets the force refresh state to false', () => { + const result = trustedAppsPageReducer( + { + ...initialState, + forceRefresh: true, + }, + { type: 'trustedAppForceRefresh', payload: { forceRefresh: false } } + ); + + expect(result).toStrictEqual({ ...initialState, forceRefresh: false }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index ea7bbb44c9bf..d0de5dc80ee7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -31,6 +31,7 @@ import { TrustedAppsExistResponse, TrustedAppsPoliciesStateChanged, TrustedAppCreationEditItemStateChanged, + TrustedAppForceRefresh, } from './action'; import { TrustedAppsListPageState } from '../state'; @@ -177,6 +178,13 @@ const updatePolicies: CaseReducer = (state, { p return state; }; +const forceRefresh: CaseReducer = (state, { payload }) => { + return { + ...state, + forceRefresh: payload.forceRefresh, + }; +}; + export const trustedAppsPageReducer: StateReducer = ( state = initialTrustedAppsPageState(), action @@ -226,6 +234,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppsPoliciesStateChanged': return updatePolicies(state, action); + + case 'trustedAppForceRefresh': + return forceRefresh(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 43506f98193a..338f30b447a8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -30,16 +30,17 @@ export const needsRefreshOfListData = (state: Immutable { - return ( - data.pageIndex === location.page_index && - data.pageSize === location.page_size && - data.timestamp >= freshDataTimestamp && - data.filter === location.filter - ); - }) + (forceRefresh || + isOutdatedResourceState(currentPage, (data) => { + return ( + data.pageIndex === location.page_index && + data.pageSize === location.page_size && + data.timestamp >= freshDataTimestamp + ); + })) ); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index 4cd6ad62f3a3..ec80b4c5ae21 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -5,7 +5,9 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; import { useLocation } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -33,6 +35,7 @@ import { TrustedAppsGrid } from './components/trusted_apps_grid'; import { TrustedAppsList } from './components/trusted_apps_list'; import { TrustedAppDeletionDialog } from './trusted_app_deletion_dialog'; import { TrustedAppsNotifications } from './trusted_apps_notifications'; +import { AppAction } from '../../../../common/store/actions'; import { ABOUT_TRUSTED_APPS, SEARCH_TRUSTED_APP_PLACEHOLDER } from './translations'; import { EmptyState } from './components/empty_state'; import { SearchBar } from '../../../components/search_bar'; @@ -40,11 +43,13 @@ import { BackToExternalAppButton } from '../../../components/back_to_external_ap import { ListPageRouteState } from '../../../../../common/endpoint/types'; export const TrustedAppsPage = memo(() => { + const dispatch = useDispatch>(); const { state: routeState } = useLocation(); const location = useTrustedAppsSelector(getCurrentLocation); const totalItemsCount = useTrustedAppsSelector(getListTotalItemsCount); const isCheckingIfEntriesExists = useTrustedAppsSelector(checkingIfEntriesExist); const doEntriesExist = useTrustedAppsSelector(entriesExist) === true; + const navigationCallback = useTrustedAppsNavigateCallback((query: string) => ({ filter: query })); const handleAddButtonClick = useTrustedAppsNavigateCallback(() => ({ show: 'create', id: undefined, @@ -56,7 +61,13 @@ export const TrustedAppsPage = memo(() => { const handleViewTypeChange = useTrustedAppsNavigateCallback((viewType: ViewType) => ({ view_type: viewType, })); - const handleOnSearch = useTrustedAppsNavigateCallback((query: string) => ({ filter: query })); + const handleOnSearch = useCallback( + (query: string) => { + dispatch({ type: 'trustedAppForceRefresh', payload: { forceRefresh: true } }); + navigationCallback(query); + }, + [dispatch, navigationCallback] + ); const showCreateFlyout = !!location.show; From 84e1b01ceb2954a74c4ea67f8695fe41b736d85a Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 28 Jun 2021 08:39:27 -0700 Subject: [PATCH 13/65] Result settings: Fix restore defaults copy (#103413) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/result_settings/result_settings_logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts index 13530c2c29ef..216c43e1d307 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts @@ -92,7 +92,7 @@ const RESET_CONFIRMATION_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.resultSettings.confirmResetMessage', { defaultMessage: - 'This will revert your settings back to the default: all fields set to raw. The default will take over immediately and impact your search results.', + 'Are you sure you want to restore result settings defaults? This will set all fields back to raw with no limits.', } ); From 96fe9c23f878354df5b9122d48093e696f014c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Mon, 28 Jun 2021 17:48:54 +0200 Subject: [PATCH 14/65] [Security solution][Endpoint] Get os name from host.os.name when agent type endpoint (#103450) * When type endpoint gets os type from os name instead of os family * Allow users add event filters only for endpoint events * Fixes error with wrong map function Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/event_filters/store/utils.ts | 16 +++++++++------- .../pages/event_filters/test_utils/index.ts | 1 + .../components/timeline/body/actions/index.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts index 6adc490b40e7..e0f9a6bcc965 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts @@ -10,6 +10,14 @@ import type { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts- import { Ecs } from '../../../../../common/ecs'; import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '../constants'; +const osTypeBasedOnAgentType = (data?: Ecs) => { + if (data?.agent?.type?.includes('endpoint')) { + return (data?.host?.os?.name || ['windows']).map((name) => name.toLowerCase()); + } else { + return data?.host?.os?.family ?? ['windows']; + } +}; + export const getInitialExceptionFromEvent = (data?: Ecs): CreateExceptionListItemSchema => ({ comments: [], description: '', @@ -46,11 +54,5 @@ export const getInitialExceptionFromEvent = (data?: Ecs): CreateExceptionListIte namespace_type: 'agnostic', tags: ['policy:all'], type: 'simple', - // TODO: Try to fix this type casting - os_types: [ - (data && data.host ? data.host.os?.family ?? ['windows'] : ['windows'])[0] as - | 'windows' - | 'linux' - | 'macos', - ], + os_types: osTypeBasedOnAgentType(data) as Array<'windows' | 'linux' | 'macos'>, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts index dc235cf51115..c45d0f88927b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts @@ -50,6 +50,7 @@ export const ecsEventMock = (): Ecs => ({ name: ['Host-tvs68wo3qc'], os: { family: ['windows'], + name: ['Windows'], }, id: ['a563b365-2bee-40df-adcd-ae84d889f523'], ip: ['10.242.233.187'], diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 0a3a1cd88acc..29e00d169b4e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -87,9 +87,9 @@ const ActionsComponent: React.FC = ({ ); const eventType = getEventType(ecsData); - const isEventContextMenuEnabled = useMemo( - () => !!ecsData.event?.kind && ecsData.event?.kind[0] === 'event', - [ecsData.event?.kind] + const isEventContextMenuEnabledForEndpoint = useMemo( + () => ecsData.event?.kind?.includes('event') && ecsData.agent?.type?.includes('endpoint'), + [ecsData.event?.kind, ecsData.agent?.type] ); return ( @@ -174,7 +174,7 @@ const ActionsComponent: React.FC = ({ key="alert-context-menu" ecsRowData={ecsData} timelineId={timelineId} - disabled={eventType !== 'signal' && !isEventContextMenuEnabled} + disabled={eventType !== 'signal' && !isEventContextMenuEnabledForEndpoint} refetch={refetch ?? noop} onRuleChange={onRuleChange} /> From 15b0dbff7bc27cee3bfbd938f698ac5053dc854e Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 28 Jun 2021 19:18:30 +0300 Subject: [PATCH 15/65] [TSVB] Fix references to the index pattern are not embedded when exporting a saved object (#103255) * [TSVB] Importing a dashboard with only TSVB viz on another space, breaks the dashboard Closes: #103059 * move index-pattern to constant Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...na-plugin-plugins-data-public.esfilters.md | 8 ++-- ...bana-plugin-plugins-data-public.eskuery.md | 2 +- ...bana-plugin-plugins-data-public.esquery.md | 2 +- ...lugin-plugins-data-public.iindexpattern.md | 2 +- ...-public.index_pattern_saved_object_type.md | 13 +++++++ .../kibana-plugin-plugins-data-public.md | 1 + ...na-plugin-plugins-data-server.esfilters.md | 8 ++-- ...bana-plugin-plugins-data-server.eskuery.md | 2 +- ...bana-plugin-plugins-data-server.esquery.md | 2 +- ...-server.index_pattern_saved_object_type.md | 13 +++++++ .../kibana-plugin-plugins-data-server.md | 1 + .../saved_objects/dashboard_migrations.ts | 29 ++++++++------ .../replace_index_pattern_reference.test.ts | 39 +++++++++++++++++++ .../replace_index_pattern_reference.ts | 22 +++++++++++ src/plugins/data/common/constants.ts | 3 ++ .../index_patterns/index_patterns.ts | 23 ++++++----- .../common/index_patterns/lib/get_title.ts | 6 ++- .../data/common/index_patterns/utils.ts | 4 +- .../search_source/extract_references.ts | 6 ++- src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 31 ++++++++------- src/plugins/data/server/index.ts | 7 +++- .../data/server/index_patterns/utils.ts | 9 ++++- .../server/saved_objects/index_patterns.ts | 5 ++- src/plugins/data/server/server.api.md | 31 ++++++++------- .../controls_references.ts | 3 +- .../timeseries_references.ts | 6 +-- ...ualization_saved_object_migrations.test.ts | 30 ++++++++++++++ .../visualization_saved_object_migrations.ts | 29 +++++++++++--- 29 files changed, 257 insertions(+), 81 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md create mode 100644 src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.ts create mode 100644 src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index 2ca4847d6dc3..80c321ce6b32 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -13,11 +13,11 @@ esFilters: { FILTERS: typeof FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhrasesFilter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").MinimalIndexPattern) => import("../common").ExistsFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").MinimalIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isPhraseFilter: (filter: any) => filter is import("../common").PhraseFilter; isExistsFilter: (filter: any) => filter is import("../common").ExistsFilter; isPhrasesFilter: (filter: any) => filter is import("../common").PhrasesFilter; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md index 881a1fa803ca..332114e63758 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").MinimalIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md index 70805aaaaee8..0bc9c0c12fc3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esquery.md @@ -10,7 +10,7 @@ esQuery: { buildEsQuery: typeof buildEsQuery; getEsQueryConfig: typeof getEsQueryConfig; - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").MinimalIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md index 88d8520a373c..ec29ef81a6e6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md @@ -12,7 +12,7 @@ Signature: ```typescript -export interface IIndexPattern extends MinimalIndexPattern +export interface IIndexPattern extends IndexPatternBase ``` ## Properties diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md new file mode 100644 index 000000000000..552d13198451 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md) + +## INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE variable + +\* + +Signature: + +```typescript +INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern" +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 7c023e756ebd..65c4601d5fae 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -118,6 +118,7 @@ | [fieldFormats](./kibana-plugin-plugins-data-public.fieldformats.md) | | | [fieldList](./kibana-plugin-plugins-data-public.fieldlist.md) | | | [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | Get the esTypes known by all kbnFieldTypes {Array} | +| [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md) | \* | | [indexPatterns](./kibana-plugin-plugins-data-public.indexpatterns.md) | | | [injectSearchSourceReferences](./kibana-plugin-plugins-data-public.injectsearchsourcereferences.md) | | | [isCompleteResponse](./kibana-plugin-plugins-data-public.iscompleteresponse.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md index d951cb242694..d009cad9ec60 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md @@ -11,11 +11,11 @@ esFilters: { buildQueryFilter: (query: any, index: string, alias: string) => import("../common").QueryStringFilter; buildCustomFilter: typeof buildCustomFilter; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("../common").Filter; - buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").MinimalIndexPattern) => import("../common").ExistsFilter; + buildExistsFilter: (field: import("../common").IFieldType, indexPattern: import("../common").IndexPatternBase) => import("../common").ExistsFilter; buildFilter: typeof buildFilter; - buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhraseFilter; - buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").MinimalIndexPattern) => import("../common").PhrasesFilter; - buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").MinimalIndexPattern, formattedValue?: string | undefined) => import("../common").RangeFilter; + buildPhraseFilter: (field: import("../common").IFieldType, value: any, indexPattern: import("../common").IndexPatternBase) => import("../common").PhraseFilter; + buildPhrasesFilter: (field: import("../common").IFieldType, params: any[], indexPattern: import("../common").IndexPatternBase) => import("../common").PhrasesFilter; + buildRangeFilter: (field: import("../common").IFieldType, params: import("../common").RangeFilterParams, indexPattern: import("../common").IndexPatternBase, formattedValue?: string | undefined) => import("../common").RangeFilter; isFilterDisabled: (filter: import("../common").Filter) => boolean; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md index 6274eb5f4f4a..fce25a899de8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.eskuery.md @@ -10,6 +10,6 @@ esKuery: { nodeTypes: import("../common/es_query/kuery/node_types").NodeTypes; fromKueryExpression: (expression: any, parseOptions?: Partial) => import("../common").KueryNode; - toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").MinimalIndexPattern | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; + toElasticsearchQuery: (node: import("../common").KueryNode, indexPattern?: import("../common").IndexPatternBase | undefined, config?: Record | undefined, context?: Record | undefined) => import("@kbn/common-utils").JsonObject; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md index 0d1baecb014f..68507f3fb9b8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esquery.md @@ -8,7 +8,7 @@ ```typescript esQuery: { - buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").MinimalIndexPattern | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { + buildQueryFromFilters: (filters: import("../common").Filter[] | undefined, indexPattern: import("../common").IndexPatternBase | undefined, ignoreFilterIfFieldNotInIndex?: boolean) => { must: never[]; filter: import("../common").Filter[]; should: never[]; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md new file mode 100644 index 000000000000..34f76d4ab13b --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md) + +## INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE variable + +\* + +Signature: + +```typescript +INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern" +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index 9816b884c461..ab14abdd74e8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -83,6 +83,7 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [exporters](./kibana-plugin-plugins-data-server.exporters.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | +| [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-server.index_pattern_saved_object_type.md) | \* | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | | [mergeCapabilitiesWithFields](./kibana-plugin-plugins-data-server.mergecapabilitieswithfields.md) | | | [search](./kibana-plugin-plugins-data-server.search.md) | | diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index 4ebca5ba8965..0bd100b3d580 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -7,7 +7,7 @@ */ import semver from 'semver'; -import { get, flow } from 'lodash'; +import { get, flow, identity } from 'lodash'; import { SavedObjectAttributes, SavedObjectMigrationFn, @@ -25,7 +25,9 @@ import { convertSavedDashboardPanelToPanelState, } from '../../common/embeddable/embeddable_saved_object_converters'; import { SavedObjectEmbeddableInput } from '../../../embeddable/common'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; import { SerializableValue } from '../../../kibana_utils/common'; +import { replaceIndexPatternReference } from './replace_index_pattern_reference'; function migrateIndexPattern(doc: DashboardDoc700To720) { const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON'); @@ -43,7 +45,7 @@ function migrateIndexPattern(doc: DashboardDoc700To720) { searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; doc.references.push({ name: searchSource.indexRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: searchSource.index, }); delete searchSource.index; @@ -56,7 +58,7 @@ function migrateIndexPattern(doc: DashboardDoc700To720) { filterRow.meta.indexRefName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; doc.references.push({ name: filterRow.meta.indexRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); delete filterRow.meta.index; @@ -214,12 +216,14 @@ export interface DashboardSavedObjectTypeMigrationsDeps { export const createDashboardSavedObjectTypeMigrations = ( deps: DashboardSavedObjectTypeMigrationsDeps ): SavedObjectMigrationMap => { - const embeddableMigrations = deps.embeddable - .getMigrationVersions() - .filter((version) => semver.gt(version, '7.12.0')) - .map((version): [string, SavedObjectMigrationFn] => { - return [version, migrateByValuePanels(deps, version)]; - }); + const embeddableMigrations = Object.fromEntries( + deps.embeddable + .getMigrationVersions() + .filter((version) => semver.gt(version, '7.12.0')) + .map((version): [string, SavedObjectMigrationFn] => { + return [version, migrateByValuePanels(deps, version)]; + }) + ); return { /** @@ -237,12 +241,15 @@ export const createDashboardSavedObjectTypeMigrations = ( '7.3.0': flow(migrations730), '7.9.3': flow(migrateMatchAllQuery), '7.11.0': flow(createExtractPanelReferencesMigration(deps)), - ...Object.fromEntries(embeddableMigrations), + + ...embeddableMigrations, /** * Any dashboard saved object migrations that come after this point will have to be wary of * potentially overwriting embeddable migrations. An example of how to mitigate this follows: */ - // '7.x': flow(yourNewMigrationFunction, embeddableMigrations['7.x']) + // '7.x': flow(yourNewMigrationFunction, embeddableMigrations['7.x'] ?? identity), + + '7.14.0': flow(replaceIndexPatternReference, embeddableMigrations['7.14.0'] ?? identity), }; }; diff --git a/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.ts b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.ts new file mode 100644 index 000000000000..01207fb4e340 --- /dev/null +++ b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { SavedObjectMigrationContext, SavedObjectMigrationFn } from 'kibana/server'; + +import { replaceIndexPatternReference } from './replace_index_pattern_reference'; + +describe('replaceIndexPatternReference', () => { + const savedObjectMigrationContext = (null as unknown) as SavedObjectMigrationContext; + + test('should replace index_pattern to index-pattern', () => { + const migratedDoc = replaceIndexPatternReference( + { + references: [ + { + name: 'name', + type: 'index_pattern', + }, + ], + } as Parameters[0], + savedObjectMigrationContext + ); + + expect(migratedDoc).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "name": "name", + "type": "index-pattern", + }, + ], + } + `); + }); +}); diff --git a/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts new file mode 100644 index 000000000000..ddd1c45841b9 --- /dev/null +++ b/src/plugins/dashboard/server/saved_objects/replace_index_pattern_reference.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectMigrationFn } from 'kibana/server'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; + +export const replaceIndexPatternReference: SavedObjectMigrationFn = (doc) => ({ + ...doc, + references: Array.isArray(doc.references) + ? doc.references.map((reference) => { + if (reference.type === 'index_pattern') { + reference.type = INDEX_PATTERN_SAVED_OBJECT_TYPE; + } + return reference; + }) + : doc.references, +}); diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index 79a9e0ac5451..c6bfbfc75c29 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -9,6 +9,9 @@ export const DEFAULT_QUERY_LANGUAGE = 'kuery'; export const KIBANA_USER_QUERY_LANGUAGE_KEY = 'kibana.userQueryLanguage'; +/** @public **/ +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; + export const UI_SETTINGS = { META_FIELDS: 'metaFields', DOC_HIGHLIGHT: 'doc_table:highlight', diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index e67e72f295b8..cecf3b8c07d1 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObjectsClientCommon } from '../..'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE, SavedObjectsClientCommon } from '../..'; import { createIndexPatternCache } from '.'; import type { RuntimeField } from '../types'; @@ -38,7 +38,6 @@ import { DuplicateIndexPatternError } from '../errors'; import { castEsToKbnFieldTypeName } from '../../kbn_field_types'; const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3; -const savedObjectType = 'index-pattern'; export interface IndexPatternSavedObjectAttrs { title: string; @@ -94,7 +93,7 @@ export class IndexPatternsService { */ private async refreshSavedObjectsCache() { const so = await this.savedObjectsClient.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, fields: ['title'], perPage: 10000, }); @@ -137,7 +136,7 @@ export class IndexPatternsService { */ find = async (search: string, size: number = 10): Promise => { const savedObjects = await this.savedObjectsClient.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, fields: ['title'], search, searchFields: ['title'], @@ -395,12 +394,16 @@ export class IndexPatternsService { private getSavedObjectAndInit = async (id: string): Promise => { const savedObject = await this.savedObjectsClient.get( - savedObjectType, + INDEX_PATTERN_SAVED_OBJECT_TYPE, id ); if (!savedObject.version) { - throw new SavedObjectNotFound(savedObjectType, id, 'management/kibana/indexPatterns'); + throw new SavedObjectNotFound( + INDEX_PATTERN_SAVED_OBJECT_TYPE, + id, + 'management/kibana/indexPatterns' + ); } return this.initFromSavedObject(savedObject); @@ -546,7 +549,7 @@ export class IndexPatternsService { const body = indexPattern.getAsSavedObjectBody(); const response: SavedObject = (await this.savedObjectsClient.create( - savedObjectType, + INDEX_PATTERN_SAVED_OBJECT_TYPE, body, { id: indexPattern.id, @@ -587,7 +590,9 @@ export class IndexPatternsService { }); return this.savedObjectsClient - .update(savedObjectType, indexPattern.id, body, { version: indexPattern.version }) + .update(INDEX_PATTERN_SAVED_OBJECT_TYPE, indexPattern.id, body, { + version: indexPattern.version, + }) .then((resp) => { indexPattern.id = resp.id; indexPattern.version = resp.version; @@ -655,7 +660,7 @@ export class IndexPatternsService { */ async delete(indexPatternId: string) { this.indexPatternCache.clear(indexPatternId); - return this.savedObjectsClient.delete('index-pattern', indexPatternId); + return this.savedObjectsClient.delete(INDEX_PATTERN_SAVED_OBJECT_TYPE, indexPatternId); } } diff --git a/src/plugins/data/common/index_patterns/lib/get_title.ts b/src/plugins/data/common/index_patterns/lib/get_title.ts index 2dd122092f68..69afad486a74 100644 --- a/src/plugins/data/common/index_patterns/lib/get_title.ts +++ b/src/plugins/data/common/index_patterns/lib/get_title.ts @@ -7,12 +7,16 @@ */ import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../constants'; export async function getTitle( client: SavedObjectsClientContract, indexPatternId: string ): Promise> { - const savedObject = (await client.get('index-pattern', indexPatternId)) as SimpleSavedObject; + const savedObject = (await client.get( + INDEX_PATTERN_SAVED_OBJECT_TYPE, + indexPatternId + )) as SimpleSavedObject; if (savedObject.error) { throw new Error(`Unable to get index-pattern title: ${savedObject.error.message}`); diff --git a/src/plugins/data/common/index_patterns/utils.ts b/src/plugins/data/common/index_patterns/utils.ts index 941ad3c47066..925f646b83bb 100644 --- a/src/plugins/data/common/index_patterns/utils.ts +++ b/src/plugins/data/common/index_patterns/utils.ts @@ -9,6 +9,8 @@ import type { IndexPatternSavedObjectAttrs } from './index_patterns'; import type { SavedObjectsClientCommon } from '../types'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../constants'; + /** * Returns an object matching a given title * @@ -19,7 +21,7 @@ import type { SavedObjectsClientCommon } from '../types'; export async function findByTitle(client: SavedObjectsClientCommon, title: string) { if (title) { const savedObjects = await client.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, perPage: 10, search: `"${title}"`, searchFields: ['title'], diff --git a/src/plugins/data/common/search/search_source/extract_references.ts b/src/plugins/data/common/search/search_source/extract_references.ts index 1b4d1732a5e3..b63b8ed1cfee 100644 --- a/src/plugins/data/common/search/search_source/extract_references.ts +++ b/src/plugins/data/common/search/search_source/extract_references.ts @@ -10,6 +10,8 @@ import { SavedObjectReference } from 'src/core/types'; import { Filter } from '../../es_query/filters'; import { SearchSourceFields } from './types'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../constants'; + export const extractReferences = ( state: SearchSourceFields ): [SearchSourceFields & { indexRefName?: string }, SavedObjectReference[]] => { @@ -20,7 +22,7 @@ export const extractReferences = ( const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; references.push({ name: refName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: indexId, }); searchSourceFields = { @@ -40,7 +42,7 @@ export const extractReferences = ( const refName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; references.push({ name: refName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); return { diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index d7667f20d517..e9e50ebfaf13 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -268,6 +268,7 @@ export { IndexPatternSpec, IndexPatternLoadExpressionFunctionDefinition, fieldList, + INDEX_PATTERN_SAVED_OBJECT_TYPE, } from '../common'; export { DuplicateIndexPatternError } from '../common/index_patterns/errors'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 2849b93b1448..6a49fab0e33f 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1361,6 +1361,9 @@ export interface IKibanaSearchResponse { // @public (undocumented) export type IMetricAggType = MetricAggType; +// @public (undocumented) +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern"; + // Warning: (ae-missing-release-tag) "IndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2772,20 +2775,20 @@ export interface WaitUntilNextSessionCompletesOptions { // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:431:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:432:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:435:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:432:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:436:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index dd60951e6d22..143400a2c09d 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -117,7 +117,12 @@ export const fieldFormats = { HistogramFormat, }; -export { IFieldFormatsRegistry, FieldFormatsGetConfigFn, FieldFormatConfig } from '../common'; +export { + IFieldFormatsRegistry, + FieldFormatsGetConfigFn, + FieldFormatConfig, + INDEX_PATTERN_SAVED_OBJECT_TYPE, +} from '../common'; /* * Index patterns: diff --git a/src/plugins/data/server/index_patterns/utils.ts b/src/plugins/data/server/index_patterns/utils.ts index bb16be23edc7..7f1a953c482d 100644 --- a/src/plugins/data/server/index_patterns/utils.ts +++ b/src/plugins/data/server/index_patterns/utils.ts @@ -7,7 +7,12 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; -import { IFieldType, IndexPatternAttributes, SavedObject } from '../../common'; +import { + IFieldType, + INDEX_PATTERN_SAVED_OBJECT_TYPE, + IndexPatternAttributes, + SavedObject, +} from '../../common'; export const getFieldByName = ( fieldName: string, @@ -24,7 +29,7 @@ export const findIndexPatternById = async ( index: string ): Promise | undefined> => { const savedObjectsResponse = await savedObjectsClient.find({ - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, fields: ['fields'], search: `"${index}"`, searchFields: ['title'], diff --git a/src/plugins/data/server/saved_objects/index_patterns.ts b/src/plugins/data/server/saved_objects/index_patterns.ts index f570e239c3c6..a809f2ce73e1 100644 --- a/src/plugins/data/server/saved_objects/index_patterns.ts +++ b/src/plugins/data/server/saved_objects/index_patterns.ts @@ -6,11 +6,12 @@ * Side Public License, v 1. */ -import { SavedObjectsType } from 'kibana/server'; +import type { SavedObjectsType } from 'kibana/server'; import { indexPatternSavedObjectTypeMigrations } from './index_pattern_migrations'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../common'; export const indexPatternSavedObjectType: SavedObjectsType = { - name: 'index-pattern', + name: INDEX_PATTERN_SAVED_OBJECT_TYPE, hidden: false, namespaceType: 'single', management: { diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 5ca19f9e1e50..86aaf64dea85 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -745,6 +745,9 @@ export interface IFieldType { // @public (undocumented) export type IMetricAggType = MetricAggType; +// @public (undocumented) +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = "index-pattern"; + // Warning: (ae-missing-release-tag) "IndexPattern" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1543,20 +1546,20 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "HistogramFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:245:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:245:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:248:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:257:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:258:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:259:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:264:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:268:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:272:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:133:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:133:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:250:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:250:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:252:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:253:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:264:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:268:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:269:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:273:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:276:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:277:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:81:74 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:115:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts index d116fd2e2e9a..7a0bb4584e83 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts @@ -8,6 +8,7 @@ import { SavedObjectReference } from '../../../../../core/types'; import { VisParams } from '../../../common'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../data/public'; const isControlsVis = (visType: string) => visType === 'input_control_vis'; @@ -25,7 +26,7 @@ export const extractControlsReferences = ( control.indexPatternRefName = `${prefix}_${i}_index_pattern`; references.push({ name: control.indexPatternRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: control.indexPattern, }); delete control.indexPattern; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts index 57706ee824e8..98970a0127c7 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts @@ -8,13 +8,11 @@ import { SavedObjectReference } from '../../../../../core/types'; import { VisParams } from '../../../common'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../data/public'; /** @internal **/ const REF_NAME_POSTFIX = '_ref_name'; -/** @internal **/ -const INDEX_PATTERN_REF_TYPE = 'index_pattern'; - /** @internal **/ type Action = (object: Record, key: string) => void; @@ -51,7 +49,7 @@ export const extractTimeSeriesReferences = ( object[key + REF_NAME_POSTFIX] = name; references.push({ name, - type: INDEX_PATTERN_REF_TYPE, + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: object[key].id, }); delete object[key]; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 7debc9412925..869a9add8906 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2163,6 +2163,36 @@ describe('migration visualization', () => { }); }); + describe('7.14.0 replaceIndexPatternReference', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.14.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + test('should replace index_pattern to index-pattern', () => { + expect( + migrate({ + references: [ + { + name: 'name', + type: 'index_pattern', + }, + ], + } as Parameters[0]) + ).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "name": "name", + "type": "index-pattern", + }, + ], + } + `); + }); + }); + describe('7.14.0 update tagcloud defaults', () => { const migrate = (doc: any) => visualizationSavedObjectTypeMigrations['7.14.0']( diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index 7fb54b042593..1f50e26ea9ec 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -8,9 +8,9 @@ import { cloneDeep, get, omit, has, flow, forOwn } from 'lodash'; -import { SavedObjectMigrationFn } from 'kibana/server'; +import type { SavedObjectMigrationFn } from 'kibana/server'; -import { DEFAULT_QUERY_LANGUAGE } from '../../../data/common'; +import { DEFAULT_QUERY_LANGUAGE, INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, @@ -37,7 +37,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; doc.references.push({ name: searchSource.indexRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: searchSource.index, }); delete searchSource.index; @@ -50,7 +50,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { filterRow.meta.indexRefName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; doc.references.push({ name: filterRow.meta.indexRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); delete filterRow.meta.index; @@ -648,7 +648,7 @@ const migrateControls: SavedObjectMigrationFn = (doc) => { control.indexPatternRefName = `control_${i}_index_pattern`; doc.references.push({ name: control.indexPatternRefName, - type: 'index-pattern', + type: INDEX_PATTERN_SAVED_OBJECT_TYPE, id: control.indexPattern, }); delete control.indexPattern; @@ -1038,6 +1038,18 @@ const migrateTagCloud: SavedObjectMigrationFn = (doc) => { return doc; }; +export const replaceIndexPatternReference: SavedObjectMigrationFn = (doc) => ({ + ...doc, + references: Array.isArray(doc.references) + ? doc.references.map((reference) => { + if (reference.type === 'index_pattern') { + reference.type = INDEX_PATTERN_SAVED_OBJECT_TYPE; + } + return reference; + }) + : doc.references, +}); + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1084,5 +1096,10 @@ export const visualizationSavedObjectTypeMigrations = { hideTSVBLastValueIndicator, removeDefaultIndexPatternAndTimeFieldFromTSVBModel ), - '7.14.0': flow(addEmptyValueColorRule, migrateVislibPie, migrateTagCloud), + '7.14.0': flow( + addEmptyValueColorRule, + migrateVislibPie, + migrateTagCloud, + replaceIndexPatternReference + ), }; From dfeecb902fd26d6e41bab81fc4a31c45663f8894 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Mon, 28 Jun 2021 12:31:10 -0400 Subject: [PATCH 16/65] [ML] Data Frame Analytics creation wizard: ensure included fields table updates correctly (#103191) * fix includes table rerender loop * remove unnecessary comment --- .../analysis_fields_table.tsx | 2 - .../configuration_step_form.tsx | 41 ++++++++----------- .../configuration_step/job_type.tsx | 1 - 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index 0b6843d49e95..9dd4c5c42cca 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -84,7 +84,6 @@ const checkboxDisabledCheck = (item: FieldSelectionItem) => export const AnalysisFieldsTable: FC<{ dependentVariable?: string; includes: string[]; - loadingItems: boolean; setFormState: React.Dispatch>; minimumFieldsRequiredMessage?: string; setMinimumFieldsRequiredMessage: React.Dispatch>; @@ -94,7 +93,6 @@ export const AnalysisFieldsTable: FC<{ }> = ({ dependentVariable, includes, - loadingItems, setFormState, minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 930c32ce7e4d..9b68b0385399 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -105,7 +105,6 @@ export const ConfigurationStepForm: FC = ({ const { currentSavedSearch, currentIndexPattern } = mlContext; const { savedSearchQuery, savedSearchQueryStr } = useSavedSearch(); - const [loadingFieldOptions, setLoadingFieldOptions] = useState(false); const [fieldOptionsFetchFail, setFieldOptionsFetchFail] = useState(false); const [loadingDepVarOptions, setLoadingDepVarOptions] = useState(false); const [dependentVariableFetchFail, setDependentVariableFetchFail] = useState(false); @@ -247,21 +246,17 @@ export const ConfigurationStepForm: FC = ({ if (firstUpdate.current) { firstUpdate.current = false; } - // Reset if jobType changes (jobType requires dependent_variable to be set - - // which won't be the case if switching from outlier detection) - if (jobTypeChanged) { - setLoadingFieldOptions(true); - } + + const depVarNotIncluded = + isJobTypeWithDepVar && includes.length > 0 && includes.includes(dependentVariable) === false; // Ensure runtime field is in 'includes' table if it is set as dependent variable const depVarIsRuntimeField = - isJobTypeWithDepVar && + depVarNotIncluded && runtimeMappings && - Object.keys(runtimeMappings).includes(dependentVariable) && - includes.length > 0 && - includes.includes(dependentVariable) === false; + Object.keys(runtimeMappings).includes(dependentVariable); let formToUse = form; - if (depVarIsRuntimeField) { + if (depVarIsRuntimeField || depVarNotIncluded) { formToUse = cloneDeep(form); formToUse.includes = [...includes, dependentVariable]; } @@ -279,24 +274,22 @@ export const ConfigurationStepForm: FC = ({ (field) => field.is_included === true && field.is_required === false ); + const formStateUpdated = { + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemory } : {}), + ...(depVarIsRuntimeField || jobTypeChanged || depVarNotIncluded + ? { includes: formToUse.includes } + : {}), + requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, + }; + if (jobTypeChanged) { - setLoadingFieldOptions(false); setFieldOptionsFetchFail(false); setMaxDistinctValuesError(undefined); setUnsupportedFieldsError(undefined); - setFormState({ - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemory } : {}), - requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, - includes: formToUse.includes, - }); setIncludesTableItems(fieldSelection ? fieldSelection : []); - } else { - setFormState({ - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemory } : {}), - requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, - includes: formToUse.includes, - }); } + + setFormState(formStateUpdated); setFetchingExplainData(false); } else { const { @@ -319,7 +312,6 @@ export const ConfigurationStepForm: FC = ({ : DEFAULT_MODEL_MEMORY_LIMIT.outlier_detection; setEstimatedModelMemoryLimit(fallbackModelMemoryLimit); - setLoadingFieldOptions(false); setFieldOptionsFetchFail(true); setMaxDistinctValuesError(maxDistinctValuesErrorMessage); setUnsupportedFieldsError(unsupportedFieldsErrorMessage); @@ -650,7 +642,6 @@ export const ConfigurationStepForm: FC = ({ tableItems={includesTableItems} unsupportedFieldsError={unsupportedFieldsError} setUnsupportedFieldsError={setUnsupportedFieldsError} - loadingItems={loadingFieldOptions} setFormState={setFormState} /> {showScatterplotMatrix && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx index 443e2cfacbb5..5f54ba3c2bb7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx @@ -82,7 +82,6 @@ export const JobType: FC = ({ type, setFormState }) => { setFormState({ previousJobType: type, jobType, - includes: [], requiredFieldsError: undefined, }); setSelectedCard({ [jobType]: !selectedCard[jobType] }); From bd32299c13036ac28e1a7dd1120dbe4f241a6c15 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Mon, 28 Jun 2021 11:31:23 -0500 Subject: [PATCH 17/65] Upgrade EUI to v34.5.1 (#103297) * eui to 34.5.0 * snapshot updates * Fix some page layouts * eui to 34.5.1 Co-authored-by: cchaos --- package.json | 2 +- .../page_template/solution_nav/solution_nav.tsx | 2 +- .../public/components/home/home.component.tsx | 1 - .../settings/__snapshots__/settings.test.tsx.snap | 14 +++++++------- .../home_integration/tutorial_directory_notice.tsx | 2 +- .../entity_by_expression.test.tsx.snap | 2 +- yarn.lock | 11 ++++++----- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index b589153d2af9..b071b587a362 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", "@elastic/ems-client": "7.14.0", - "@elastic/eui": "34.3.0", + "@elastic/eui": "34.5.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", diff --git a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav.tsx b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav.tsx index bd9ee8eb4d0e..4aa456f716db 100644 --- a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav.tsx +++ b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav.tsx @@ -17,7 +17,7 @@ import { KibanaPageTemplateSolutionNavAvatarProps, } from './solution_nav_avatar'; -export type KibanaPageTemplateSolutionNavProps = Partial> & { +export type KibanaPageTemplateSolutionNavProps = EuiSideNavProps<{}> & { /** * Name of the solution, i.e. "Observability" */ diff --git a/x-pack/plugins/canvas/public/components/home/home.component.tsx b/x-pack/plugins/canvas/public/components/home/home.component.tsx index 96a773186da2..6e98439a0c53 100644 --- a/x-pack/plugins/canvas/public/components/home/home.component.tsx +++ b/x-pack/plugins/canvas/public/components/home/home.component.tsx @@ -31,7 +31,6 @@ export const Home = ({ activeTab = 'workpads' }: Props) => { pageHeader={{ pageTitle: 'Canvas', rightSideItems: [], - bottomBorder: true, tabs: [ { label: strings.getMyWorkpadsTabLabel(), diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap index 27f0d3610fb9..075c0cd38675 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap +++ b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap @@ -31,7 +31,7 @@ exports[` can navigate Autoplay Settings 1`] = ` >
can navigate Autoplay Settings 2`] = ` >
can navigate Autoplay Settings 2`] = `
`; -exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx index 5e4357d95235..23754571c5bc 100644 --- a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx +++ b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx @@ -66,7 +66,6 @@ const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => { return hasIngestManager && !hasSeenNotice ? ( <> - { + ) : null; }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap index d9dd6ec4a0be..8f59fdd7df00 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap @@ -141,7 +141,7 @@ exports[`should render entity by expression with aggregatable field options for value="FlightNum" >
{detailFields.length > 0 ? ( - detailFields.map(({ fieldName, label }, index) => ( -
- -

{label}

-
- -
{result[fieldName]}
-
-
- )) + detailFields.map(({ fieldName, label }, index) => { + const value = result[fieldName] as string; + const dateValue = getAsLocalDateTimeString(value); + + return ( +
+ +

{label}

+
+ +
{dateValue || value}
+
+
+ ); + }) ) : ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index cc890e0f104a..e9b857403291 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -198,12 +198,16 @@ export const Overview: React.FC = () => { {!custom && ( - {status} {activityDetails && } + + {status} {activityDetails && } + )} - {time} + + {time} + ))} @@ -453,7 +457,7 @@ export const Overview: React.FC = () => { - + @@ -465,7 +469,7 @@ export const Overview: React.FC = () => { )} - + {groups.length > 0 && groupsSummary} {details.length > 0 && {detailsSummary}} From bc097856e61be2dc5285bd11eb22704a8c056647 Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Mon, 28 Jun 2021 19:33:35 -0400 Subject: [PATCH 53/65] Unskip the reporting screenshots.ts by fixing unable to update UI settings error. (#103184) --- .../functional/apps/dashboard/reporting/screenshots.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts index 7eb2ef74000e..881b847f1180 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts @@ -29,9 +29,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const ecommerceSOPath = 'x-pack/test/functional/fixtures/kbn_archiver/reporting/ecommerce.json'; - // https://github.com/elastic/kibana/issues/102911 - describe.skip('Dashboard Reporting Screenshots', () => { + describe('Dashboard Reporting Screenshots', () => { before('initialize tests', async () => { + await kibanaServer.uiSettings.replace({ + defaultIndex: '5193f870-d861-11e9-a311-0fa548c5f953', + }); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/reporting/ecommerce'); await kibanaServer.importExport.load(ecommerceSOPath); await browser.setWindowSize(1600, 850); @@ -215,7 +218,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('downloads a PDF file with saved search given EuiDataGrid enabled', async function () { - await kibanaServer.uiSettings.replace({ 'doc_table:legacy': false }); + await kibanaServer.uiSettings.update({ 'doc_table:legacy': false }); this.timeout(300000); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); From 1b5cc2a7bc046f8f9870a6f9f74481df7a7b588b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Mon, 28 Jun 2021 19:43:38 -0400 Subject: [PATCH 54/65] [Security Solution] Disables loadPrebuiltRulesAndTemplatesButton if loading is in progress (#103568) --- .../load_empty_prompt.test.tsx | 22 +++++++++++++++++++ .../pre_packaged_rules/load_empty_prompt.tsx | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx index 9e482b228018..cbdfe5b246af 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx @@ -127,4 +127,26 @@ describe('LoadPrebuiltRulesAndTemplatesButton', () => { ); }); }); + + it('renders disabled button if loading is true', async () => { + (getPrePackagedRulesStatus as jest.Mock).mockResolvedValue({ + rules_not_installed: 0, + rules_installed: 0, + rules_not_updated: 0, + timelines_not_installed: 3, + timelines_installed: 0, + timelines_not_updated: 0, + }); + + const wrapper: ReactWrapper = mount( + + ); + await waitFor(() => { + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="load-prebuilt-rules"] button').props().disabled + ).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx index 9a011da9aff0..56875bcc4f88 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx @@ -64,12 +64,12 @@ const PrePackagedRulesPromptComponent: React.FC = ( const loadPrebuiltRulesAndTemplatesButton = useMemo( () => getLoadPrebuiltRulesAndTemplatesButton({ - isDisabled: !userHasPermissions, + isDisabled: !userHasPermissions || loading, onClick: handlePreBuiltCreation, fill: true, 'data-test-subj': 'load-prebuilt-rules', }), - [getLoadPrebuiltRulesAndTemplatesButton, handlePreBuiltCreation, userHasPermissions] + [getLoadPrebuiltRulesAndTemplatesButton, handlePreBuiltCreation, userHasPermissions, loading] ); return ( From 7442a99168b56a02514519e0ed3e143a60822811 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 28 Jun 2021 16:44:29 -0700 Subject: [PATCH 55/65] [dev_docs] add tutorial for setting up a development env (#103566) Co-authored-by: Jonathan Budzenski Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../setting_up_a_development_env.mdx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 dev_docs/tutorials/setting_up_a_development_env.mdx diff --git a/dev_docs/tutorials/setting_up_a_development_env.mdx b/dev_docs/tutorials/setting_up_a_development_env.mdx new file mode 100644 index 000000000000..449e8b886a44 --- /dev/null +++ b/dev_docs/tutorials/setting_up_a_development_env.mdx @@ -0,0 +1,89 @@ +--- +id: kibDevTutorialSetupDevEnv +slug: /kibana-dev-docs/tutorial/setup-dev-env +title: Setting up a Development Environment +summary: Learn how to setup a development environemnt for contributing to the Kibana repository +date: 2021-04-26 +tags: ['kibana', 'onboarding', 'dev', 'architecture', 'setup'] +--- + +Setting up a development environment is pretty easy. + + + In order to support Windows development we currently require you to use one of the following: + + - [Git Bash](https://git-scm.com/download/win) + - [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) + + + Before running the steps below, please make sure you have installed [Visual C++ Redistributable for Visual Studio 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145) and that you are running all commands in either Git Bash or WSL. + + +## Get the code + +Start by forking [the Kibana repository](https://github.com/elastic/kibana) on Github so that you have a place to stage pull requests and create branches for development. + +Then clone the repository to your machine: + +```sh +git clone https://github.com/[YOUR_USERNAME]/kibana.git kibana +cd kibana +``` + +## Install dependencies + +Install the version of Node.js listed in the `.node-version` file. This can be automated with tools such as [nvm](https://github.com/creationix/nvm) or [nvm-windows](https://github.com/coreybutler/nvm-windows). As we also include a `.nvmrc` file you can switch to the correct version when using nvm by running: + +```sh +nvm use +``` + +Then, install the latest version of yarn using: + +```sh +npm install -g yarn +``` + +Finally, boostrap Kibana and install all of the remaining dependencies: + +```sh +yarn kbn bootstrap +``` + +Node.js native modules could be in use and node-gyp is the tool used to build them. There are tools you need to install per platform and python versions you need to be using. Please follow the [node-gyp installation steps](https://github.com/nodejs/node-gyp#installation) for your platform. + +## Run Elasticsearch + +In order to start Kibana you need to run a local version of Elasticsearch. You can startup and initialize the latest Elasticsearch snapshot of the correct version for Kibana by running the following in a new terminal tab/window: + +```sh +yarn es snapshot +``` + +You can pass `--license trial` to start Elasticsearch with a trial license, or use the Kibana UI to switch the local version to a trial version which includes all features. + +Read about more options for [Running Elasticsearch during development](https://www.elastic.co/guide/en/kibana/current/running-elasticsearch.html), like connecting to a remote host, running from source, preserving data inbetween runs, running remote cluster, etc. + +## Run Kibana + +In another terminal tab/window you can start Kibana. + +```sh +yarn start +``` + +If you include the `--run-examples` flag then all of the [developer examples](https://github.com/elastic/kibana/tree/{branch}/examples). Read more about the advanced options for [Running Kibana](https://www.elastic.co/guide/en/kibana/current/running-kibana-advanced.html). + +## Code away! + +You are now ready to start developing. Changes to the source files should be picked up automatically and either cause the server to restart, or be served to the browser on the next page refresh. + +## Install pre-commit hook (optional) + +In case you want to run a couple of checks like linting or check the file casing of the files to commit, we provide a way to install a pre-commit hook. To configure it you just need to run the following: + +```sh +node scripts/register_git_hook +``` + +After the script completes the pre-commit hook will be created within the file `.git/hooks/pre-commit`. If you choose to not install it, don’t worry, we still run a quick CI check to provide feedback earliest as we can about the same checks. From d7d4a14c8d0d51a6085afd8a30b6e192650d2887 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Mon, 28 Jun 2021 18:11:10 -0600 Subject: [PATCH 56/65] [Security Solutions][Detection Engine] Implements best effort merging of constant_keyword, runtime fields, aliases, and copy_to fields (#102280) ## Summary This adds utilities and two strategies for merging using the [fields API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html) and the `_source` document during signal generation. This gives us the ability to support `constant_keyword`, field alias value support, some runtime fields support, and `copy_to` support. Previously we did not copy any of these values and only generated signals based on the `_source` record values. This changes the behavior to allow us to copy some of the mentioned values above. The folder of `source_fields_merging` contains a `strategy` folder and a `utils` folder which contains both the strategies and the utilities for this implementation. The two strategies are `merge_all_fields_with_source` and `merge_missing_fields_with_source`. The defaulted choice for this PR is we use `merge_missing_fields_with_source` and not the `merge_all_fields_with_source`. The reasoning is that this is much lower risk and lower behavior changes to the signals detection engine. The main driving force behind this PR is that ECS has introduced `constant_keyword` and that field has the possibility of only showing up in the fields section of a document and not `_source` when index authors do not push the `constant_keyword` into the `_source` section. The secondary driving forces behind this behavioral change is that some users have been expecting their runtime fields, `copy_to` fields, and field alias values of their indexes to be copied into the signals index. Both strategies of `merge_missing_fields_with_source` and `merge_all_fields_with_source` are considered Best Effort meaning that both strategies will not always merge as expected when they encounter ambiguous use cases as outlined in the `README.md` text at the top of `source_fields_merging` in detail. The default used strategy of `merge_missing_fields_with_source` which has the simplest behavior will work in most common use cases. This is simply if the `_source` document is missing a value that is present in the `fields`, and the `fields` value is a primitive concrete value such as a `string` or `number` or `boolean` and the `_source` document does not contain an existing object or ambiguous array, then the value will be merged into `_source` and a new reference is returned. If you call the strategy twice it should be idempotent meaning that the second call will detect a value is now present in `_source` and not re-merge a second time. * 301 unit tests were added * Extensive README.md docs are added * e2e tests are updated to test scenarios and ambiguity and conflicts from previously to support this effort. * Other e2e tests were updated * One bug with EQL and fields was found with a workaround implemented. See https://github.com/elastic/elasticsearch/issues/74582 * SearchTypes adjusted to use recursive TypeScript types * Changed deprecated for `@deprecated` in a few spots * Removed some `ts-expect-error` in favor of `??` in a few areas * Added a new handling of epoch strings and tests to `detection_engine/signals/utils.ts` since fields returns `epoch_millis` as a string instead of as a number. * Uses lodash safer set to reduce changes of prototype pollution ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Risk Matrix | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Prototype pollution | Low | High | Used lodash safer set | | Users which have existing rules that work, upgrade and now we do not generate signals due to bad merging of fields and _source | Mid | High | We use the safer strategy method, `merge_missing_fields_with_source `, that is lighter weight to start with. We might add a follow up PR which enables a key in Kibana to turn off merging of fields with source. We added extensive unit tests and e2e tests. However, unexpected unknowns and behaviors from runtime fields and fields API such as geo-points looking like nested fields or `epoch_milliseconds` being a string value or runtime fields allowing invalid values were uncovered and tests and utilities around that have been added which makes this PR risky | | Found a bug with using fields and EQL which caused EQL rules to not run. | Low | High | Implemented workaround for tests to pass and created an Elastic ticket and communicated the bug to EQL developers. | --- .../detection_engine/get_query_filter.test.ts | 30 + .../detection_engine/get_query_filter.ts | 14 + .../common/detection_engine/types.ts | 18 +- .../__mocks__/empty_signal_source_hit.ts | 17 + .../signals/build_bulk_body.test.ts | 24 + .../signals/build_bulk_body.ts | 34 +- .../signals/source_fields_merging/README.md | 389 +++++ .../signals/source_fields_merging/index.ts | 9 + .../source_fields_merging/strategies/index.ts | 8 + .../merge_all_fields_with_source.test.ts | 1478 +++++++++++++++++ .../merge_all_fields_with_source.ts | 113 ++ .../merge_missing_fields_with_source.test.ts | 1379 +++++++++++++++ .../merge_missing_fields_with_source.ts | 88 + .../signals/source_fields_merging/types.ts | 11 + .../utils/array_in_path_exists.test.ts | 42 + .../utils/array_in_path_exists.ts | 23 + .../utils/filter_field_entries.test.ts | 83 + .../utils/filter_field_entries.ts | 32 + .../source_fields_merging/utils/index.ts | 16 + .../utils/is_array_of_primitives.test.ts | 51 + .../utils/is_array_of_primitives.ts | 21 + .../utils/is_invalid_key.test.ts | 51 + .../utils/is_invalid_key.ts | 16 + .../utils/is_multifield.test.ts | 40 + .../utils/is_multifield.ts | 34 + .../utils/is_nested_object.test.ts | 46 + .../utils/is_nested_object.ts | 22 + ...objectlike_or_array_of_objectlikes.test.ts | 71 + .../is_objectlike_or_array_of_objectlikes.ts | 25 + .../utils/is_primitive.test.ts | 42 + .../utils/is_primitive.ts | 16 + .../utils/is_type_object.test.ts | 34 + .../utils/is_type_object.ts | 25 + .../utils/recursive_unboxing_fields.test.ts | 292 ++++ .../utils/recursive_unboxing_fields.ts | 60 + .../lib/detection_engine/signals/types.ts | 27 +- .../detection_engine/signals/utils.test.ts | 39 + .../lib/detection_engine/signals/utils.ts | 9 + .../security_and_spaces/tests/aliases.ts | 7 +- .../security_and_spaces/tests/create_ml.ts | 7 + .../tests/keyword_family/const_keyword.ts | 6 +- .../keyword_mixed_with_const.ts | 6 +- .../security_and_spaces/tests/runtime.ts | 67 +- .../security_solution/alias/data.json | 8 +- .../runtime_conflicting_fields/mappings.json | 5 +- 45 files changed, 4773 insertions(+), 62 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/empty_signal_source_hit.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/README.md create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.ts diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index 63a38ad7d71c..7de082e778a0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -1143,6 +1143,16 @@ describe('get_filter', () => { ], }, }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: '@timestamp', + format: 'epoch_millis', + }, + ], }, }); }); @@ -1180,6 +1190,16 @@ describe('get_filter', () => { ], }, }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: '@timestamp', + format: 'epoch_millis', + }, + ], }, }); }); @@ -1262,6 +1282,16 @@ describe('get_filter', () => { ], }, }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: '@timestamp', + format: 'epoch_millis', + }, + ], }, }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 6a61f892747b..86e66577abd4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -121,6 +121,20 @@ export const buildEqlSearchRequest = ( }, }, event_category_field: eventCategoryOverride, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: '@timestamp', + // BUG: We have to format @timestamp until this bug is fixed with epoch_millis + // https://github.com/elastic/elasticsearch/issues/74582 + // TODO: Remove epoch and use the same techniques from x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts + // where we format both the timestamp and any overrides as ISO8601 + format: 'epoch_millis', + }, + ], }, }; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/types.ts b/x-pack/plugins/security_solution/common/detection_engine/types.ts index c0e502312b2f..e7b8cca8d5a9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/types.ts @@ -11,16 +11,14 @@ export type RuleAlertAction = Omit & { action_type_id: string; }; -export type SearchTypes = - | string - | string[] - | number - | number[] - | boolean - | boolean[] - | object - | object[] - | undefined; +/** + * Defines the search types you can have from Elasticsearch within a + * doc._source. It uses recursive types of "| SearchTypes[]" to designate + * anything can also be of a type array, and it uses the recursive type of + * "| { [property: string]: SearchTypes }" to designate you can can sub-objects + * or sub-sub-objects, etc... + */ +export type SearchTypes = string | number | boolean | object | SearchTypes[] | undefined; export interface Explanation { value: number; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/empty_signal_source_hit.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/empty_signal_source_hit.ts new file mode 100644 index 000000000000..805a401f782f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/empty_signal_source_hit.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SignalSourceHit } from '../types'; + +/** + * Simple empty Elasticsearch result for testing + * @returns Empty Elasticsearch result for testing + */ +export const emptyEsResult = (): SignalSourceHit => ({ + _index: 'index', + _id: '123', +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 4d3ca26f5a71..4053d64539c4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -77,6 +77,9 @@ describe('buildBulkBody', () => { rule: expectedRule(), depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -160,6 +163,9 @@ describe('buildBulkBody', () => { }, depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -222,6 +228,9 @@ describe('buildBulkBody', () => { rule: expectedRule(), depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -282,6 +291,9 @@ describe('buildBulkBody', () => { rule: expectedRule(), depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -335,6 +347,9 @@ describe('buildBulkBody', () => { rule: expectedRule(), depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -388,6 +403,9 @@ describe('buildBulkBody', () => { rule: expectedRule(), depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -441,6 +459,9 @@ describe('buildBulkBody', () => { rule: expectedRule(), depth: 1, }, + source: { + ip: '127.0.0.1', + }, }; expect(fakeSignalSourceHit).toEqual(expected); }); @@ -673,6 +694,9 @@ describe('buildSignalFromEvent', () => { rule: expectedRule(), depth: 2, }, + source: { + ip: '127.0.0.1', + }, }; expect(signal).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 10cc16870044..819e1f3eb6df 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -6,6 +6,7 @@ */ import { SavedObject } from 'src/core/types'; +import { mergeMissingFieldsWithSource } from './source_fields_merging/strategies/merge_missing_fields_with_source'; import { AlertAttributes, SignalSourceHit, @@ -21,18 +22,27 @@ import { buildEventTypeSignal } from './build_event_type_signal'; import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; -// format search_after result for signals index. +/** + * Formats the search_after result for insertion into the signals index. We first create a + * "best effort" merged "fields" with the "_source" object, then build the signal object, + * then the event object, and finally we strip away any additional temporary data that was added + * such as the "threshold_result". + * @param ruleSO The rule saved object to build overrides + * @param doc The SignalSourceHit with "_source", "fields", and additional data such as "threshold_result" + * @returns The body that can be added to a bulk call for inserting the signal. + */ export const buildBulkBody = ( ruleSO: SavedObject, doc: SignalSourceHit ): SignalHit => { - const rule = buildRuleWithOverrides(ruleSO, doc._source!); + const mergedDoc = mergeMissingFieldsWithSource({ doc }); + const rule = buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {}); const signal: Signal = { - ...buildSignal([doc], rule), - ...additionalSignalFields(doc), + ...buildSignal([mergedDoc], rule), + ...additionalSignalFields(mergedDoc), }; - const event = buildEventTypeSignal(doc); - const { threshold_result: thresholdResult, ...filteredSource } = doc._source || { + const event = buildEventTypeSignal(mergedDoc); + const { threshold_result: thresholdResult, ...filteredSource } = mergedDoc._source || { threshold_result: null, }; const signalHit: SignalHit = { @@ -122,18 +132,18 @@ export const buildSignalFromEvent = ( ruleSO: SavedObject, applyOverrides: boolean ): SignalHit => { + const mergedEvent = mergeMissingFieldsWithSource({ doc: event }); const rule = applyOverrides - ? // @ts-expect-error @elastic/elasticsearch _source is optional - buildRuleWithOverrides(ruleSO, event._source) + ? buildRuleWithOverrides(ruleSO, mergedEvent._source ?? {}) : buildRuleWithoutOverrides(ruleSO); const signal: Signal = { - ...buildSignal([event], rule), - ...additionalSignalFields(event), + ...buildSignal([mergedEvent], rule), + ...additionalSignalFields(mergedEvent), }; - const eventFields = buildEventTypeSignal(event); + const eventFields = buildEventTypeSignal(mergedEvent); // TODO: better naming for SignalHit - it's really a new signal to be inserted const signalHit: SignalHit = { - ...event._source, + ...mergedEvent._source, '@timestamp': new Date().toISOString(), event: eventFields, signal, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/README.md new file mode 100644 index 000000000000..eb72fc2b3268 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/README.md @@ -0,0 +1,389 @@ +Set of utilities for merging between `_source` and `fields` are within this folder as well as strategies for merging between these two. + +See `strategies` for the strategies for merging between `_source` and `fields`. See the `utils` folder for the different utilities +which the strategies utilize for help in building their merged documents. + +If we run into problems such as ambiguities, uncertainties, or data type contradictions then we will prefer the value within +"doc.fields" when we can. If "doc.fields" contradicts its self or is too ambiguous, then we assume that +there a problem within "doc.fields" due to a malformed runtime field definition and omit the last seen +contradiction. In some cases we might have to omit the merging of the field altogether and instead utilize +the value from "doc._source" + +Hence, these are labeled as "best effort" since we could run into conditions where we should have taken the value +from "doc.fields" but instead did not and took the value from "doc._source". + +If "doc.fields" does not exist we return "doc._source" untouched as-is. If "doc._source" does not exist but +"doc.fields" does exist then we will do a "best effort" to merge "doc.fields" into a fully functional object as +if it was a "doc._source". But again, if we run into contradictions or ambiguities from the +"doc.fields" we will remove that field or omit one of the contradictions. + +If a "doc.field" is found that does not exist in "doc._source" then we merge that "doc.field" into our +return object. + +If we find that a "field" contradicts the "doc._source" object in which we cannot create a regular +JSON such as a keyword trying to override an object or an object trying to override a keyword: + +``` +"fields": { 'foo': 'value_1', foo.bar': 'value_2' } <-- Foo cannot be both an object and a value +``` +Then you will get an object such as + +``` +{ "foo": "value_1" } +``` + +We cannot merge both together as this is a contradiction and no longer capable of being a JSON object. +This happens when we have multiFields since multiFields are represented in fields as well as when runtime +fields tries to add multiple overrides or invalid multiFields. + +Invalid field names such as ".", "..", ".foo", "foo.", ".foo." will be skipped as those cause errors if +we tried to insert them into Elasticsearch as a new field. + +If we encounter an array within "doc._source" that contains an object with more than 1 value and a "field" +tries to add a new element we will not merge that in as we do not know which array to merge that value into. + +If we encounter a flattened array in the fields object which is not a nested fields such as: +``` +"fields": { "object_name.first" : [ "foo", "bar" ], "object_name.second" : [ "mars", "bar" ] } +``` + +and no "doc._source" with the name "object_name", the assumption is that we these are not related and we construct the object as this: + +``` +{ "object.name": { "first": ["foo", "bar" }, "second": ["mars", "bar"] } +``` + +If we detect a "doc._source with a single flattened array sub objects we will prefer the "fields" flattened +array elements and copy them over as-is, which means we could be subtracting elements, adding elements, or +completely changing the items from the array. + +If we detect an object within the "doc._source" inside of the array, we will not take anything from the +"fields" flattened array elements even if they exist as it is ambiguous where we would put those elements +within the ""doc._source" as an override. + +It is best to feed this both the "doc._source" and "doc.fields" values to get the best chances of merging the document correctly. + +Using these strategies will get you these value types merged that you would otherwise not get directly on your +``` +"doc._source": + - constant_keyword field + - runtime fields + - field aliases + - copy_to +``` + +References: +--- + * https://www.elastic.co/guide/en/elasticsearch/reference/7.13/keyword.html#constant-keyword-field-type + * https://www.elastic.co/guide/en/elasticsearch/reference/7.13/runtime.html + * https://www.elastic.co/guide/en/elasticsearch/reference/7.13/search-fields.html + +Ambiguities and issues +--- +* geo data points/types and nested fields look the same. +* multi-fields such as `host.name` and `host.name.keyword` can lead to misinterpreting valid values vs multi-fields +* All data is an array with at least 1 value we call "boxed", meaning that it is difficult to determine if the user wanted the fields as an array or not. + +Existing bugs and ambiguities +--- +* We currently filter out the geo data points by looking at "type" on the object and filter it out. We could transform it to be valid input at some point. + +Tests +--- +Some tests in this folder use a special table and nomenclature in the comments to show the enumerations and tests for each type. + +Key for the nomenclature is: +``` +undefined means non-existent +p_[] means primitive key and empty array +p_p1 or p_p2 means primitive key and primitive value +p_[p1] or p_[p2] means primitive key and primitive array with a single array value +p[p1, ...1] or p[p2, ...2] means primitive array with 2 or more values +p_{}1 or p_{}2 means a primitive key with a single object +p_[{}1] or p_[{}2] means a primitive key with an array of exactly 1 object +p_[{}1, ...1] or p_[{}2, ...2] means a primitive key with 2 or more array elements +f_[] means a flattened object key and empty array +f_p1 or f_p2 means a flattened object key and a primitive value +f_[p1] or f_[p2] means a flattened object key and a single primitive value in an array +f_[p1, ...1] or f_[p2, ...2] means a flattened object key and 2 or more primitive values in an array +f_{}1 or f_{}2 means a flattened object key with 1 object +f_[{}1] or f_[{}2] means a flattened object key with a single object in a single array +f_[{}1, ...1] or f_[{}2, ...2] means a flattened object key with 2 or more objects in an array +``` + +`_source` documents can contain the following values: +``` +undefined +p_[] +p_p1 +p_[p1] +p_[p1, ...1] +p_{}1 +p_[{}1] +p_[{}1, ...1] +f_[] +f_p1 +f_[p1] +f_[p1, ...1] +f_{}1 +f_[{}1] +f_[{}1, ...1] +``` + +fields arrays can contain the following values: +``` +undefined +f_[] +f_[p2] +f_[p2, ...2] +f_[{}2] +f_[{}2, ...2] +``` + +When fields is undefined or empty array f_[] you never overwrite +the source and source is always the same as before the merge for all the strategies +``` +source | fields | value after merge +----- | --------- | ----- +undefined | undefined | undefined +undefined | f_[] | undefined +p_[] | undefined | p_[] +p_[] | f_[] | p_[] +p_p1 | undefined | p_p1 +p_p1 | f_[] | p_p1 +p_[p1] | undefined | p_[p1] +p_[p1] | f_[] | p_[p1] +p_[p1, ...1] | undefined | p_[p1, ...1] +p_[p1, ...1] | f_[] | p_[p1, ...1] +p_{}1 | undefined | p_{}1 +p_{}1 | f_[] | p_{}1 +p_[{}1] | undefined | p_{}1 +p_[{}1] | f_[] | p_{}1 +p_[{}1, ...1] | undefined | p_[{}1, ...1] +p_[{}1, ...1] | f_[] | p_[{}1, ...1] +f_[] | undefined | f_[] +f_[] | f_[] | f_[] +f_p1 | undefined | f_p1 +f_p1 | f_[] | f_p1 +f_[p1] | undefined | f_[p1] +f_[p1] | f_[] | f_[p1] +f_[p1, ...1] | undefined | f_[p1, ...1] +f_[p1, ...1] | f_[] | f_[p1, ...1] +f_{}1 | undefined | f_{}1 +f_{}1 | f_[] | f_{}1 +f_[{}1] | undefined | f_{}1 +f_[{}1] | f_[] | f_{}1 +f_[{}1, ...1] | undefined | f_[{}1, ...1] +f_[{}1, ...1] | f_[] | f_[{}1, ...1] +``` + +When source key and source value does not exist but field keys and values do exist, then you +you will always get field keys and values replacing the source key and value. Caveat is that +fields will create a single item rather than an array item if field keys and value only has a single +array element. Also, we prefer to create an object structure in source (e.x. p_p2 instead of a flattened object f_p2) +for the `merge_all_fields_with_source` strategy +``` +source | fields | value after merge +----- | --------- | ----- +undefined | f_[p2] | p_p2 <-- Unboxed from array +undefined | f_[p2, ...2] | p_[p2, ...2] +undefined | f_[{}2] | p_{}2 <-- Unboxed from array +undefined | f_[{}2, ...2] | p_[{}2, ...2] +``` + +For the `merge_missing_fields_with_source` it will be that we completely skip the fields that contain nested +fields or type fields such as geo points. + +``` +source | fields | value after merge +----- | --------- | ----- +undefined | f_[p2] | p_p2 <-- Unboxed from array +undefined | f_[p2, ...2] | p_[p2, ...2] +undefined | f_[{}2] | {} <-- We have an empty object since we only merge primitives +undefined | f_[{}2, ...2] | {} <-- We have an empty object since we only merge primitives +``` + +When source key is either a primitive key or a flattened object key with a primitive value (p_p1 or f_p1), +then we overwrite source value with fields value as an unboxed value array if fields value is a +single array element (f_[p2] or f[{}2]), otherwise we overwrite source as an array. + +``` +source | fields | value after merge +----- | --------- | ----- +p_p1 | f_[p2] | p_p2 <-- Unboxed from array +p_p1 | f_[p2, ...2] | p_[p2, ...2] +p_p1 | f_[{}2] | p_{}2 <-- Unboxed from array +p_p1 | f_[{}2, ...2] | p_[{}2, ...2] + +f_p1 | f_[p2] | f_p2 <-- Unboxed from array +f_p1 | f_[p2, ...2] | f_[p2, ...2] +f_p1 | f_[{}2] | f_{}2 <-- Unboxed from array +f_p1 | f_[{}2, ...2] | f_[{}2, ...2] +``` + +For the `merge_missing_fields_with_source` none of these will be merged since the source has values such as + +``` +source | fields | value after merge +----- | --------- | ----- +p_p1 | f_[p2] | p_p1 +p_p1 | f_[p2, ...2] | p_p1 +p_p1 | f_[{}2] | p_p1 +p_p1 | f_[{}2, ...2] | p_p1 + +f_p1 | f_[p2] | f_p1 +f_p1 | f_[p2, ...2] | f_p1 +f_p1 | f_[{}2] | f_p1 +f_p1 | f_[{}2, ...2] | f_p1 +``` + +When source key is a primitive key or a flattened object key and the source value is any +type of array (p_[], p_p[p1], or p_p[p1, ...1]) of primitives then we always copy the +fields value as is and keep the source key as it was originally (primitive or flattened) + +``` +source | fields | value after merge +----- | --------- | ----- +p_[] | f_[p2] | p_[p2] +p_[] | f_[p2, ...2] | p_[p2, ...2] +p_[] | f_[{}2] | p_[{}2] +p_[] | f_[{}2, ...2] | p_[{}2, ...2] + +f_[] | f_[p2] | f_[p2] +f_[] | f_[p2, ...2] | f_[p2, ...2] +f_[] | f_[{}2] | f_[{}2] +f_[] | f_[{}2, ...2] | f_[{}2, ...2] + +p_[p1] | f_[p2] | p_[p2] +p_[p1] | f_[p2, ...2] | p_[p2, ...2] +p_[p1] | f_[{}2] | p_[{}2] +p_[p1] | f_[{}2, ...2] | p_[{}2, ...2] + +f_[p1] | f_[p2] | f_[p2] +f_[p1] | f_[p2, ...2] | f_[p2, ...2] +f_[p1] | f_[{}2] | f_{}2 +f_[p1] | f_[{}2, ...2] | f_[{}2, ...2] + +p_[p1, ...1] | f_[p2] | p_[p2] +p_[p1, ...1] | f_[p2, ...2] | p_[p2, ...2] +p_[p1, ...1] | f_[{}2] | p_[{}2] +p_[p1, ...1] | f_[{}2, ...2] | p_[{}2, ...2] + +f_[p1, ...1] | f_[p2] | f_[p2] +f_[p1, ...1] | f_[p2, ...2] | f_[p2, ...2] +f_[p1, ...1] | f_[{}2] | f_[{}2] +f_[p1, ...1] | f_[{}2, ...2] | f_[{}2, ...2] +``` + +For the `merge_missing_fields_with_source` none of these will be merged since the source has values such as + +``` +source | fields | value after merge +----- | --------- | ----- +p_[] | f_[p2] | p_[] +p_[] | f_[p2, ...2] | p_[] +p_[] | f_[{}2] | p_[] +p_[] | f_[{}2, ...2] | p_[] + +f_[] | f_[p2] | f_[] +f_[] | f_[p2, ...2] | f_[] +f_[] | f_[{}2] | f_[] +f_[] | f_[{}2, ...2] | f_[] + +p_[p1] | f_[p2] | p_[p1] +p_[p1] | f_[p2, ...2] | p_[p1] +p_[p1] | f_[{}2] | p_[p1] +p_[p1] | f_[{}2, ...2] | p_[p1] + +f_[p1] | f_[p2] | f_[p1] +f_[p1] | f_[p2, ...2] | f_[p1] +f_[p1] | f_[{}2] | f_[p1] +f_[p1] | f_[{}2, ...2] | f_[p1] + +p_[p1, ...1] | f_[p2] | p_[p1, ...1] +p_[p1, ...1] | f_[p2, ...2] | p_[p1, ...1] +p_[p1, ...1] | f_[{}2] | p_[p1, ...1] +p_[p1, ...1] | f_[{}2, ...2] | p_[p1, ...1] + +f_[p1, ...1] | f_[p2] | f_[p1, ...1] +f_[p1, ...1] | f_[p2, ...2] | f_[p1, ...1] +f_[p1, ...1] | f_[{}2] | f_[p1, ...1] +f_[p1, ...1] | f_[{}2, ...2] | f_[p1, ...1] +``` + +When source key is a primitive key or flattened key and the source value is an object (p_{}1, f_{}1) or +an array containing objects ([p_{1}], f_{}1, p_[{}1, ...1], f_[{}1, ...1]), we only copy the +field value if we detect that field value is also an object meaning that it is a nested field, +(f_[{}]2 or f[{}2, ...2]). We never allow a field to convert an object back into a value. +We never try to merge field values into the array either since they're flattened in the fields and we +will have too many ambiguities and issues between the flattened array values and the source objects. + +``` +source | fields | value after merge +----- | --------- | ----- +p_{}1 | f_[p2] | p_{}1 +p_{}1 | f_[p2, ...2] | p_{}1 +p_{}1 | f_[{}2] | p_{}2 <-- Copied and unboxed array since we detected a nested field +p_{}1 | f_[{}2, ...2] | p_[{}2, ...2] <-- Copied since we detected a nested field + +f_{}1 | f_[p2] | f_{}1 +f_{}1 | f_[p2, ...2] | f_{}1 +f_{}1 | f_[{}2] | f_{}2 <-- Copied and unboxed array since we detected a nested field +f_{}1 | f_[{}2, ...2] | f_[{}2, ...2] <-- Copied since we detected a nested field + +p_[{}1] | f_[p2] | p_[{}1] +p_[{}1] | f_[p2, ...2] | p_[{}1] +p_[{}1] | f_[{}2] | p_[{}2] <-- Copied since we detected a nested field +p_[{}1] | f_[{}2, ...2] | p_[{}2, ...2] <-- Copied since we detected a nested field + +f_[{}1] | f_[p2] | f_[{}1] +f_[{}1] | f_[p2, ...2] | f_[{}1] +f_[{}1] | f_[{}2] | f_[{}2] <-- Copied since we detected a nested field +f_[{}1] | f_[{}2, ...2] | f_[{}2, ...2] <-- Copied since we detected a nested field + +p_[{}1, ...1] | f_[p2] | p_[{}1, ...1] +p_[{}1, ...1] | f_[p2, ...2] | p_[{}1, ...1] +p_[{}1, ...1] | f_[{}2] | p_[{}2] <-- Copied since we detected a nested field +p_[{}1, ...1] | f_[{}2, ...2] | p_[{}2, ...2] <-- Copied since we detected a nested field + +f_[{}1, ...1] | f_[p2] | f_[{}1, ...1] +f_[{}1, ...1] | f_[p2, ...2] | f_[{}1, ...1] +f_[{}1, ...1] | f_[{}2] | f_[{}2] <-- Copied since we detected a nested field +f_[{}1, ...1] | f_[{}2, ...2] | f_[{}2, ...2] <-- Copied since we detected a nested field +``` + +For the `merge_missing_fields_with_source` none of these will be merged since the source has values such as + +``` +source | fields | value after merge +----- | --------- | ----- +p_{}1 | f_[p2] | p_{}1 +p_{}1 | f_[p2, ...2] | p_{}1 +p_{}1 | f_[{}2] | p_{}1 +p_{}1 | f_[{}2, ...2] | p_{}1 + +f_{}1 | f_[p2] | f_{}1 +f_{}1 | f_[p2, ...2] | f_{}1 +f_{}1 | f_[{}2] | f_{}1 +f_{}1 | f_[{}2, ...2] | f_{}1 + +p_[{}1] | f_[p2] | p_[{}1] +p_[{}1] | f_[p2, ...2] | p_[{}1] +p_[{}1] | f_[{}2] | p_[{}1] +p_[{}1] | f_[{}2, ...2] | p_[{}1] + +f_[{}1] | f_[p2] | f_[{}1] +f_[{}1] | f_[p2, ...2] | f_[{}1] +f_[{}1] | f_[{}2] | f_[{}1] +f_[{}1] | f_[{}2, ...2] | f_[{}1] + +p_[{}1, ...1] | f_[p2] | p_[{}1, ...1] +p_[{}1, ...1] | f_[p2, ...2] | p_[{}1, ...1] +p_[{}1, ...1] | f_[{}2] | p_[{}1, ...1] +p_[{}1, ...1] | f_[{}2, ...2] | p_[{}1, ...1] + +f_[{}1, ...1] | f_[p2] | f_[{}1, ...1] +f_[{}1, ...1] | f_[p2, ...2] | f_[{}1, ...1] +f_[{}1, ...1] | f_[{}2] | f_[{}1, ...1] +f_[{}1, ...1] | f_[{}2, ...2] | f_[{}1, ...1] +``` diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/index.ts new file mode 100644 index 000000000000..ff07c898a3a2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export * from './types'; +export * from './strategies'; +export * from './utils'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts new file mode 100644 index 000000000000..212eba9c6c3b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export * from './merge_all_fields_with_source'; +export * from './merge_missing_fields_with_source'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts new file mode 100644 index 000000000000..b900ea268fd6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.test.ts @@ -0,0 +1,1478 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mergeAllFieldsWithSource } from './merge_all_fields_with_source'; +import { SignalSourceHit } from '../../types'; +import { emptyEsResult } from '../../__mocks__/empty_signal_source_hit'; + +/** + * See ../README.md for the nomenclature of any notes within tests below + */ +describe('merge_all_fields_with_source', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + /** Get the return type of the mergeAllFieldsWithSource for TypeScript checks against expected */ + type ReturnTypeMergeFieldsWithSource = ReturnType['_source']; + + describe('fields is "undefined"', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | undefined | undefined + * p_[] | undefined | p_[] + * p_p1 | undefined | p_p1 + * p_[p1] | undefined | p_[p1] + * p_[p1, ...1] | undefined | p_[p1, ...1] + * p_{}1 | undefined | p_{}1 + * p_[{}1] | undefined | p_{}1 + * p_[{}1, ...1] | undefined | p_[{}1, ...1] + */ + describe('primitive keys in the _source document', () => { + /** fields is "undefined" for all tests below */ + const fields: SignalSourceHit['fields'] = {}; + + test('when source is "undefined", merged doc is "undefined"', () => { + const _source: SignalSourceHit['_source'] = {}; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an empty array (p_[]), merged doc is empty array (p_[])"', () => { + const _source: SignalSourceHit['_source'] = { + foo: [], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (p_p1), merged doc is primitive (p_p1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: 'value', + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (p_[p1]), merged doc is primitive (p_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: ['value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (p_[p1, ..1]), merged doc is primitive (p_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (p_{}), merged doc is single object (p_{})', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'some value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object (p_[{}1]), merged doc is single object (p_[{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (p_[{}, ...1]), merged doc is the same (p_[{}, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }, { foo: 'some other value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | undefined | undefined + * f_[] | undefined | f_[] + * f_p1 | undefined | f_p1 + * f_[p1] | undefined | f_[p1] + * f_[p1, ...1] | undefined | f_[p1, ...1] + * f_{}1 | undefined | f_{}1 + * f_[{}1] | undefined | f_{}1 + * f_[{}1, ...1] | undefined | f_[{}1, ...1] + */ + describe('flattened object keys in the _source document', () => { + /** fields is "undefined" for all tests below */ + const fields: SignalSourceHit['fields'] = {}; + + test('when source is an empty array (f_[]), merged doc is empty array (f_[])"', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (f_p1), merged doc is primitive (f_p1)', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': 'value', + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (f_[p1]), merged doc is primitive (f_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (f_[p1, ...1]), merged doc is primitive (f_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (f_{}1), merged doc is single object (f_{}1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'some value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object ([f_{}1]), merged doc is single object ([f_{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (f_[{}1, ...1]), merged doc is the same (f_[{}1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }, { foo: 'some other value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + describe('fields is "[]"', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | f_[] | undefined + * p_[] | f_[] | p_[] + * p_p1 | f_[] | p_p1 + * p_[p1] | f_[] | p_[p1] + * p_[p1, ...1] | f_[] | p_[p1, ...1] + * p_{}1 | f_[] | p_{}1 + * p_[{}1] | f_[] | p_{}1 + * p_[{}1, ...1] | f_[] | p_[{}1, ...1] + */ + describe('primitive keys in the _source document', () => { + /** fields is a flattened object key and an empty array value (p_[]) */ + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [], + }; + + test('when source is an empty array (p_[]), merged doc is empty array (p_[])"', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: [] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (p_p1), merged doc is primitive (p_p1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (p_[p1]), merged doc is primitive (p_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: ['value'] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (p_[p1, ..1]), merged doc is primitive (p_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: ['value_1', 'value_2'] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (p_{}), merged doc is single object (p_{})', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: { mars: 'some value' } }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object (p_[{}1]), merged doc is single object (p_[{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: 'some value' }] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (p_[{}, ...1]), merged doc is the same (p_[{}, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: 'some value' }, { mars: 'some other value' }] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | f_[] | undefined + * f_[] | f_[] | f_[] + * f_p1 | f_[] | f_p1 + * f_[p1] | f_[] | f_[p1] + * f_[p1, ...1] | f_[] | f_[p1, ...1] + * f_{}1 | f_[] | f_{}1 + * f_[{}1] | f_[] | f_{}1 + * f_[{}1, ...1] | f_[] | f_[{}1, ...1] + */ + describe('flattened object keys in the _source document', () => { + /** fields is flattened object key with an empty array for a value (f_[]) */ + const fields: SignalSourceHit['fields'] = { + 'bar.foo': [], + }; + + test('when source is an empty array (f_[]), merged doc is empty array (f_[])"', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': [], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (f_p1), merged doc is primitive (f_p1)', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': 'value', + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (f_[p1]), merged doc is primitive (f_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': ['value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (f_[p1, ...1]), merged doc is primitive (f_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (f_{}1), merged doc is single object (f_{}1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'some value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object ([f_{}1]), merged doc is single object ([f_{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (f_[{}1, ...1]), merged doc is the same (f_[{}1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }, { foo: 'some other value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | f_[p2] | p_p2 <-- Unboxed from array + * undefined | f_[p2, ...2] | p_[p2, ...2] + * undefined | f_[{}2] | p_{}2 <-- Unboxed from array + * undefined | f_[{}2, ...2] | p_[{}2, ...2] + */ + describe('source is "undefined"', () => { + /** _source is "undefined" for all tests below */ + const _source: SignalSourceHit['_source'] = {}; + + test('fields is a single primitive value (f_[p2]), merged doc is an unboxed array element p_p2"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: 'other_value_1', + }, + }); + }); + + test('fields is a multiple primitive values (f_[p2, ...2]), merged doc is the array (f_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: ['other_value_1', 'other_value_2'], + }, + }); + }); + + test('fields is a single nested field value (f_[{}2]), merged doc is the unboxed array element (p_{}2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: { zed: 'other_value_1' } }, + }); + }); + + test('fields is multiple nested field values (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] }, + }); + }); + }); + + describe('source is either primitive or flattened keys, with primitive values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_p1 | f_[p2] | p_p2 <-- Unboxed from array + * p_p1 | f_[p2, ...2] | p_[p2, ...2] + * p_p1 | f_[{}2] | p_{}2 <-- Unboxed from array + * p_p1 | f_[{}2, ...2] | p_[{}2, ...2] + */ + describe('primitive keys in the _source document with the value of "value" (p_p1)', () => { + /** _source is a single primitive key with a primitive value for all tests below (p_p1) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'value' }, + }; + + test('fields is single array primitive value (f_[p2]), merged doc is unboxed primitive key and value (p_p2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: 'other_value_1', + }, + }); + }); + + test('fields has single primitive values (f_[p2, ...2]), merged doc is the array (p_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: ['other_value_1', 'other_value_2'] }, + }); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed array (p_{}2)"', () => { + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: 'other_value_1', + }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: 'other_value_1' }, { bar: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: [{ bar: 'other_value_1' }, { bar: 'other_value_2' }], + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_p1 | f_[p2] | f_p2 <-- Unboxed from array + * f_p1 | f_[p2, ...2] | f_[p2, ...2] + * f_p1 | f_[{}2] | f_{}2 <-- Unboxed from array + * f_p1 | f_[{}2, ...2] | f_[{}2, ...2] + */ + describe('flattened object keys in the _source document (f_p1)', () => { + /** _source is a flattened object key with a primitive value for all tests below (f_p1) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': 'value', + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is unboxed primitive key and value (f_p2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + 'foo.bar': 'other_value_1', + }); + }); + + test('fields has single primitive value (f_[p2, ...2]), merged doc is the array (f_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed array (f_{}2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + 'foo.bar': { zed: 'other_value_1' }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + }); + }); + + describe('source is either primitive or flattened keys, with primitive array values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[] | f_[p2] | p_[p2] + * p_[] | f_[p2, ...2] | p_[p2, ...2] + * p_[] | f_[{}2] | p_[{}2] + * p_[] | f_[{}2, ...2] | p_[{}2, ...2] + */ + describe('primitive keys in the _source document with empty array (p_[])', () => { + /** _source is a primitive key with an empty array for all tests below (p_[]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: [] }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is array value (p_[p2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: ['other_value_1'] }, + }); + }); + + test('fields has single primitive values (f_[p2, ...2]), merged doc is the array (p_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: ['other_value_1', 'other_value_2'] }, + }); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array value (p_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }] }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] }, + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[] | f_[p2] | f_[p2] + * f_[] | f_[p2, ...2] | f_[p2, ...2] + * f_[] | f_[{}2] | f_[{}2] + * f_[] | f_[{}2, ...2] | f_[{}2, ...2] + */ + describe('flattened object keys in the _source document with empty array (f_[])', () => { + /** _source is a flattened object key with an empty array for all tests below (f_[]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is array (f_[p2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple primitive values (f_[p2, ...2]), merged doc is the array (f_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (f_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[p1] | f_[p2] | p_[p2] + * p_[p1] | f_[p2, ...2] | p_[p2, ...2] + * p_[p1] | f_[{}2] | p_[{}2] + * p_[p1] | f_[{}2, ...2] | p_[{}2, ...2] + */ + describe('primitive keys in the _source document with single primitive value in an array (p_[p1])', () => { + /** _source is a primitive key with a single primitive array value for all tests below (p_[p1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: ['value'] }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the array value (p_[p2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: ['other_value_1'] }, + }); + }); + + test('fields has single primitive value (f_[p2, ...2]), merged doc is the array (p_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: ['other_value_1', 'other_value_2'] }, + }); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (p_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }] }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] }, + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[p1] | f_[p2] | f_[p2] + * f_[p1] | f_[p2, ...2] | f_[p2, ...2] + * f_[p1] | f_[{}2] | f_[{}2] + * f_[p1] | f_[{}2, ...2] | f_[{}2, ...2] + */ + describe('flattened keys in the _source document with single flattened value in an array (f_[p1])', () => { + /** _source is a flattened object key with a single primitive value for all tests below (f_p[p1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value'], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is array value (f_[p2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has single primitive value (f_[p2, ...2]), merged doc is the array (f_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (f_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[p1, ...1] | f_[p2] | p_[p2] + * p_[p1, ...1] | f_[p2, ...2] | p_[p2, ...2] + * p_[p1, ...1] | f_[{}2] | p_[{}2] + * p_[p1, ...1] | f_[{}2, ...2] | p_[{}2, ...2] + */ + describe('primitive keys in the _source document with multiple array values in an array (p_[p1, ...1])', () => { + /** _source is a primitive key with an array of 2 or more elements for all tests below (p_[p1, ...1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { + bar: ['value_1', 'value_2'], + }, + }; + + test('fields is single array value (f_[p2]), merged doc is array (p_[p2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: ['other_value_1'], + }, + }); + }); + + test('fields is multiple primitive values (f_[p2, ...2]), merged doc is the array (p_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: ['other_value_1', 'other_value_2'], + }, + }); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed value (p_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: [{ zed: 'other_value_1' }], + }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }, + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[p1, ...1] | f_[p2] | f_[p2] + * f_[p1, ...1] | f_[p2, ...2] | f_[p2, ...2] + * f_[p1, ...1] | f_[{}2] | f_[{}2] + * f_[p1, ...1] | f_[{}2, ...2] | f_[{}2, ...2] + */ + describe('flattened keys in the _source document with multiple array values in an array (f_[p1, ...1])', () => { + /** _source is a flattened object key with an array of 2 or more elements for all tests below (f_[p1, ...1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value_1', 'value_2'], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is unboxed primitive key and value (f_p2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple primitive values (f_[p2, ...2]), merged doc is the array (f_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (f_{}2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + }); + }); + + describe('source is either primitive or flattened keys, with object values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_{}1 | f_[p2] | p_{}1 + * p_{}1 | f_[p2, ...2] | p_{}1 + * p_{}1 | f_[{}2] | p_{}2 <-- Copied and unboxed array since we detected a nested field + * p_{}1 | f_[{}2, ...2] | p_[{}2, ...2] <-- Copied since we detected a nested field + */ + describe('primitive keys in the _source document with the value of "value" (p_{}1)', () => { + /** _source is a primitive key with an object value for all tests below (p_{}1) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: { mars: 'value_1' } }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same source (p_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive values (f_[p2, ...2]), merged doc is the same _source (p_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed array value (p_{}2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: { zed: 'other_value_1' } }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] }, + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_{}1 | f_[p2] | f_{}1 + * f_{}1 | f_[p2, ...2] | f_{}1 + * f_{}1 | f_[{}2] | f_{}2 <-- Copied and unboxed array since we detected a nested field + * f_{}1 | f_[{}2, ...2] | f_[{}2, ...2] <-- Copied since we detected a nested field + */ + describe('flattened object keys in the _source document with the value of "value" (f_{}1)', () => { + /** _source is a flattened object key with an object value for all tests below (f_{}1) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': { mars: 'value_1' }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same source (f_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has primitive values (f_[p2, ...2]), merged doc is the same _source (f_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is unboxed array value (f_{}2)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + 'foo.bar': { mars: 'other_value_1' }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }); + }); + }); + }); + + describe('source is either primitive or flattened keys, with object array values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[{}1] | f_[p2] | p_[{}1] + * p_[{}1] | f_[p2, ...2] | p_[{}1] + * p_[{}1] | f_[{}2] | p_[{}2] <-- Copied since we detected a nested field + * p_[{}1] | f_[{}2, ...2] | p_[{}2, ...2] <-- Copied since we detected a nested field + */ + describe('primitive keys in the _source document with a single array value with an object (p_[{}1])', () => { + /** _source is a primitive key with a single array value with an object for all tests below (p_[{}1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: ['value_1'] }] }, + }; + + test('fields has a single primitive value (f_[p2]), merged doc is the same _source (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has 2 or more primitive values (f_[p2, ...2]), merged doc is the same _source (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array value (p_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }] }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] }, + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[{}1, ...1] | f_[p2] | p_[{}1, ...1] + * p_[{}1, ...1] | f_[p2, ...2] | p_[{}1, ...1] + * p_[{}1, ...1] | f_[{}2] | p_[{}2] <-- Copied since we detected a nested field + * p_[{}1, ...1] | f_[{}2, ...2] | p_[{}2, ...2] <-- Copied since we detected a nested field + */ + describe('primitive keys in the _source document with multiple array objects (p_[{}1, ...1])', () => { + /** _source is a primitive key with a 2 or more array values with an object for all tests below (p_[{}1, ...1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: ['value_1'] }, { mars: ['value_1'] }] }, + }; + + test('fields has a single primitive value (f_[p2]), merged doc is the same _source (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has 2 or more primitive values (f_[p2, ...2]), merged doc is the same _source (p_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array value (p_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }] }, + }); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: [{ zed: 'other_value_1' }, { zed: 'other_value_2' }] }, + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[{}1] | f_[p2] | f_[{}1] + * f_[{}1] | f_[p2, ...2] | f_[{}1] + * f_[{}1] | f_[{}2] | f_[{}2] <-- Copied since we detected a nested field + * f_[{}1] | f_[{}2, ...2] | f_[{}2, ...2] <-- Copied since we detected a nested field + */ + describe('flattened object keys in the _source document with the single value of "value" (f_[{}1])', () => { + /** _source is a flattened object key with a single array object for all tests below (f_[{}1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [{ mars: 'value_1' }], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same _source (f_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has primitive values (f_[p2, ...2]), merged doc is the same _source (f_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is array value (f_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[{}1, ...1] | f_[p2] | f_[{}1, ...1] + * f_[{}1, ...1] | f_[p2, ...2] | f_[{}1, ...1] + * f_[{}1, ...1] | f_[{}2] | f_[{}2] <-- Copied since we detected a nested field + * f_[{}1, ...1] | f_[{}2, ...2] | f_[{}2, ...2] <-- Copied since we detected a nested field + */ + describe('flattened object keys in the _source document with multiple values of "value" (f_[{}1, ...1])', () => { + /** _source is a flattened object key with 2 or more array objects for all tests below (f_[{}1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [{ mars: 'value_1' }, { mars: 'value_2' }], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same _source (f_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has primitive values (f_[p2, ...2]), merged doc is the same _source (f_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is array value (f_[{}2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(fields); + }); + }); + }); + + /** + * It is possible to have a mixture of flattened keys and primitive keys within a _source document. + * These tests cover those cases and these test cases should be considered hopefully rare occurrences. + * If these become more common place, update the top table with all the permutations and combinations + * of tests for these. For now, expect the flattened object keys to get the values added to them vs. + * the other value. These tests show the behaviors of this but also the existing bugs of what happens + * when we merge. + */ + describe('miscellaneous tests of mixed flattened and source objects within _source', () => { + /** _source has a primitive key mixed with an object with the same path information which causes ambiguity */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'value_1' }, + 'foo.bar': 'value_2', + }; + + test('fields has a single primitive value f_[p2] which is to override one of the values above"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: 'value_1' }, + 'foo.bar': 'other_value_1', + }); + }); + + /** + * This is an ambiguous situation in which we produce incorrect results. + */ + test('fields has the same list of values as that of the original document and we actually do not understand if this is a new value or not"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: 'value_1' }, // <--- We have duplicated value_1 twice which is a bug + 'foo.bar': ['value_1', 'value_2'], // <-- We have merged the array value because we do not understand if we should or not + }); + }); + }); + + /** + * These tests show the behaviors around overriding fields with other fields such as objects overriding + * values and values overriding objects. This occurs with multi fields where you can have "foo" and "foo.keyword" + * in the fields + */ + describe('Fields overriding fields', () => { + describe('primitive keys for the _source', () => { + test('removes multi-field values such "foo.keyword" mixed with "foo" and prefers just "foo" for 1st level', () => { + const _source: SignalSourceHit['_source'] = { + foo: 'foo_value_1', + bar: 'bar_value_1', + }; + const fields: SignalSourceHit['fields'] = { + foo: ['foo_other_value_1'], + 'foo.keyword': ['foo_other_value_keyword_1'], + bar: ['bar_other_value_1'], + 'bar.keyword': ['bar_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: 'foo_other_value_1', + bar: 'bar_other_value_1', + }); + }); + + test('removes multi-field values such "host.name.keyword" mixed with "host.name" and prefers just "host.name" for 2nd level', () => { + const _source: SignalSourceHit['_source'] = { + host: { + name: 'host_value_1', + hostname: 'host_name_value_1', + }, + }; + const fields: SignalSourceHit['fields'] = { + 'host.name': ['host_name_other_value_1'], + 'host.name.keyword': ['host_name_other_value_keyword_1'], + 'host.hostname': ['hostname_other_value_1'], + 'host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + host: { + hostname: 'hostname_other_value_1', + name: 'host_name_other_value_1', + }, + }); + }); + + test('removes multi-field values such "foo.host.name.keyword" mixed with "foo.host.name" and prefers just "foo.host.name" for 3rd level', () => { + const _source: SignalSourceHit['_source'] = { + foo: { + host: { + name: 'host_value_1', + hostname: 'host_name_value_1', + }, + }, + }; + const fields: SignalSourceHit['fields'] = { + 'foo.host.name': ['host_name_other_value_1'], + 'foo.host.name.keyword': ['host_name_other_value_keyword_1'], + 'foo.host.hostname': ['hostname_other_value_1'], + 'foo.host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + host: { + hostname: 'hostname_other_value_1', + name: 'host_name_other_value_1', + }, + }, + }); + }); + + test('multi-field values mixed with regular values will not be merged accidentally"', () => { + const _source: SignalSourceHit['_source'] = {}; + const fields: SignalSourceHit['fields'] = { + foo: ['other_value_1'], + 'foo.bar': ['other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: 'other_value_1', + }); + }); + }); + + describe('flattened keys for the _source', () => { + test('removes multi-field values such "host.name.keyword" mixed with "host.name" and prefers just "host.name" for 2nd level', () => { + const _source: SignalSourceHit['_source'] = { + 'host.name': 'host_value_1', + 'host.hostname': 'host_name_value_1', + }; + const fields: SignalSourceHit['fields'] = { + 'host.name': ['host_name_other_value_1'], + 'host.name.keyword': ['host_name_other_value_keyword_1'], + 'host.hostname': ['hostname_other_value_1'], + 'host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + 'host.name': 'host_name_other_value_1', + 'host.hostname': 'hostname_other_value_1', + }); + }); + + test('removes multi-field values such "foo.host.name.keyword" mixed with "foo.host.name" and prefers just "foo.host.name" for 3rd level', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.host.name': 'host_value_1', + 'foo.host.hostname': 'host_name_value_1', + }; + const fields: SignalSourceHit['fields'] = { + 'foo.host.name': ['host_name_other_value_1'], + 'foo.host.name.keyword': ['host_name_other_value_keyword_1'], + 'foo.host.hostname': ['hostname_other_value_1'], + 'foo.host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + 'foo.host.name': 'host_name_other_value_1', + 'foo.host.hostname': 'hostname_other_value_1', + }); + }); + + test('invalid fields of several levels mixed with regular values will not be merged accidentally due to runtime fields being liberal"', () => { + const _source: SignalSourceHit['_source'] = {}; + const fields: SignalSourceHit['fields'] = { + foo: ['other_value_1'], + 'foo.bar': ['other_value_2'], + 'foo.bar.zed': ['zed_other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: 'other_value_1', + }); + }); + }); + }); + + /** + * These tests are around parent objects that are not nested but are array types. We do not try to merge + * into these as this causes ambiguities between array types and object types. + */ + describe('parent array objects', () => { + test('parent array objects will not be overridden since that is an ambiguous use case for a top level value', () => { + const _source: SignalSourceHit['_source'] = { + foo: [ + { + bar: 'value_1', + mars: ['value_1'], + }, + ], + }; + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + 'foo.mars': ['other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('parent array objects will not be overridden since that is an ambiguous use case for a deeply nested value', () => { + const _source: SignalSourceHit['_source'] = { + foo: { + zed: [ + { + bar: 'value_1', + mars: ['value_1'], + }, + ], + }, + }; + const fields: SignalSourceHit['fields'] = { + 'foo.zed.bar': ['other_value_1'], + 'foo.zed.mars': ['other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * Specific tests around nested field types such as ensuring we are unboxing when we can + */ + describe('nested fields', () => { + test('unboxes deeply nested fields from a single array items when source is non-existent', () => { + const _source: SignalSourceHit['_source'] = {}; + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: ['single_value'], zed: ['single_value'] }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { bar: 'single_value', zed: 'single_value' }, + }); + }); + + test('does not unbox when source is exists and has arrays for the same values with primitive keys', () => { + const _source: SignalSourceHit['_source'] = { + foo: [ + { + bar: [], + zed: [], + }, + ], + }; + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: ['single_value'], zed: ['single_value'] }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeAllFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: [{ bar: ['single_value'], zed: ['single_value'] }], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts new file mode 100644 index 000000000000..de8d3ba820e2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash/fp'; +import { set } from '@elastic/safer-lodash-set/fp'; +import { SignalSource, SignalSourceHit } from '../../types'; +import { filterFieldEntries } from '../utils/filter_field_entries'; +import type { FieldsType } from '../types'; +import { isObjectLikeOrArrayOfObjectLikes } from '../utils/is_objectlike_or_array_of_objectlikes'; +import { isNestedObject } from '../utils/is_nested_object'; +import { recursiveUnboxingFields } from '../utils/recursive_unboxing_fields'; +import { isPrimitive } from '../utils/is_primitive'; +import { isArrayOfPrimitives } from '../utils/is_array_of_primitives'; +import { arrayInPathExists } from '../utils/array_in_path_exists'; +import { isTypeObject } from '../utils/is_type_object'; + +/** + * Merges all of "doc._source" with its "doc.fields" on a "best effort" basis. See ../README.md for more information + * on this function and the general strategies. + * + * @param doc The document with "_source" and "fields" + * @param throwOnFailSafe Defaults to false, but if set to true it will cause a throw if the fail safe is triggered to indicate we need to add a new explicit test condition + * @returns The two merged together in one object where we can + */ +export const mergeAllFieldsWithSource = ({ doc }: { doc: SignalSourceHit }): SignalSourceHit => { + const source = doc._source ?? {}; + const fields = doc.fields ?? {}; + const fieldEntries = Object.entries(fields); + const filteredEntries = filterFieldEntries(fieldEntries); + + const transformedSource = filteredEntries.reduce( + (merged, [fieldsKey, fieldsValue]: [string, FieldsType]) => { + if ( + hasEarlyReturnConditions({ + fieldsValue, + fieldsKey, + merged, + }) + ) { + return merged; + } + + const valueInMergedDocument = get(fieldsKey, merged); + if (valueInMergedDocument === undefined) { + const valueToMerge = recursiveUnboxingFields(fieldsValue, valueInMergedDocument); + return set(fieldsKey, valueToMerge, merged); + } else if (isPrimitive(valueInMergedDocument)) { + const valueToMerge = recursiveUnboxingFields(fieldsValue, valueInMergedDocument); + return set(fieldsKey, valueToMerge, merged); + } else if (isArrayOfPrimitives(valueInMergedDocument)) { + const valueToMerge = recursiveUnboxingFields(fieldsValue, valueInMergedDocument); + return set(fieldsKey, valueToMerge, merged); + } else if ( + isObjectLikeOrArrayOfObjectLikes(valueInMergedDocument) && + isNestedObject(fieldsValue) && + !Array.isArray(valueInMergedDocument) + ) { + const valueToMerge = recursiveUnboxingFields(fieldsValue, valueInMergedDocument); + return set(fieldsKey, valueToMerge, merged); + } else if ( + isObjectLikeOrArrayOfObjectLikes(valueInMergedDocument) && + isNestedObject(fieldsValue) && + Array.isArray(valueInMergedDocument) + ) { + const valueToMerge = recursiveUnboxingFields(fieldsValue, valueInMergedDocument); + return set(fieldsKey, valueToMerge, merged); + } else { + // fail safe catch all condition for production, but we shouldn't try to reach here and + // instead write tests if we encounter this situation. + return merged; + } + }, + { ...source } + ); + + return { + ...doc, + _source: transformedSource, + }; +}; + +/** + * Returns true if any early return conditions are met which are + * - If the fieldsValue is an empty array return + * - If we have an array within the path return and the value in our merged documented is non-existent + * - If the value is an object or is an array of object types and we don't have a nested field + * @param fieldsValue The field value to check + * @param fieldsKey The key of the field we are checking + * @param merged The merge document which is what we are testing conditions against + * @returns true if we should return early, otherwise false + */ +const hasEarlyReturnConditions = ({ + fieldsValue, + fieldsKey, + merged, +}: { + fieldsValue: FieldsType; + fieldsKey: string; + merged: SignalSource; +}) => { + const valueInMergedDocument = get(fieldsKey, merged); + return ( + fieldsValue.length === 0 || + (valueInMergedDocument === undefined && arrayInPathExists(fieldsKey, merged)) || + (isObjectLikeOrArrayOfObjectLikes(valueInMergedDocument) && + !isNestedObject(fieldsValue) && + !isTypeObject(fieldsValue)) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.test.ts new file mode 100644 index 000000000000..70d1e79580e8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.test.ts @@ -0,0 +1,1379 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mergeMissingFieldsWithSource } from './merge_missing_fields_with_source'; +import { SignalSourceHit } from '../../types'; +import { emptyEsResult } from '../../__mocks__/empty_signal_source_hit'; + +/** + * See ../README.md for the nomenclature of any notes within tests below + */ +describe('merge_missing_fields_with_source', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + /** Get the return type of the mergeMissingFieldsWithSource for TypeScript checks against expected */ + type ReturnTypeMergeFieldsWithSource = ReturnType['_source']; + + describe('fields is "undefined"', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | undefined | undefined + * p_[] | undefined | p_[] + * p_p1 | undefined | p_p1 + * p_[p1] | undefined | p_[p1] + * p_[p1, ...1] | undefined | p_[p1, ...1] + * p_{}1 | undefined | p_{}1 + * p_[{}1] | undefined | p_{}1 + * p_[{}1, ...1] | undefined | p_[{}1, ...1] + */ + describe('primitive keys in the _source document', () => { + /** fields is "undefined" for all tests below */ + const fields: SignalSourceHit['fields'] = {}; + + test('when source is "undefined", merged doc is "undefined"', () => { + const _source: SignalSourceHit['_source'] = {}; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an empty array (p_[]), merged doc is empty array (p_[])"', () => { + const _source: SignalSourceHit['_source'] = { + foo: [], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (p_p1), merged doc is primitive (p_p1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: 'value', + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (p_[p1]), merged doc is primitive (p_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: ['value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (p_[p1, ..1]), merged doc is primitive (p_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (p_{}), merged doc is single object (p_{})', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'some value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object (p_[{}1]), merged doc is single object (p_[{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (p_[{}, ...1]), merged doc is the same (p_[{}, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }, { foo: 'some other value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | undefined | undefined + * f_[] | undefined | f_[] + * f_p1 | undefined | f_p1 + * f_[p1] | undefined | f_[p1] + * f_[p1, ...1] | undefined | f_[p1, ...1] + * f_{}1 | undefined | f_{}1 + * f_[{}1] | undefined | f_{}1 + * f_[{}1, ...1] | undefined | f_[{}1, ...1] + */ + describe('flattened object keys in the _source document', () => { + /** fields is "undefined" for all tests below */ + const fields: SignalSourceHit['fields'] = {}; + + test('when source is an empty array (f_[]), merged doc is empty array (f_[])"', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (f_p1), merged doc is primitive (f_p1)', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': 'value', + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (f_[p1]), merged doc is primitive (f_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (f_[p1, ...1]), merged doc is primitive (f_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (f_{}1), merged doc is single object (f_{}1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'some value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object ([f_{}1]), merged doc is single object ([f_{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (f_[{}1, ...1]), merged doc is the same (f_[{}1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }, { foo: 'some other value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + describe('fields is "[]"', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | f_[] | undefined + * p_[] | f_[] | p_[] + * p_p1 | f_[] | p_p1 + * p_[p1] | f_[] | p_[p1] + * p_[p1, ...1] | f_[] | p_[p1, ...1] + * p_{}1 | f_[] | p_{}1 + * p_[{}1] | f_[] | p_{}1 + * p_[{}1, ...1] | f_[] | p_[{}1, ...1] + */ + describe('primitive keys in the _source document', () => { + /** fields is a flattened object key and an empty array value (p_[]) */ + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [], + }; + + test('when source is an empty array (p_[]), merged doc is empty array (p_[])"', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: [] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (p_p1), merged doc is primitive (p_p1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (p_[p1]), merged doc is primitive (p_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: ['value'] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (p_[p1, ..1]), merged doc is primitive (p_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: ['value_1', 'value_2'] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (p_{}), merged doc is single object (p_{})', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: { mars: 'some value' } }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object (p_[{}1]), merged doc is single object (p_[{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: 'some value' }] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (p_[{}, ...1]), merged doc is the same (p_[{}, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: 'some value' }, { mars: 'some other value' }] }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | f_[] | undefined + * f_[] | f_[] | f_[] + * f_p1 | f_[] | f_p1 + * f_[p1] | f_[] | f_[p1] + * f_[p1, ...1] | f_[] | f_[p1, ...1] + * f_{}1 | f_[] | f_{}1 + * f_[{}1] | f_[] | f_{}1 + * f_[{}1, ...1] | f_[] | f_[{}1, ...1] + */ + describe('flattened object keys in the _source document', () => { + /** fields is flattened object key with an empty array for a value (f_[]) */ + const fields: SignalSourceHit['fields'] = { + 'bar.foo': [], + }; + + test('when source is an empty array (f_[]), merged doc is empty array (f_[])"', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': [], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a primitive (f_p1), merged doc is primitive (f_p1)', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': 'value', + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with a single primitive (f_[p1]), merged doc is primitive (f_[p1])', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': ['value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with 2 or more primitives (f_[p1, ...1]), merged doc is primitive (f_[p1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + 'bar.foo': ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is a single object (f_{}1), merged doc is single object (f_{}1)', () => { + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'some value' }, + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array with single object ([f_{}1]), merged doc is single object ([f_{}1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('when source is an array of 1 or more objects (f_[{}1, ...1]), merged doc is the same (f_[{}1, ...1])', () => { + const _source: SignalSourceHit['_source'] = { + foo: [{ bar: 'some value' }, { foo: 'some other value' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * undefined | f_[p2] | p_p2 <-- Unboxed from array + * undefined | f_[p2, ...2] | p_[p2, ...2] + * undefined | f_[{}2] | {} <-- We have an empty object since we only merge primitives + * undefined | f_[{}2, ...2] | {} <-- We have an empty object since we only merge primitives + */ + describe('source is "undefined"', () => { + /** _source is "undefined" for all tests below */ + const _source: SignalSourceHit['_source'] = {}; + + test('fields is a single primitive value (f_[p2]), merged doc is an unboxed array element p_p2"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: 'other_value_1', + }, + }); + }); + + test('fields is a multiple primitive values (f_[p2, ...2]), merged doc is the array (p_[p2, ...2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: { + bar: ['other_value_1', 'other_value_2'], + }, + }); + }); + + test('fields is a single nested field value (f_[{}2]), merged doc is empty object ({})"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({}); + }); + + test('fields is multiple nested field values (f_[{}2, ...2]), merged doc is the empty object ({})"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({}); + }); + }); + + describe('source is either primitive or flattened keys, with primitive values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_p1 | f_[p2] | p_p1 + * p_p1 | f_[p2, ...2] | p_p1 + * p_p1 | f_[{}2] | p_p1 + * p_p1 | f_[{}2, ...2] | p_p1 + */ + describe('primitive keys in the _source document with the value of "value" (p_p1)', () => { + /** _source is a single primitive key with a primitive value for all tests below (p_p1) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'value' }, + }; + + test('fields is single array primitive value (f_[p2]), merged doc is unboxed primitive key and value (p_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive values (f_[p2, ...2]), merged doc is the array (p_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed array (p_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: 'other_value_1' }, { bar: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_p1 | f_[p2] | f_p1 + * f_p1 | f_[p2, ...2] | f_p1 + * f_p1 | f_[{}2] | f_p1 + * f_p1 | f_[{}2, ...2] | f_p1 + */ + describe('flattened object keys in the _source document (f_p1)', () => { + /** _source is a flattened object key with a primitive value for all tests below (f_p1) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': 'value', + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is unboxed primitive key and value (f_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive value (f_[p2, ...2]), merged doc is the array (f_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed array (f_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_p1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + describe('source is either primitive or flattened keys, with primitive array values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[] | f_[p2] | p_[] + * p_[] | f_[p2, ...2] | p_[] + * p_[] | f_[{}2] | p_[] + * p_[] | f_[{}2, ...2] | p_[] + */ + describe('primitive keys in the _source document with empty array (p_[])', () => { + /** _source is a primitive key with an empty array for all tests below (p_[]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: [] }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is array value (p_[]])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive values (f_[p2, ...2]), merged doc is the array (p_[])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array value (p_[])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[] | f_[p2] | f_[] + * f_[] | f_[p2, ...2] | f_[] + * f_[] | f_[{}2] | f_[] + * f_[] | f_[{}2, ...2] | f_[] + */ + describe('flattened object keys in the _source document with empty array (f_[])', () => { + /** _source is a flattened object key with an empty array for all tests below (f_[]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is array (f_[p2])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple primitive values (f_[p2, ...2]), merged doc is the array (f_[])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (f_[])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[p1] | f_[p2] | p_[p1] + * p_[p1] | f_[p2, ...2] | p_[p1] + * p_[p1] | f_[{}2] | p_[p1] + * p_[p1] | f_[{}2, ...2] | p_[p1] + */ + describe('primitive keys in the _source document with single primitive value in an array (p_[p1])', () => { + /** _source is a primitive key with a single primitive array value for all tests below (p_[p1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: ['value'] }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the array value (p_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive value (f_[p2, ...2]), merged doc is the array (p_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (p_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[p1] | f_[p2] | f_[p1] + * f_[p1] | f_[p2, ...2] | f_[p1] + * f_[p1] | f_[{}2] | f_[p1] + * f_[p1] | f_[{}2, ...2] | f_[p1] + */ + describe('flattened keys in the _source document with single flattened value in an array (f_[p1])', () => { + /** _source is a flattened object key with a single primitive value for all tests below (f_p[p1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value'], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is array value (f_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive value (f_[p2, ...2]), merged doc is the array (f_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (f_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[p1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[p1, ...1] | f_[p2] | p_[p1, ...1] + * p_[p1, ...1] | f_[p2, ...2] | p_[p1, ...1] + * p_[p1, ...1] | f_[{}2] | p_[p1, ...1] + * p_[p1, ...1] | f_[{}2, ...2] | p_[p1, ...1] + */ + describe('primitive keys in the _source document with multiple array values in an array (p_[p1, ...1])', () => { + /** _source is a primitive key with an array of 2 or more elements for all tests below (p_[p1, ...1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { + bar: ['value_1', 'value_2'], + }, + }; + + test('fields is single array value (f_[p2]), merged doc is array (p_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields is multiple primitive values (f_[p2, ...2]), merged doc is the array (p_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed value (p_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[p1, ...1] | f_[p2] | f_[p1, ...1] + * f_[p1, ...1] | f_[p2, ...2] | f_[p1, ...1] + * f_[p1, ...1] | f_[{}2] | f_[p1, ...1] + * f_[p1, ...1] | f_[{}2, ...2] | f_[p1, ...1] + */ + describe('flattened keys in the _source document with multiple array values in an array (f_[p1, ...1])', () => { + /** _source is a flattened object key with an array of 2 or more elements for all tests below (f_[p1, ...1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': ['value_1', 'value_2'], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is unboxed primitive key and value (f_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple primitive values (f_[p2, ...2]), merged doc is the array (f_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array (f_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[p1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + describe('source is either primitive or flattened keys, with object values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_{}1 | f_[p2] | p_{}1 + * p_{}1 | f_[p2, ...2] | p_{}1 + * p_{}1 | f_[{}2] | p_{}1 + * p_{}1 | f_[{}2, ...2] | p_{}1 + */ + describe('primitive keys in the _source document with the value of "value" (p_{}1)', () => { + /** _source is a primitive key with an object value for all tests below (p_{}1) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: { mars: 'value_1' } }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same source (p_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has single primitive values (f_[p2, ...2]), merged doc is the same _source (p_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the unboxed array value (p_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_{}1 | f_[p2] | f_{}1 + * f_{}1 | f_[p2, ...2] | f_{}1 + * f_{}1 | f_[{}2] | f_{}1 + * f_{}1 | f_[{}2, ...2] | f_{}1 + */ + describe('flattened object keys in the _source document with the value of "value" (f_{}1)', () => { + /** _source is a flattened object key with an object value for all tests below (f_{}1) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': { mars: 'value_1' }, + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same source (f_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has primitive values (f_[p2, ...2]), merged doc is the same _source (f_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is unboxed array value (f_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_{}1)"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + describe('source is either primitive or flattened keys, with object array values', () => { + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[{}1] | f_[p2] | p_[{}1] + * p_[{}1] | f_[p2, ...2] | p_[{}1] + * p_[{}1] | f_[{}2] | p_[{}1] + * p_[{}1] | f_[{}2, ...2] | p_[{}1] + */ + describe('primitive keys in the _source document with a single array value with an object (p_[{}1])', () => { + /** _source is a primitive key with a single array value with an object for all tests below (p_[{}1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: ['value_1'] }] }, + }; + + test('fields has a single primitive value (f_[p2]), merged doc is the same _source (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has 2 or more primitive values (f_[p2, ...2]), merged doc is the same _source (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array value (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * p_[{}1, ...1] | f_[p2] | p_[{}1, ...1] + * p_[{}1, ...1] | f_[p2, ...2] | p_[{}1, ...1] + * p_[{}1, ...1] | f_[{}2] | p_[{}1, ...1] + * p_[{}1, ...1] | f_[{}2, ...2] | p_[{}1, ...1] + */ + describe('primitive keys in the _source document with multiple array objects (p_[{}1, ...1])', () => { + /** _source is a primitive key with a 2 or more array values with an object for all tests below (p_[{}1, ...1]) */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: [{ mars: ['value_1'] }, { mars: ['value_1'] }] }, + }; + + test('fields has a single primitive value (f_[p2]), merged doc is the same _source (p_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has 2 or more primitive values (f_[p2, ...2]), merged doc is the same _source (p_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is the array value (p_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (p_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ zed: 'other_value_1' }, { zed: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[{}1] | f_[p2] | f_[{}1] + * f_[{}1] | f_[p2, ...2] | f_[{}1] + * f_[{}1] | f_[{}2] | f_[{}1] + * f_[{}1] | f_[{}2, ...2] | f_[{}1] + */ + describe('flattened object keys in the _source document with the single value of "value" (f_[{}1])', () => { + /** _source is a flattened object key with a single array object for all tests below (f_[{}1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [{ mars: 'value_1' }], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same _source (f_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has primitive values (f_[p2, ...2]), merged doc is the same _source (f_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is array value (f_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * source | fields | value after merge + * ----- | --------- | ----- + * f_[{}1, ...1] | f_[p2] | f_[{}1, ...1] + * f_[{}1, ...1] | f_[p2, ...2] | f_[{}1, ...1] + * f_[{}1, ...1] | f_[{}2] | f_[{}1, ...1] + * f_[{}1, ...1] | f_[{}2, ...2] | f_[{}1, ...1] + */ + describe('flattened object keys in the _source document with multiple values of "value" (f_[{}1, ...1])', () => { + /** _source is a flattened object key with 2 or more array objects for all tests below (f_[{}1]) */ + const _source: SignalSourceHit['_source'] = { + 'foo.bar': [{ mars: 'value_1' }, { mars: 'value_2' }], + }; + + test('fields is flattened object key with single array value (f_[p2]), merged doc is the same _source (f_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has primitive values (f_[p2, ...2]), merged doc is the same _source (f_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1', 'other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has a single nested object (f_[{}2]), merged doc is array value (f_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('fields has multiple nested objects (f_[{}2, ...2]), merged doc is the array (f_[{}1, ...1])"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': [{ mars: 'other_value_1' }, { mars: 'other_value_2' }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + }); + + /** + * It is possible to have a mixture of flattened keys and primitive keys within a _source document. + * These tests cover those cases and these test cases should be considered hopefully rare occurrences. + * If these become more common place, update the top table with all the permutations and combinations + * of tests for these. For now, expect the flattened object keys to get the values added to them vs. + * the other value. These tests show the behaviors of this but also the existing bugs of what happens + * when we merge. + */ + describe('miscellaneous tests of mixed flattened and source objects within _source', () => { + /** _source has a primitive key mixed with an object with the same path information which causes ambiguity */ + const _source: SignalSourceHit['_source'] = { + foo: { bar: 'value_1' }, + 'foo.bar': 'value_2', + }; + + test('fields has a single primitive value f_[p2] which is not overridden"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + /** + * This is an ambiguous situation in which we produce correct results since _source is defined. + */ + test('fields has the same list of values as that of the original document"', () => { + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['value_1', 'value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * These tests show the behaviors around overriding fields with other fields such as objects overriding + * values and values overriding objects. This occurs with multi fields where you can have "foo" and "foo.keyword" + * in the fields + */ + describe('Fields overriding fields', () => { + describe('primitive keys for the _source', () => { + test('DOES NOT remove multi-field values such "foo.keyword" mixed with "foo" and prefers just "foo" for 1st level', () => { + const _source: SignalSourceHit['_source'] = { + foo: 'foo_value_1', + bar: 'bar_value_1', + }; + const fields: SignalSourceHit['fields'] = { + foo: ['foo_other_value_1'], + 'foo.keyword': ['foo_other_value_keyword_1'], + bar: ['bar_other_value_1'], + 'bar.keyword': ['bar_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('DOES NOT remove multi-field values such "host.name.keyword" mixed with "host.name" and prefers just "host.name" for 2nd level', () => { + const _source: SignalSourceHit['_source'] = { + host: { + name: 'host_value_1', + hostname: 'host_name_value_1', + }, + }; + const fields: SignalSourceHit['fields'] = { + 'host.name': ['host_name_other_value_1'], + 'host.name.keyword': ['host_name_other_value_keyword_1'], + 'host.hostname': ['hostname_other_value_1'], + 'host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('DOES NOT remove multi-field values such "foo.host.name.keyword" mixed with "foo.host.name" and prefers just "foo.host.name" for 3rd level', () => { + const _source: SignalSourceHit['_source'] = { + foo: { + host: { + name: 'host_value_1', + hostname: 'host_name_value_1', + }, + }, + }; + const fields: SignalSourceHit['fields'] = { + 'foo.host.name': ['host_name_other_value_1'], + 'foo.host.name.keyword': ['host_name_other_value_keyword_1'], + 'foo.host.hostname': ['hostname_other_value_1'], + 'foo.host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('multi-field values mixed with regular values will not be merged accidentally"', () => { + const _source: SignalSourceHit['_source'] = {}; + const fields: SignalSourceHit['fields'] = { + foo: ['other_value_1'], + 'foo.bar': ['other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: 'other_value_1', + }); + }); + }); + + describe('flattened keys for the _source', () => { + test('DOES NOT remove multi-field values such "host.name.keyword" mixed with "host.name" and prefers just "host.name" for 2nd level', () => { + const _source: SignalSourceHit['_source'] = { + 'host.name': 'host_value_1', + 'host.hostname': 'host_name_value_1', + }; + const fields: SignalSourceHit['fields'] = { + 'host.name': ['host_name_other_value_1'], + 'host.name.keyword': ['host_name_other_value_keyword_1'], + 'host.hostname': ['hostname_other_value_1'], + 'host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('DOES NOT remove multi-field values such "foo.host.name.keyword" mixed with "foo.host.name" and prefers just "foo.host.name" for 3rd level', () => { + const _source: SignalSourceHit['_source'] = { + 'foo.host.name': 'host_value_1', + 'foo.host.hostname': 'host_name_value_1', + }; + const fields: SignalSourceHit['fields'] = { + 'foo.host.name': ['host_name_other_value_1'], + 'foo.host.name.keyword': ['host_name_other_value_keyword_1'], + 'foo.host.hostname': ['hostname_other_value_1'], + 'foo.host.hostname.keyword': ['hostname_other_value_keyword_1'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('invalid fields of several levels mixed with regular values will not be merged accidentally due to runtime fields being liberal"', () => { + const _source: SignalSourceHit['_source'] = {}; + const fields: SignalSourceHit['fields'] = { + foo: ['other_value_1'], + 'foo.bar': ['other_value_2'], + 'foo.bar.zed': ['zed_other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({ + foo: 'other_value_1', + }); + }); + }); + }); + + /** + * These tests are around parent objects that are not nested but are array types. We do not try to merge + * into these as this causes ambiguities between array types and object types. + */ + describe('parent array objects', () => { + test('parent array objects will not be overridden since that is an ambiguous use case for a top level value', () => { + const _source: SignalSourceHit['_source'] = { + foo: [ + { + bar: 'value_1', + mars: ['value_1'], + }, + ], + }; + const fields: SignalSourceHit['fields'] = { + 'foo.bar': ['other_value_1'], + 'foo.mars': ['other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + + test('parent array objects will not be overridden since that is an ambiguous use case for a deeply nested value', () => { + const _source: SignalSourceHit['_source'] = { + foo: { + zed: [ + { + bar: 'value_1', + mars: ['value_1'], + }, + ], + }, + }; + const fields: SignalSourceHit['fields'] = { + 'foo.zed.bar': ['other_value_1'], + 'foo.zed.mars': ['other_value_2'], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); + + /** + * Specific tests around nested field types such as ensuring we are unboxing when we can + */ + describe('nested fields', () => { + test('returns empty object since we only consider merging in primitive values and not nested fields', () => { + const _source: SignalSourceHit['_source'] = {}; + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: ['single_value'], zed: ['single_value'] }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual({}); + }); + + test('does not touch the source object when it is empty arrays', () => { + const _source: SignalSourceHit['_source'] = { + foo: [ + { + bar: [], + zed: [], + }, + ], + }; + const fields: SignalSourceHit['fields'] = { + foo: [{ bar: ['single_value'], zed: ['single_value'] }], + }; + const doc: SignalSourceHit = { ...emptyEsResult(), _source, fields }; + const merged = mergeMissingFieldsWithSource({ doc })._source; + expect(merged).toEqual(_source); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts new file mode 100644 index 000000000000..bf541acbe7e3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash/fp'; +import { set } from '@elastic/safer-lodash-set/fp'; +import { SignalSource, SignalSourceHit } from '../../types'; +import { filterFieldEntries } from '../utils/filter_field_entries'; +import type { FieldsType } from '../types'; +import { recursiveUnboxingFields } from '../utils/recursive_unboxing_fields'; +import { isTypeObject } from '../utils/is_type_object'; +import { arrayInPathExists } from '../utils/array_in_path_exists'; +import { isNestedObject } from '../utils/is_nested_object'; + +/** + * Merges only missing sections of "doc._source" with its "doc.fields" on a "best effort" basis. See ../README.md for more information + * on this function and the general strategies. + * @param doc The document with "_source" and "fields" + * @param throwOnFailSafe Defaults to false, but if set to true it will cause a throw if the fail safe is triggered to indicate we need to add a new explicit test condition + * @returns The two merged together in one object where we can + */ +export const mergeMissingFieldsWithSource = ({ + doc, +}: { + doc: SignalSourceHit; +}): SignalSourceHit => { + const source = doc._source ?? {}; + const fields = doc.fields ?? {}; + const fieldEntries = Object.entries(fields); + const filteredEntries = filterFieldEntries(fieldEntries); + + const transformedSource = filteredEntries.reduce( + (merged, [fieldsKey, fieldsValue]: [string, FieldsType]) => { + if ( + hasEarlyReturnConditions({ + fieldsValue, + fieldsKey, + merged, + }) + ) { + return merged; + } + + const valueInMergedDocument = get(fieldsKey, merged); + const valueToMerge = recursiveUnboxingFields(fieldsValue, valueInMergedDocument); + return set(fieldsKey, valueToMerge, merged); + }, + { ...source } + ); + + return { + ...doc, + _source: transformedSource, + }; +}; + +/** + * Returns true if any early return conditions are met which are + * - If the fieldsValue is an empty array return + * - If the value to merge in is not undefined, return early + * - If an array within the path exists, do an early return + * - If the value matches a type object, do an early return + * @param fieldsValue The field value to check + * @param fieldsKey The key of the field we are checking + * @param merged The merge document which is what we are testing conditions against + * @returns true if we should return early, otherwise false + */ +const hasEarlyReturnConditions = ({ + fieldsValue, + fieldsKey, + merged, +}: { + fieldsValue: FieldsType; + fieldsKey: string; + merged: SignalSource; +}) => { + const valueInMergedDocument = get(fieldsKey, merged); + return ( + fieldsValue.length === 0 || + valueInMergedDocument !== undefined || + arrayInPathExists(fieldsKey, merged) || + isNestedObject(fieldsValue) || + isTypeObject(fieldsValue) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts new file mode 100644 index 000000000000..e8142e41715e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * A bit stricter typing since the default fields type is an "any" + */ +export type FieldsType = string[] | number[] | boolean[] | object[]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.test.ts new file mode 100644 index 000000000000..1b2accd0a16a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { arrayInPathExists } from './array_in_path_exists'; + +describe('array_in_path_exists', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('returns false when empty string and empty object', () => { + expect(arrayInPathExists('', {})).toEqual(false); + }); + + test('returns false when a path and empty object', () => { + expect(arrayInPathExists('a.b.c', {})).toEqual(false); + }); + + test('returns true when a path and an array exists', () => { + expect(arrayInPathExists('a', { a: [] })).toEqual(true); + }); + + test('returns true when a path and an array exists within the parent path at level 1', () => { + expect(arrayInPathExists('a.b', { a: [] })).toEqual(true); + }); + + test('returns true when a path and an array exists within the parent path at level 3', () => { + expect(arrayInPathExists('a.b.c', { a: [] })).toEqual(true); + }); + + test('returns true when a path and an array exists within the parent path at level 2', () => { + expect(arrayInPathExists('a.b.c', { a: { b: [] } })).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.ts new file mode 100644 index 000000000000..b8e742fbaba6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/array_in_path_exists.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash/fp'; +import { SignalSource } from '../../types'; + +/** + * Returns true if an array within the path exists anywhere. + * @param fieldsKey The fields key to check if an array exists along the path + * @param source The source document to check for an array anywhere along the path + * @returns true if we detect an array along the path, otherwise false + */ +export const arrayInPathExists = (fieldsKey: string, source: SignalSource): boolean => { + const splitPath = fieldsKey.split('.'); + return splitPath.some((_, index, array) => { + const newPath = [...array].splice(0, index + 1).join('.'); + return Array.isArray(get(newPath, source)); + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.test.ts new file mode 100644 index 000000000000..9cc247829088 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { filterFieldEntries } from './filter_field_entries'; +import { FieldsType } from '../types'; + +describe('filter_field_entries', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + /** Dummy test value */ + const dummyValue = ['value']; + + /** + * Get the return type of the mergeFieldsWithSource for TypeScript checks against expected + */ + type ReturnTypeFilterFieldEntries = ReturnType; + + test('returns a single valid fieldEntries as expected', () => { + const fieldEntries: Array<[string, FieldsType]> = [['foo.bar', dummyValue]]; + expect(filterFieldEntries(fieldEntries)).toEqual(fieldEntries); + }); + + test('removes invalid dotted entries', () => { + const fieldEntries: Array<[string, FieldsType]> = [ + ['.', dummyValue], + ['foo.bar', dummyValue], + ['..', dummyValue], + ['foo..bar', dummyValue], + ]; + expect(filterFieldEntries(fieldEntries)).toEqual([ + ['foo.bar', dummyValue], + ]); + }); + + test('removes multi-field values such "foo.keyword" mixed with "foo" and prefers just "foo" for 1st level', () => { + const fieldEntries: Array<[string, FieldsType]> = [ + ['foo', dummyValue], + ['foo.keyword', dummyValue], // <-- "foo.keyword" multi-field should be removed + ['bar.keyword', dummyValue], // <-- "bar.keyword" multi-field should be removed + ['bar', dummyValue], + ]; + expect(filterFieldEntries(fieldEntries)).toEqual([ + ['foo', dummyValue], + ['bar', dummyValue], + ]); + }); + + test('removes multi-field values such "host.name.keyword" mixed with "host.name" and prefers just "host.name" for 2nd level', () => { + const fieldEntries: Array<[string, FieldsType]> = [ + ['host.name', dummyValue], + ['host.name.keyword', dummyValue], // <-- multi-field should be removed + ['host.hostname', dummyValue], + ['host.hostname.keyword', dummyValue], // <-- multi-field should be removed + ]; + expect(filterFieldEntries(fieldEntries)).toEqual([ + ['host.name', dummyValue], + ['host.hostname', dummyValue], + ]); + }); + + test('removes multi-field values such "foo.host.name.keyword" mixed with "foo.host.name" and prefers just "foo.host.name" for 3rd level', () => { + const fieldEntries: Array<[string, FieldsType]> = [ + ['foo.host.name', dummyValue], + ['foo.host.name.keyword', dummyValue], // <-- multi-field should be removed + ['foo.host.hostname', dummyValue], + ['foo.host.hostname.keyword', dummyValue], // <-- multi-field should be removed + ]; + expect(filterFieldEntries(fieldEntries)).toEqual([ + ['foo.host.name', dummyValue], + ['foo.host.hostname', dummyValue], + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.ts new file mode 100644 index 000000000000..221cdabc6284 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/filter_field_entries.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isMultiField } from './is_multifield'; +import { isInvalidKey } from './is_invalid_key'; +import { isTypeObject } from './is_type_object'; +import { FieldsType } from '../types'; + +/** + * Filters field entries by removing invalid field entries such as any invalid characters + * in the keys or if there are sub-objects that are trying to override regular objects and + * are invalid runtime field names. Also matches type objects such as geo-points and we ignore + * those and don't try to merge those. + * + * @param fieldEntries The field entries to filter + * @returns The field entries filtered + */ +export const filterFieldEntries = ( + fieldEntries: Array<[string, FieldsType]> +): Array<[string, FieldsType]> => { + return fieldEntries.filter(([fieldsKey, fieldsValue]: [string, FieldsType]) => { + return ( + !isInvalidKey(fieldsKey) && + !isMultiField(fieldsKey, fieldEntries) && + !isTypeObject(fieldsValue) // TODO: Look at not filtering this and instead transform it so it can be inserted correctly in the strategies which does an overwrite of everything from fields + ); + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/index.ts new file mode 100644 index 000000000000..baf9efca511e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export * from './array_in_path_exists'; +export * from './filter_field_entries'; +export * from './is_array_of_primitives'; +export * from './is_invalid_key'; +export * from './is_multifield'; +export * from './is_nested_object'; +export * from './is_objectlike_or_array_of_objectlikes'; +export * from './is_primitive'; +export * from './is_type_object'; +export * from './recursive_unboxing_fields'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.test.ts new file mode 100644 index 000000000000..22b9903d30de --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isArrayOfPrimitives } from './is_array_of_primitives'; + +describe('is_array_of_primitives', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('returns true when an empty array is passed in', () => { + expect(isArrayOfPrimitives([])).toEqual(true); + }); + + test('returns true when an array of primitives are passed in', () => { + expect(isArrayOfPrimitives([null, 2, 'string', 5, undefined])).toEqual(true); + }); + + /** + * Simple table test of values of primitive arrays which should all pass + */ + test.each([ + [[null]], + [[1]], + [['string']], + [['string', null, 5, false, String('hi'), Boolean(true), undefined]], + ])('returns true when a primitive array of %o is passed in', (arrayValue) => { + expect(isArrayOfPrimitives(arrayValue)).toEqual(true); + }); + + /** + * Simple table test of values of all objects which should not pass + */ + test.each([ + [[{}]], + [[{ a: 1 }]], + [[1, {}]], + [[[], 'string']], + [['string', null, 5, false, String('hi'), {}, Boolean(true), undefined]], + ])('returns false when the array of %o contains an object is passed in', (arrayValue) => { + expect(isArrayOfPrimitives(arrayValue)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.ts new file mode 100644 index 000000000000..c65c88c40b9b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_array_of_primitives.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SearchTypes } from '../../../../../../common/detection_engine/types'; +import { isPrimitive } from './is_primitive'; + +/** + * Returns true if this is an array and all elements of the array are primitives and not objects + * @param valueInMergedDocument The search type to check if everything is primitive or not + * @returns true if is an array and everything in the array is a primitive type + */ +export const isArrayOfPrimitives = (valueInMergedDocument: SearchTypes | null): boolean => { + return ( + Array.isArray(valueInMergedDocument) && + valueInMergedDocument.every((value) => isPrimitive(value)) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.test.ts new file mode 100644 index 000000000000..703573223877 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isInvalidKey } from './is_invalid_key'; + +describe('matches_invalid_key', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('it returns true if a value is a single dot', () => { + expect(isInvalidKey('.')).toEqual(true); + }); + + test('it returns true if a value starts with a dot', () => { + expect(isInvalidKey('.invalidName')).toEqual(true); + }); + + test('it returns true if a value is 2 dots', () => { + expect(isInvalidKey('..')).toEqual(true); + }); + + test('it returns true if a value is 3 dots', () => { + expect(isInvalidKey('...')).toEqual(true); + }); + + test('it returns true if a value has two dots in its name', () => { + expect(isInvalidKey('host..name')).toEqual(true); + }); + + test('it returns false if a value has a single dot', () => { + expect(isInvalidKey('host.name')).toEqual(false); + }); + + test('it returns false if a value is a regular path', () => { + expect(isInvalidKey('a.b.c.d')).toEqual(false); + }); + + /** Yes, this is a valid key in elastic */ + test('it returns false if a value ends with a dot', () => { + expect(isInvalidKey('validName.')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.ts new file mode 100644 index 000000000000..358dc3e14854 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_invalid_key.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Matches any invalid keys from runtime fields such as runtime fields which can start with a + * "." or runtime fields which can have ".." two or more dots. + * @param fieldsKey The fields key to match against + * @returns true if it is invalid key, otherwise false + */ +export const isInvalidKey = (fieldsKey: string): boolean => { + return fieldsKey.startsWith('.') || fieldsKey.match(/[\.]{2,}/) != null; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.test.ts new file mode 100644 index 000000000000..a8050b600b46 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isMultiField } from './is_multifield'; + +describe('is_multifield', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const dummyValue = ['value']; + + test('it returns true if the string "foo.bar" is a multiField', () => { + expect(isMultiField('foo.bar', [['foo', dummyValue]])).toEqual(true); + }); + + test('it returns false if the string "foo" is not a multiField', () => { + expect(isMultiField('foo', [['foo', dummyValue]])).toEqual(false); + }); + + test('it returns false if we have a sibling string and are not a multiField', () => { + expect(isMultiField('foo.bar', [['foo.mar', dummyValue]])).toEqual(false); + }); + + test('it returns true for a 3rd level match of being a sub-object. Runtime fields can have multiple layers of multiFields', () => { + expect(isMultiField('foo.mars.bar', [['foo', dummyValue]])).toEqual(true); + }); + + test('it returns true for a 3rd level match against a 2nd level sub-object. Runtime fields can have multiple layers of multiFields', () => { + expect(isMultiField('foo.mars.bar', [['foo.mars', dummyValue]])).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.ts new file mode 100644 index 000000000000..feee6026c60b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_multifield.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldsType } from '../types'; + +/** + * Returns true if we are a multiField when passed in a fields entry and a fields key, + * otherwise false. Notice that runtime fields can have multiple levels of multiFields which is kind a problem + * but we compensate and test for that here as well. So technically this matches both multiFields and + * invalid multiple-multiFields. + * @param fieldsKey The key to check against the entries to see if it is a multiField + * @param fieldEntries The entries to check against. + * @returns True if we are a subObject, otherwise false. + */ +export const isMultiField = ( + fieldsKey: string, + fieldEntries: Array<[string, FieldsType]> +): boolean => { + const splitPath = fieldsKey.split('.'); + return splitPath.some((_, index, array) => { + if (index + 1 === array.length) { + return false; + } else { + const newPath = [...array].splice(0, index + 1).join('.'); + return fieldEntries.some(([fieldKeyToCheck]) => { + return fieldKeyToCheck === newPath; + }); + } + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.test.ts new file mode 100644 index 000000000000..d083fb8bdf84 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isNestedObject } from './is_nested_object'; + +describe('is_nested_object', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('returns false when an empty array is passed in', () => { + expect(isNestedObject([])).toEqual(false); + }); + + /** + * Simple table test of values of primitive arrays which should all return false + */ + test.each([ + [[1]], + [['string']], + [[true]], + [[String('hi')]], + [[Number(5)]], + [[{ type: 'point' }]], + ])('returns false when a primitive array of %o is passed in', (arrayValues) => { + expect(isNestedObject(arrayValues)).toEqual(false); + }); + + /** + * Simple table test of values of primitive arrays which should all return true + */ + test.each([[[{}]], [[{ a: 'foo' }]]])( + 'returns false when a primitive array of %o is passed in', + (arrayValues) => { + expect(isNestedObject(arrayValues)).toEqual(true); + } + ); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.ts new file mode 100644 index 000000000000..38a0f871279e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_nested_object.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isObjectLike } from 'lodash/fp'; +import { isTypeObject } from './is_type_object'; +import { FieldsType } from '../types'; + +/** + * Returns true if the first value is object-like but does not contain the shape of + * a "type object" such as geo point, then it makes an assumption everything is objectlike + * and not "type object" for all the array values. This should be used only for checking + * for nested object types within fields. + * @param fieldsValue The value to check if the first element is object like or not + * @returns True if this is a nested object, otherwise false. + */ +export const isNestedObject = (fieldsValue: FieldsType): boolean => { + return isObjectLike(fieldsValue[0]) && !isTypeObject(fieldsValue); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.test.ts new file mode 100644 index 000000000000..454b70e69d0a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isObjectLikeOrArrayOfObjectLikes } from './is_objectlike_or_array_of_objectlikes'; + +describe('is_objectlike_or_array_of_objectlikes', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('returns false when an empty array is passed in', () => { + expect(isObjectLikeOrArrayOfObjectLikes([])).toEqual(false); + }); + + test('returns false when an array of primitives are passed in', () => { + expect(isObjectLikeOrArrayOfObjectLikes([null, 2, 'string', 5, undefined])).toEqual(false); + }); + + /** + * Simple table test of values of primitive arrays which should all fail + */ + test.each([ + [[null]], + [[1]], + [['string']], + [['string', null, 5, false, String('hi'), Boolean(true), undefined]], + ])('returns true when a primitive array of %o is passed in', (arrayValue) => { + expect(isObjectLikeOrArrayOfObjectLikes(arrayValue)).toEqual(false); + }); + + /** + * Simple table test of values of primitives which should all fail + */ + test.each([[null], [1], ['string'], [null], [String('hi')], [Boolean(true)], [undefined]])( + 'returns true when a primitive array of %o is passed in', + (arrayValue) => { + expect(isObjectLikeOrArrayOfObjectLikes(arrayValue)).toEqual(false); + } + ); + + /** + * Simple table test of values of all array of objects which should pass + */ + test.each([ + [[{}]], + [[{ a: 1 }]], + [[1, {}]], + [[[], 'string']], + [['string', null, 5, false, String('hi'), {}, Boolean(true), undefined]], + ])('returns false when the array of %o contains an object is passed in', (arrayValue) => { + expect(isObjectLikeOrArrayOfObjectLikes(arrayValue)).toEqual(true); + }); + + /** + * Simple table test of objects which should pass + */ + test.each([[{}], [{ a: 1 }]])( + 'returns false when the array of %o contains an object is passed in', + (arrayValue) => { + expect(isObjectLikeOrArrayOfObjectLikes(arrayValue)).toEqual(true); + } + ); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.ts new file mode 100644 index 000000000000..3f57eda31ca3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_objectlike_or_array_of_objectlikes.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isObjectLike } from 'lodash/fp'; +import { SearchTypes } from '../../../../../../common/detection_engine/types'; + +/** + * Returns true if at least one element is an object, otherwise false if they all are not objects + * if this is an array. If it is not an array, this will check that single type + * @param valueInMergedDocument The search type to check if it is object like or not + * @returns true if is object like and not an array, or true if it is an array and at least 1 element is object like + */ +export const isObjectLikeOrArrayOfObjectLikes = ( + valueInMergedDocument: SearchTypes | null +): boolean => { + if (Array.isArray(valueInMergedDocument)) { + return valueInMergedDocument.some((value) => isObjectLike(value)); + } else { + return isObjectLike(valueInMergedDocument); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.test.ts new file mode 100644 index 000000000000..6d1b273df4ad --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isPrimitive } from './is_primitive'; + +describe('is_primitives', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('returns false when an empty array is passed in', () => { + expect(isPrimitive([])).toEqual(false); + }); + + /** + * Simple table test of values of primitive values which should all pass + */ + test.each([[null], [1], ['string'], [true], [Boolean('true')]])( + 'returns true when a primitive array of %o is passed in', + (arrayValue) => { + expect(isPrimitive(arrayValue)).toEqual(true); + } + ); + + /** + * Simple table test of values of objects which should not pass + */ + test.each([[{}], [{ a: 1 }]])( + 'returns false when the array of %o contains an object is passed in', + (arrayValue) => { + expect(isPrimitive(arrayValue)).toEqual(false); + } + ); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.ts new file mode 100644 index 000000000000..c74b5f085989 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_primitive.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isObjectLike } from 'lodash/fp'; +import { SearchTypes } from '../../../../../../common/detection_engine/types'; + +/** + * Returns true if it is a primitive type, otherwise false + */ +export const isPrimitive = (valueInMergedDocument: SearchTypes | null): boolean => { + return !isObjectLike(valueInMergedDocument); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.test.ts new file mode 100644 index 000000000000..ee5ba60e91e1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isTypeObject } from './is_type_object'; + +describe('is_type_object', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('returns false when an empty array is passed in', () => { + expect(isTypeObject([])).toEqual(false); + }); + + test('returns true when a type object is in the array', () => { + expect(isTypeObject([{ type: 'Point' }])).toEqual(true); + }); + + test('returns false when a type object is not in the array', () => { + expect(isTypeObject([{ foo: 'a' }])).toEqual(false); + }); + + test('returns false when a primitive is passed in', () => { + expect(isTypeObject(['string'])).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.ts new file mode 100644 index 000000000000..68afad9ff4fe --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/is_type_object.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash/fp'; +import { FieldsType } from '../types'; + +/** + * Returns true if we match a "type" object which could be a geo-point when we are parsing field + * values and we encounter a geo-point. + * @param fieldsValue The value to test the shape of the data and see if it is a geo-point or not + * @returns True if we match a geo-point or another type or not. + */ +export const isTypeObject = (fieldsValue: FieldsType): boolean => { + return (fieldsValue as Array).some((value) => { + if (typeof value === 'object' && value != null) { + return get('type', value); + } else { + return false; + } + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.test.ts new file mode 100644 index 000000000000..130990393b74 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SearchTypes } from '../../../../../../common/detection_engine/types'; +import { recursiveUnboxingFields } from './recursive_unboxing_fields'; +import { FieldsType } from '../types'; + +describe('recursive_unboxing_fields', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('valueInMergedDocument is "undefined"', () => { + const valueInMergedDocument: SearchTypes = undefined; + test('it will return an empty array as is', () => { + const nested: FieldsType = []; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual([]); + }); + + test('it will return an empty object as is', () => { + const nested: FieldsType[0] = {}; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual({}); + }); + + test('it will unbox a single array field', () => { + const nested: FieldsType = ['foo_value_1']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual('foo_value_1'); + }); + + test('it will not unbox an array with two fields', () => { + const nested: FieldsType = ['foo_value_1', 'foo_value_2']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual([ + 'foo_value_1', + 'foo_value_2', + ]); + }); + + test('it will unbox a nested structure of 3 single arrays', () => { + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual({ bar: { zed: 'zed_value_1' }, foo: 'foo_value_1' }); + }); + + test('it will not unbox a nested structure of 2 array values at the top most level', () => { + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual([ + { bar: { zed: 'zed_value_1' }, foo: 'foo_value_1' }, + { bar: { zed: 'zed_value_1' }, foo: 'foo_value_1' }, + ]); + }); + + test('it will not unbox a nested structure of mixed values at different levels', () => { + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + fred: { + yolo: ['deep_1', 'deep_2'], + }, + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual({ + bar: { fred: { yolo: ['deep_1', 'deep_2'] }, zed: 'zed_value_1' }, + foo: 'foo_value_1', + }); + }); + }); + + describe('valueInMergedDocument is an empty object', () => { + const valueInMergedDocument: SearchTypes = {}; + test('it will return an empty array as is', () => { + const nested: FieldsType = []; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual([]); + }); + + test('it will return an empty object as is', () => { + const nested: FieldsType[0] = {}; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual({}); + }); + + test('it will unbox a single array field', () => { + const nested: FieldsType = ['foo_value_1']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual('foo_value_1'); + }); + + test('it will not unbox an array with two fields', () => { + const nested: FieldsType = ['foo_value_1', 'foo_value_2']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual([ + 'foo_value_1', + 'foo_value_2', + ]); + }); + + test('it will unbox a nested structure of 3 single arrays', () => { + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual({ bar: { zed: 'zed_value_1' }, foo: 'foo_value_1' }); + }); + + test('it will not unbox a nested structure of 2 array values at the top most level', () => { + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual([ + { bar: { zed: 'zed_value_1' }, foo: 'foo_value_1' }, + { bar: { zed: 'zed_value_1' }, foo: 'foo_value_1' }, + ]); + }); + + test('it will not unbox a nested structure of mixed values at different levels', () => { + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + fred: { + yolo: ['deep_1', 'deep_2'], + }, + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual({ + bar: { fred: { yolo: ['deep_1', 'deep_2'] }, zed: 'zed_value_1' }, + foo: 'foo_value_1', + }); + }); + }); + + describe('valueInMergedDocument mirrors the nested field in different ways', () => { + test('it will not unbox when the valueInMergedDocument is an array value', () => { + const valueInMergedDocument: SearchTypes = ['foo_value_1']; + const nested: FieldsType = ['foo_value_1']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual(['foo_value_1']); + }); + + test('it will not unbox when the valueInMergedDocument is an empty array value', () => { + const valueInMergedDocument: SearchTypes = []; + const nested: FieldsType = ['foo_value_1']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual(['foo_value_1']); + }); + + test('it will not unbox an array with two fields', () => { + const valueInMergedDocument: SearchTypes = ['foo_value_1', 'foo_value_2']; + const nested: FieldsType = ['foo_value_1', 'foo_value_2']; + expect(recursiveUnboxingFields(nested, valueInMergedDocument)).toEqual([ + 'foo_value_1', + 'foo_value_2', + ]); + }); + + test('it will not unbox a nested structure of 3 single arrays when valueInMergedDocument has empty array values', () => { + const valueInMergedDocument: SearchTypes = [ + { + foo: [], + bar: { + zed: [], + }, + }, + ]; + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual([{ bar: { zed: ['zed_value_1'] }, foo: ['foo_value_1'] }]); + }); + + test('it will not unbox a nested structure of 3 single arrays when valueInMergedDocument has array values', () => { + const valueInMergedDocument: SearchTypes = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual([{ bar: { zed: ['zed_value_1'] }, foo: ['foo_value_1'] }]); + }); + + test('it will not overwrite a nested structure of 3 single arrays when valueInMergedDocument has array values that are different', () => { + const valueInMergedDocument: SearchTypes = [ + { + foo: ['other_value_1'], + bar: { + zed: ['other_value_2'], + }, + }, + ]; + const nested: FieldsType = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual([{ bar: { zed: ['zed_value_1'] }, foo: ['foo_value_1'] }]); + }); + + test('it will work with mixed array values between "nested" and "valueInMergedDocument"', () => { + const valueInMergedDocument: SearchTypes = [ + { + foo: ['foo_value_1'], + bar: { + zed: ['zed_value_1'], + }, + }, + ]; + const nested: FieldsType = [ + { + foo: ['foo_value_1', 'foo_value_2', 'foo_value_3'], + bar: { + zed: ['zed_value_1', 'zed_value_1', 'zed_value_2'], + }, + }, + ]; + const recursed = recursiveUnboxingFields(nested, valueInMergedDocument); + expect(recursed).toEqual([ + { + bar: { zed: ['zed_value_1', 'zed_value_1', 'zed_value_2'] }, + foo: ['foo_value_1', 'foo_value_2', 'foo_value_3'], + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.ts new file mode 100644 index 000000000000..9cd0ebcb5a42 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/utils/recursive_unboxing_fields.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash/fp'; +import { set } from '@elastic/safer-lodash-set/fp'; +import { SearchTypes } from '../../../../../../common/detection_engine/types'; +import { FieldsType } from '../types'; + +/** + * Recursively unboxes fields from an array when it is common sense to unbox them and safe to + * make an assumption to unbox them when we compare them to the "fieldsValue" and the "valueInMergedDocument" + * + * NOTE: We use "typeof fieldsValue === 'object' && fieldsValue != null" instead of lodash "objectLike" + * so that we can do type narrowing into an object to get the keys from it. + * + * @param fieldsValue The fields value that contains the nested field or not. + * @param valueInMergedDocument The document to compare against fields value to see if it is also an array or not + * @returns + */ +export const recursiveUnboxingFields = ( + fieldsValue: FieldsType | FieldsType[0], + valueInMergedDocument: SearchTypes +): FieldsType | FieldsType[0] => { + if (Array.isArray(fieldsValue)) { + const fieldsValueMapped = (fieldsValue as Array).map( + (value, index) => { + if (Array.isArray(valueInMergedDocument)) { + return recursiveUnboxingFields(value, valueInMergedDocument[index]); + } else { + return recursiveUnboxingFields(value, undefined); + } + } + ); + + if (fieldsValueMapped.length === 1) { + if (Array.isArray(valueInMergedDocument)) { + return fieldsValueMapped; + } else { + return fieldsValueMapped[0]; + } + } else { + return fieldsValueMapped; + } + } else if (typeof fieldsValue === 'object' && fieldsValue != null) { + const reducedFromKeys = Object.keys(fieldsValue).reduce((accum, key) => { + const recursed = recursiveUnboxingFields( + get(key, fieldsValue), + get(key, valueInMergedDocument) + ); + return set(key, recursed, accum); + }, {}); + return reducedFromKeys; + } else { + return fieldsValue; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index c399454b9888..3fc36d5930a0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -83,14 +83,25 @@ export interface RuleRangeTuple { maxSignals: number; } +/** + * SignalSource is being used as both a type for documents that match detection engine queries as well as + * for queries that could be on top of signals. In cases where it is matched against detection engine queries, + * '@timestamp' might not be there since it is not required and we have timestamp override capabilities. Also + * the signal addition object, "signal?: {" will not be there unless it's a conflicting field when we are running + * queries on events. + * + * For cases where we are running queries against signals (signals on signals) "@timestamp" should always be there + * and the "signal?: {" sub-object should always be there. + */ export interface SignalSource { [key: string]: SearchTypes; - // TODO: SignalSource is being used as the type for documents matching detection engine queries, but they may not - // actually have @timestamp if a timestamp override is used - '@timestamp': string; + '@timestamp'?: string; signal?: { - // parent is deprecated: new signals should populate parents instead - // both are optional until all signals with parent are gone and we can safely remove it + /** + * "parent" is deprecated: new signals should populate "parents" instead. Both are optional + * until all signals with parent are gone and we can safely remove it. + * @deprecated Use parents instead + */ parent?: Ancestor; parents?: Ancestor[]; ancestors: Ancestor[]; @@ -101,7 +112,7 @@ export interface SignalSource { rule: { id: string; }; - // signal.depth doesn't exist on pre-7.10 signals + /** signal.depth was introduced in 7.10 and pre-7.10 signals do not have it. */ depth?: number; original_time?: string; threshold_result?: ThresholdResult; @@ -202,7 +213,9 @@ export interface Signal { version: number; }; rule: RulesSchema; - // DEPRECATED: use parents instead of parent + /** + * @deprecated Use "parents" instead of "parent" + */ parent?: Ancestor; parents: Ancestor[]; ancestors: Ancestor[]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 616cf714d6a8..4d5ac05957a4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -1368,6 +1368,45 @@ describe('utils', () => { const date = getValidDateFromDoc({ doc, timestampOverride: 'different_timestamp' }); expect(date?.toISOString()).toEqual(override); }); + + test('It returns the timestamp if the timestamp happens to be a string of an epoch when it has it in _source and fields', () => { + const doc = sampleDocNoSortId(); + const testDateString = '2021-06-25T15:53:56.590Z'; + const testDate = `${new Date(testDateString).valueOf()}`; + doc._source['@timestamp'] = testDate; + if (doc.fields != null) { + doc.fields['@timestamp'] = [testDate]; + } + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date?.toISOString()).toEqual(testDateString); + }); + + test('It returns the timestamp if the timestamp happens to be a string of an epoch when it has it in _source and fields is nonexistent', () => { + const doc = sampleDocNoSortId(); + const testDateString = '2021-06-25T15:53:56.590Z'; + const testDate = `${new Date(testDateString).valueOf()}`; + doc._source['@timestamp'] = testDate; + doc.fields = undefined; + const date = getValidDateFromDoc({ doc, timestampOverride: undefined }); + expect(date?.toISOString()).toEqual(testDateString); + }); + + test('It returns the timestamp if the timestamp happens to be a string of an epoch in an override field', () => { + const override = '2020-10-07T19:36:31.110Z'; + const testDate = `${new Date(override).valueOf()}`; + let doc = sampleDocNoSortId(); + if (doc == null) { + throw new TypeError('Test requires one element'); + } + doc = { + ...doc, + fields: { + different_timestamp: [testDate], + }, + }; + const date = getValidDateFromDoc({ doc, timestampOverride: 'different_timestamp' }); + expect(date?.toISOString()).toEqual(override); + }); }); describe('createSearchAfterReturnType', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 6d67bab6eb2f..4dd434156288 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -625,6 +625,15 @@ export const getValidDateFromDoc = ({ const tempMoment = moment(lastTimestamp); if (tempMoment.isValid()) { return tempMoment.toDate(); + } else if (typeof timestampValue === 'string') { + // worse case we have a string from fields API or other areas of Elasticsearch that have given us a number as a string, + // so we try one last time to parse this best we can by converting from string to a number + const maybeDate = moment(+lastTimestamp); + if (maybeDate.isValid()) { + return maybeDate.toDate(); + } else { + return undefined; + } } else { return undefined; } diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts index ca1281e0d2da..790dc2b725a7 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts @@ -40,7 +40,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should keep the original alias value such as "host_alias" from a source index when the value is indexed', async () => { - const rule = getRuleForSignalTesting(['alias']); + const rule = getRuleForSignalTesting(['host_alias']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); @@ -51,9 +51,8 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); }); - // TODO: Make aliases work to where we can have ECS fields such as host.name filled out - it.skip('should copy alias data from a source index into the signals index in the same position when the target is ECS compatible', async () => { - const rule = getRuleForSignalTesting(['alias']); + it('should copy alias data from a source index into the signals index in the same position when the target is ECS compatible', async () => { + const rule = getRuleForSignalTesting(['host_alias']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts index 2294d51537fb..6a6822ba7eb2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts @@ -191,6 +191,13 @@ export default ({ getService }: FtrProviderContext) => { }, original_time: '2020-11-16T22:58:08.000Z', }, + all_field_values: [ + 'store', + 'linux_anomalous_network_activity_ecs', + 'root', + 'store', + 'mothra', + ], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts index fccfe4d74e24..b793fc635843 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts @@ -60,8 +60,7 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOpen.hits.hits.length).to.eql(4); }); - // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source - it.skip('should copy the dataset_name_1 from the index into the signal', async () => { + it('should copy the dataset_name_1 from the index into the signal', async () => { const rule = { ...getRuleForSignalTesting(['const_keyword']), query: 'event.dataset: "dataset_name_1"', @@ -99,8 +98,7 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOpen.hits.hits.length).to.eql(4); }); - // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source - it.skip('should copy the "dataset_name_1" from "event.dataset"', async () => { + it('should copy the "dataset_name_1" from "event.dataset"', async () => { const rule: EqlCreateSchema = { ...getRuleForSignalTesting(['const_keyword']), rule_id: 'eql-rule', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts index 3802d1f7a7be..2ce88da13afa 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts @@ -62,8 +62,7 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOpen.hits.hits.length).to.eql(8); }); - // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source - it.skip('should copy the dataset_name_1 from the index into the signal', async () => { + it('should copy the dataset_name_1 from the index into the signal', async () => { const rule = { ...getRuleForSignalTesting(['keyword', 'const_keyword']), query: 'event.dataset: "dataset_name_1"', @@ -105,8 +104,7 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOpen.hits.hits.length).to.eql(8); }); - // TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source - it.skip('should copy the "dataset_name_1" from "event.dataset"', async () => { + it('should copy the "dataset_name_1" from "event.dataset"', async () => { const rule: EqlCreateSchema = { ...getRuleForSignalTesting(['keyword', 'const_keyword']), rule_id: 'eql-rule', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/runtime.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/runtime.ts index 94cc390e0e6e..0015a41f911d 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/runtime.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/runtime.ts @@ -23,7 +23,7 @@ import { export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - interface HostAlias { + interface Runtime { name: string; hostname: string; } @@ -47,19 +47,18 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((signal) => (signal._source.host as HostAlias).name); + const hits = signalsOpen.hits.hits.map((signal) => (signal._source.host as Runtime).name); expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); }); - // TODO: Make runtime fields able to be copied to where we can have ECS fields such as host.name filled out by them within the mapping directly - it.skip('should copy "runtime mapping" data from a source index into the signals index in the same position when the target is ECS compatible', async () => { + it('should copy "runtime mapping" data from a source index into the signals index in the same position when the target is ECS compatible', async () => { const rule = getRuleForSignalTesting(['runtime']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map( - (signal) => (signal._source.host_alias as HostAlias).hostname + (signal) => (signal._source.host as Runtime).hostname ); expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); }); @@ -81,33 +80,69 @@ export default ({ getService }: FtrProviderContext) => { ); }); - // TODO: Make the overrides of runtime fields override the host.name in this use case. - it.skip('should copy normal non-runtime data set from the source index into the signals index in the same position when the target is ECS compatible', async () => { + /** + * Note, this test shows that we do not shadow or overwrite runtime fields on-top of regular fields as we reduced + * risk with overwriting fields in the strategy we are currently using in detection engine. If you swap, change the strategies + * because we decide to overwrite "_source" values with "fields", then expect to change this test. + */ + it('should NOT copy normal non-runtime data set from the source index into the signals index in the same position when the target is ECS compatible', async () => { const rule = getRuleForSignalTesting(['runtime_conflicting_fields']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((signal) => (signal._source.host as HostAlias).name); + const hits = signalsOpen.hits.hits.map((signal) => signal._source.host); expect(hits).to.eql([ - 'I am the [host.name] field value which shadows the original host.name value', - 'I am the [host.name] field value which shadows the original host.name value', - 'I am the [host.name] field value which shadows the original host.name value', - 'I am the [host.name] field value which shadows the original host.name value', + [ + { + name: 'host name 1_1', + }, + { + name: 'host name 1_2', + }, + ], + [ + { + name: 'host name 2_1', + }, + { + name: 'host name 2_2', + }, + ], + [ + { + name: 'host name 3_1', + }, + { + name: 'host name 3_2', + }, + ], + [ + { + name: 'host name 4_1', + }, + { + name: 'host name 4_2', + }, + ], ]); }); - // TODO: Make runtime fields able to be copied to where we can have ECS fields such as host.name filled out by them within the mapping directly - it.skip('should copy "runtime mapping" data from a source index into the signals index in the same position when the target is ECS compatible', async () => { + /** + * Note, this test shows that we do NOT shadow or overwrite runtime fields on-top of regular fields when we detect those + * fields as arrays of objects since the objects are flattened in "fields" and we detect something already there so we skip + * this shadowed runtime data as it is ambiguous of where we would put it in the array. + */ + it('should NOT copy "runtime mapping" data from a source index into the signals index in the same position when the target is ECS compatible', async () => { const rule = getRuleForSignalTesting(['runtime_conflicting_fields']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map( - (signal) => (signal._source.host_alias as HostAlias).hostname + (signal) => (signal._source.host as Runtime).hostname ); - expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); + expect(hits).to.eql([undefined, undefined, undefined, undefined]); }); }); }); diff --git a/x-pack/test/functional/es_archives/security_solution/alias/data.json b/x-pack/test/functional/es_archives/security_solution/alias/data.json index a8bd64cb044e..f0f74a9094df 100644 --- a/x-pack/test/functional/es_archives/security_solution/alias/data.json +++ b/x-pack/test/functional/es_archives/security_solution/alias/data.json @@ -2,7 +2,7 @@ "type": "doc", "value": { "id": "1", - "index": "alias", + "index": "host_alias", "source": { "@timestamp": "2020-10-28T05:00:53.000Z", "host_alias": { @@ -17,7 +17,7 @@ "type": "doc", "value": { "id": "2", - "index": "alias", + "index": "host_alias", "source": { "@timestamp": "2020-10-28T05:01:53.000Z", "host_alias": { @@ -32,7 +32,7 @@ "type": "doc", "value": { "id": "3", - "index": "alias", + "index": "host_alias", "source": { "@timestamp": "2020-10-28T05:02:53.000Z", "host_alias": { @@ -47,7 +47,7 @@ "type": "doc", "value": { "id": "4", - "index": "alias", + "index": "host_alias", "source": { "@timestamp": "2020-10-28T05:03:53.000Z", "host_alias": { diff --git a/x-pack/test/functional/es_archives/security_solution/runtime_conflicting_fields/mappings.json b/x-pack/test/functional/es_archives/security_solution/runtime_conflicting_fields/mappings.json index 04a538a33295..2e34eae159a7 100644 --- a/x-pack/test/functional/es_archives/security_solution/runtime_conflicting_fields/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/runtime_conflicting_fields/mappings.json @@ -5,7 +5,7 @@ "mappings": { "dynamic": "strict", "runtime": { - "host_alias": { + "host.hostname": { "type": "keyword", "script": { "source": "emit(doc['host.name'].value)" @@ -98,6 +98,9 @@ "properties": { "name": { "type": "keyword" + }, + "hostname": { + "type": "keyword" } } } From f28bfa71ad1ef9803d9a5fa59fcbe70c50564373 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 28 Jun 2021 18:13:12 -0600 Subject: [PATCH 57/65] [Maps] Move edit tools to beta and remove experimental config flags (#103556) --- x-pack/plugins/maps/config.ts | 2 -- .../public/classes/layers/layer_wizard_registry.ts | 2 ++ .../public/classes/layers/load_layer_wizards.ts | 5 +---- .../layers/new_vector_layer_wizard/config.tsx | 4 +++- .../sources/es_search_source/es_search_source.tsx | 10 +--------- .../flyout_body/layer_wizard_select.tsx | 1 + x-pack/plugins/maps/server/index.ts | 1 - x-pack/plugins/maps/server/plugin.ts | 9 +-------- x-pack/plugins/maps/server/routes.js | 13 ++----------- x-pack/test/functional/config.js | 1 - 10 files changed, 11 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/maps/config.ts b/x-pack/plugins/maps/config.ts index 781967714be0..104ba0026354 100644 --- a/x-pack/plugins/maps/config.ts +++ b/x-pack/plugins/maps/config.ts @@ -10,7 +10,6 @@ import { schema, TypeOf } from '@kbn/config-schema'; export interface MapsConfigType { enabled: boolean; showMapVisualizationTypes: boolean; - enableDrawingFeature: boolean; showMapsInspectorAdapter: boolean; preserveDrawingBuffer: boolean; } @@ -18,7 +17,6 @@ export interface MapsConfigType { export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), showMapVisualizationTypes: schema.boolean({ defaultValue: false }), - enableDrawingFeature: schema.boolean({ defaultValue: false }), // flag used in functional testing showMapsInspectorAdapter: schema.boolean({ defaultValue: false }), // flag used in functional testing diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 824d9835380e..b14ba7cf693b 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -33,6 +33,7 @@ export type LayerWizard = { description: string; disabledReason?: string; getIsDisabled?: () => Promise | boolean; + isBeta?: boolean; icon: string | FunctionComponent; prerequisiteSteps?: Array<{ id: string; label: string }>; renderWizard(renderWizardArguments: RenderWizardArguments): ReactElement; @@ -54,6 +55,7 @@ export function registerLayerWizard(layerWizard: LayerWizard) { getIsDisabled: async () => { return false; }, + isBeta: false, ...layerWizard, }); } diff --git a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts index 2c7f09ce9dfe..3c86c57343a0 100644 --- a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts @@ -29,7 +29,6 @@ import { ObservabilityLayerWizardConfig } from './solution_layers/observability' import { SecurityLayerWizardConfig } from './solution_layers/security'; import { choroplethLayerWizardConfig } from './choropleth_layer_wizard'; import { newVectorLayerWizardConfig } from './new_vector_layer_wizard'; -import { getMapAppConfig } from '../../kibana_services'; let registered = false; export function registerLayerWizards() { @@ -39,9 +38,7 @@ export function registerLayerWizards() { // Registration order determines display order registerLayerWizard(uploadLayerWizardConfig); - if (getMapAppConfig().enableDrawingFeature) { - registerLayerWizard(newVectorLayerWizardConfig); - } + registerLayerWizard(newVectorLayerWizardConfig); registerLayerWizard(esDocumentsLayerWizardConfig); // @ts-ignore registerLayerWizard(choroplethLayerWizardConfig); diff --git a/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/config.tsx b/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/config.tsx index 2a0400c3d6be..c4464c8787c1 100644 --- a/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/config.tsx +++ b/x-pack/plugins/maps/public/classes/layers/new_vector_layer_wizard/config.tsx @@ -18,7 +18,8 @@ const ADD_VECTOR_DRAWING_LAYER = 'ADD_VECTOR_DRAWING_LAYER'; export const newVectorLayerWizardConfig: LayerWizard = { categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.newVectorLayerWizard.description', { - defaultMessage: 'Creates a new empty layer. Use this to add shapes to the map', + defaultMessage: + 'Create an empty layer. Use this to create documents by drawing shapes on the map', }), disabledReason: i18n.translate('xpack.maps.newVectorLayerWizard.disabledDesc', { defaultMessage: @@ -31,6 +32,7 @@ export const newVectorLayerWizardConfig: LayerWizard = { }); return !hasImportPermission; }, + isBeta: true, icon: DrawLayerIcon, prerequisiteSteps: [ { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 019c3c1b4943..343c366b548f 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -12,12 +12,7 @@ import { i18n } from '@kbn/i18n'; import { IFieldType, IndexPattern } from 'src/plugins/data/public'; import { GeoJsonProperties, Geometry, Position } from 'geojson'; import { AbstractESSource } from '../es_source'; -import { - getHttp, - getMapAppConfig, - getSearchService, - getTimeFilter, -} from '../../../kibana_services'; +import { getHttp, getSearchService, getTimeFilter } from '../../../kibana_services'; import { addFieldToDSL, getField, @@ -424,9 +419,6 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye } async supportsFeatureEditing(): Promise { - if (!getMapAppConfig().enableDrawingFeature) { - return false; - } await this.getIndexPattern(); if (!(this.indexPattern && this.indexPattern.title)) { return false; diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx index 5350b0e2ccf3..1e92dd16d9da 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx @@ -161,6 +161,7 @@ export class LayerWizardSelect extends Component { = { exposeToBrowser: { enabled: true, showMapVisualizationTypes: true, - enableDrawingFeature: true, showMapsInspectorAdapter: true, preserveDrawingBuffer: true, }, diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index b8676559a4e2..c5fc602864f9 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -170,14 +170,7 @@ export class MapsPlugin implements Plugin { lastLicenseId = license.uid; }); - initRoutes( - core, - () => lastLicenseId, - emsSettings, - this.kibanaVersion, - this._logger, - currentConfig.enableDrawingFeature - ); + initRoutes(core, () => lastLicenseId, emsSettings, this.kibanaVersion, this._logger); this._initHomeData(home, core.http.basePath.prepend, emsSettings); diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index 39ce9979870c..1b669a5bcdbe 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -54,14 +54,7 @@ const EMPTY_EMS_CLIENT = { addQueryParams() {}, }; -export async function initRoutes( - core, - getLicenseId, - emsSettings, - kbnVersion, - logger, - drawingFeatureEnabled -) { +export async function initRoutes(core, getLicenseId, emsSettings, kbnVersion, logger) { let emsClient; let lastLicenseId; const router = core.http.createRouter(); @@ -624,7 +617,5 @@ export async function initRoutes( } initMVTRoutes({ router, logger }); - if (drawingFeatureEnabled) { - initIndexingRoutes({ router, logger, dataPlugin }); - } + initIndexingRoutes({ router, logger, dataPlugin }); } diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 2c3a3c93e2a0..a79e51057c90 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -85,7 +85,6 @@ export default async function ({ readConfigFile }) { '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', '--xpack.maps.showMapsInspectorAdapter=true', '--xpack.maps.preserveDrawingBuffer=true', - '--xpack.maps.enableDrawingFeature=true', '--xpack.reporting.roles.enabled=false', // use the non-deprecated access control model for Reporting '--xpack.reporting.queue.pollInterval=3000', // make it explicitly the default '--xpack.reporting.csv.maxSizeBytes=2850', // small-ish limit for cutting off a 1999 byte report From ad3601c260b7e7e683c7976a6734604cec04b39f Mon Sep 17 00:00:00 2001 From: fgierlinger <2966031+fgierlinger@users.noreply.github.com> Date: Tue, 29 Jun 2021 02:17:26 +0200 Subject: [PATCH 58/65] fix: typo in time dropdown list (#103407) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/node_details/tabs/metrics/time_dropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx index c080cf279478..28e57992cfaf 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx @@ -20,7 +20,7 @@ export const TimeDropdown = (props: Props) => ( options={[ { text: i18n.translate('xpack.infra.nodeDetails.metrics.last15Minutes', { - defaultMessage: 'Last 15 mintues', + defaultMessage: 'Last 15 minutes', }), value: 15 * 60 * 1000, }, From 699c875b210c2451959c6b7d41116c83d51284b9 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 28 Jun 2021 19:18:14 -0500 Subject: [PATCH 59/65] [Workplace Search] Fix edge case API error (#103574) This PR fixes an edge case where a race condition mught cause the total_results from a federated content source to come back null from the server. This PR tells the server to expect null in those edge cases to prevent browser errors --- .../enterprise_search/server/routes/workplace_search/sources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 7e9d7d92742a..044393f65dc5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -25,7 +25,7 @@ const pageSchema = schema.object({ current: schema.nullable(schema.number()), size: schema.nullable(schema.number()), total_pages: schema.nullable(schema.number()), - total_results: schema.number(), + total_results: schema.nullable(schema.number()), }); const oauthConfigSchema = schema.object({ From aafcc473f3a947cc406fb3024767351f9c740ed7 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 28 Jun 2021 17:23:10 -0700 Subject: [PATCH 60/65] [Reporting] Reorganize UI components (#103571) --- x-pack/plugins/reporting/public/index.ts | 2 +- .../job_queue_client.test.mocks.ts | 2 +- .../reporting/public/lib/reporting_api_client.ts | 2 +- .../plugins/reporting/public/lib/stream_handler.ts | 2 +- .../__snapshots__/report_info_button.test.tsx.snap | 0 .../__snapshots__/report_listing.test.tsx.snap | 0 .../buttons/index.tsx => management/index.ts} | 0 .../{ => management}/mount_management_section.tsx | 14 +++++++------- .../report_delete_button.tsx | 6 +++--- .../report_diagnostic.tsx | 0 .../report_download_button.tsx | 4 ++-- .../buttons => management}/report_error_button.tsx | 6 +++--- .../report_info_button.test.tsx | 4 ++-- .../buttons => management}/report_info_button.tsx | 6 +++--- .../report_listing.test.tsx | 0 .../{components => management}/report_listing.tsx | 7 +------ x-pack/plugins/reporting/public/mocks.ts | 2 +- .../{components => notifier}/general_error.tsx | 0 .../public/{components => notifier}/index.ts | 6 ++---- .../job_completion_notifications.ts | 0 .../job_download_button.tsx | 0 .../{components => notifier}/job_failure.tsx | 0 .../{components => notifier}/job_success.tsx | 0 .../job_warning_formulas.tsx | 0 .../job_warning_max_size.tsx | 0 .../{components => notifier}/report_link.tsx | 0 x-pack/plugins/reporting/public/plugin.ts | 5 +++-- .../screen_capture_panel_content.test.tsx.snap | 0 .../panel_spinner.tsx | 0 .../share_context_menu/register_csv_reporting.tsx | 2 +- .../register_pdf_png_reporting.tsx | 2 +- .../reporting_panel_content.test.tsx | 0 .../reporting_panel_content.tsx | 0 .../reporting_panel_content_lazy.tsx | 2 +- .../screen_capture_panel_content.test.tsx | 0 .../screen_capture_panel_content.tsx | 0 .../screen_capture_panel_content_lazy.tsx | 2 +- .../shared/get_shared_components.tsx | 8 ++++---- .../shared/index.tsx => shared/index.ts} | 0 39 files changed, 39 insertions(+), 45 deletions(-) rename x-pack/plugins/reporting/public/{components => lib}/job_queue_client.test.mocks.ts (87%) rename x-pack/plugins/reporting/public/{components/buttons => management}/__snapshots__/report_info_button.test.tsx.snap (100%) rename x-pack/plugins/reporting/public/{components => management}/__snapshots__/report_listing.test.tsx.snap (100%) rename x-pack/plugins/reporting/public/{components/buttons/index.tsx => management/index.ts} (100%) rename x-pack/plugins/reporting/public/{ => management}/mount_management_section.tsx (78%) rename x-pack/plugins/reporting/public/{components/buttons => management}/report_delete_button.tsx (94%) rename x-pack/plugins/reporting/public/{components => management}/report_diagnostic.tsx (100%) rename x-pack/plugins/reporting/public/{components/buttons => management}/report_download_button.tsx (93%) rename x-pack/plugins/reporting/public/{components/buttons => management}/report_error_button.tsx (93%) rename x-pack/plugins/reporting/public/{components/buttons => management}/report_info_button.test.tsx (94%) rename x-pack/plugins/reporting/public/{components/buttons => management}/report_info_button.tsx (97%) rename x-pack/plugins/reporting/public/{components => management}/report_listing.test.tsx (100%) rename x-pack/plugins/reporting/public/{components => management}/report_listing.tsx (99%) rename x-pack/plugins/reporting/public/{components => notifier}/general_error.tsx (100%) rename x-pack/plugins/reporting/public/{components => notifier}/index.ts (81%) rename x-pack/plugins/reporting/public/{lib => notifier}/job_completion_notifications.ts (100%) rename x-pack/plugins/reporting/public/{components => notifier}/job_download_button.tsx (100%) rename x-pack/plugins/reporting/public/{components => notifier}/job_failure.tsx (100%) rename x-pack/plugins/reporting/public/{components => notifier}/job_success.tsx (100%) rename x-pack/plugins/reporting/public/{components => notifier}/job_warning_formulas.tsx (100%) rename x-pack/plugins/reporting/public/{components => notifier}/job_warning_max_size.tsx (100%) rename x-pack/plugins/reporting/public/{components => notifier}/report_link.tsx (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/__snapshots__/screen_capture_panel_content.test.tsx.snap (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/panel_spinner.tsx (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/reporting_panel_content.test.tsx (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/reporting_panel_content.tsx (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/reporting_panel_content_lazy.tsx (94%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/screen_capture_panel_content.test.tsx (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/screen_capture_panel_content.tsx (100%) rename x-pack/plugins/reporting/public/{components => share_context_menu}/screen_capture_panel_content_lazy.tsx (94%) rename x-pack/plugins/reporting/public/{components => }/shared/get_shared_components.tsx (79%) rename x-pack/plugins/reporting/public/{components/shared/index.tsx => shared/index.ts} (100%) diff --git a/x-pack/plugins/reporting/public/index.ts b/x-pack/plugins/reporting/public/index.ts index 7179a81664b6..6acdd8fb048e 100644 --- a/x-pack/plugins/reporting/public/index.ts +++ b/x-pack/plugins/reporting/public/index.ts @@ -7,9 +7,9 @@ import { PluginInitializerContext } from 'src/core/public'; import { getDefaultLayoutSelectors } from '../common'; -import { getSharedComponents } from './components'; import { ReportingAPIClient } from './lib/reporting_api_client'; import { ReportingPublicPlugin } from './plugin'; +import { getSharedComponents } from './shared'; export interface ReportingSetup { getDefaultLayoutSelectors: typeof getDefaultLayoutSelectors; diff --git a/x-pack/plugins/reporting/public/components/job_queue_client.test.mocks.ts b/x-pack/plugins/reporting/public/lib/job_queue_client.test.mocks.ts similarity index 87% rename from x-pack/plugins/reporting/public/components/job_queue_client.test.mocks.ts rename to x-pack/plugins/reporting/public/lib/job_queue_client.test.mocks.ts index 9426ec8d8751..6c66eada6a82 100644 --- a/x-pack/plugins/reporting/public/components/job_queue_client.test.mocks.ts +++ b/x-pack/plugins/reporting/public/lib/job_queue_client.test.mocks.ts @@ -15,4 +15,4 @@ export const mockAPIClient = { downloadReport: jest.fn(), }; -jest.mock('../lib/reporting_api_client', () => mockAPIClient); +jest.mock('./reporting_api_client', () => mockAPIClient); diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index 92604de0f471..4ce9e8760f21 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -22,7 +22,7 @@ import { ReportDocument, ReportSource, } from '../../common/types'; -import { add } from './job_completion_notifications'; +import { add } from '../notifier/job_completion_notifications'; export interface JobQueueEntry { _id: string; diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 961345aeb592..53191cacb5ba 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -17,7 +17,7 @@ import { getSuccessToast, getWarningFormulasToast, getWarningMaxSizeToast, -} from '../components'; +} from '../notifier'; import { ReportingAPIClient } from './reporting_api_client'; function updateStored(jobIds: JobId[]): void { diff --git a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap similarity index 100% rename from x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap rename to x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap diff --git a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap similarity index 100% rename from x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap rename to x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap diff --git a/x-pack/plugins/reporting/public/components/buttons/index.tsx b/x-pack/plugins/reporting/public/management/index.ts similarity index 100% rename from x-pack/plugins/reporting/public/components/buttons/index.tsx rename to x-pack/plugins/reporting/public/management/index.ts diff --git a/x-pack/plugins/reporting/public/mount_management_section.tsx b/x-pack/plugins/reporting/public/management/mount_management_section.tsx similarity index 78% rename from x-pack/plugins/reporting/public/mount_management_section.tsx rename to x-pack/plugins/reporting/public/management/mount_management_section.tsx index bc165badae71..eb1057a9bdfc 100644 --- a/x-pack/plugins/reporting/public/mount_management_section.tsx +++ b/x-pack/plugins/reporting/public/management/mount_management_section.tsx @@ -5,16 +5,16 @@ * 2.0. */ +import { I18nProvider } from '@kbn/i18n/react'; import * as React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { I18nProvider } from '@kbn/i18n/react'; -import { CoreSetup, CoreStart } from 'src/core/public'; import { Observable } from 'rxjs'; -import { ReportListing } from './components/report_listing'; -import { ManagementAppMountParams } from '../../../../src/plugins/management/public'; -import { ILicense } from '../../licensing/public'; -import { ClientConfigType } from './plugin'; -import { ReportingAPIClient } from './lib/reporting_api_client'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; +import { ILicense } from '../../../licensing/public'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ClientConfigType } from '../plugin'; +import { ReportListing } from './report_listing'; export async function mountManagementSection( coreSetup: CoreSetup, diff --git a/x-pack/plugins/reporting/public/components/buttons/report_delete_button.tsx b/x-pack/plugins/reporting/public/management/report_delete_button.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/buttons/report_delete_button.tsx rename to x-pack/plugins/reporting/public/management/report_delete_button.tsx index cd432758fa76..dfb411fc195e 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_delete_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_delete_button.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { EuiConfirmModal, EuiButton } from '@elastic/eui'; -import React, { PureComponent, Fragment } from 'react'; -import { Job, Props as ListingProps } from '../report_listing'; +import { EuiButton, EuiConfirmModal } from '@elastic/eui'; +import React, { Fragment, PureComponent } from 'react'; +import { Job, Props as ListingProps } from './report_listing'; type DeleteFn = () => Promise; type Props = { jobsToDelete: Job[]; performDelete: DeleteFn } & ListingProps; diff --git a/x-pack/plugins/reporting/public/components/report_diagnostic.tsx b/x-pack/plugins/reporting/public/management/report_diagnostic.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/report_diagnostic.tsx rename to x-pack/plugins/reporting/public/management/report_diagnostic.tsx diff --git a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx b/x-pack/plugins/reporting/public/management/report_download_button.tsx similarity index 93% rename from x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx rename to x-pack/plugins/reporting/public/management/report_download_button.tsx index a5e57dafc286..78022b85e2ff 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_download_button.tsx @@ -7,8 +7,8 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; -import { JOB_STATUSES } from '../../../common/constants'; -import { Job as ListingJob, Props as ListingProps } from '../report_listing'; +import { JOB_STATUSES } from '../../common/constants'; +import { Job as ListingJob, Props as ListingProps } from './report_listing'; type Props = { record: ListingJob } & ListingProps; diff --git a/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx b/x-pack/plugins/reporting/public/management/report_error_button.tsx similarity index 93% rename from x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx rename to x-pack/plugins/reporting/public/management/report_error_button.tsx index b34463b61253..0ebdf5ca60b5 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_error_button.tsx @@ -8,9 +8,9 @@ import { EuiButtonIcon, EuiCallOut, EuiPopover } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component } from 'react'; -import { JOB_STATUSES } from '../../../common/constants'; -import { JobContent, ReportingAPIClient } from '../../lib/reporting_api_client'; -import { Job as ListingJob } from '../report_listing'; +import { JOB_STATUSES } from '../../common/constants'; +import { JobContent, ReportingAPIClient } from '../lib/reporting_api_client'; +import { Job as ListingJob } from './report_listing'; interface Props { intl: InjectedIntl; diff --git a/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx b/x-pack/plugins/reporting/public/management/report_info_button.test.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx rename to x-pack/plugins/reporting/public/management/report_info_button.test.tsx index 785f49646110..119856042a32 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx +++ b/x-pack/plugins/reporting/public/management/report_info_button.test.tsx @@ -9,9 +9,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { ReportInfoButton } from './report_info_button'; -jest.mock('../../lib/reporting_api_client'); +jest.mock('../lib/reporting_api_client'); -import { ReportingAPIClient } from '../../lib/reporting_api_client'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; const httpSetup = {} as any; const apiClient = new ReportingAPIClient(httpSetup); diff --git a/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx b/x-pack/plugins/reporting/public/management/report_info_button.tsx similarity index 97% rename from x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx rename to x-pack/plugins/reporting/public/management/report_info_button.tsx index 7f2d5b6adcc3..719f1ff341da 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_info_button.tsx @@ -18,9 +18,9 @@ import { } from '@elastic/eui'; import { get } from 'lodash'; import React, { Component, Fragment } from 'react'; -import { USES_HEADLESS_JOB_TYPES } from '../../../common/constants'; -import { ReportApiJSON } from '../../../common/types'; -import { ReportingAPIClient } from '../../lib/reporting_api_client'; +import { USES_HEADLESS_JOB_TYPES } from '../../common/constants'; +import { ReportApiJSON } from '../../common/types'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface Props { jobId: string; diff --git a/x-pack/plugins/reporting/public/components/report_listing.test.tsx b/x-pack/plugins/reporting/public/management/report_listing.test.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/report_listing.test.tsx rename to x-pack/plugins/reporting/public/management/report_listing.test.tsx diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx similarity index 99% rename from x-pack/plugins/reporting/public/components/report_listing.tsx rename to x-pack/plugins/reporting/public/management/report_listing.tsx index 618c91fba071..fffa952be6cb 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -28,12 +28,7 @@ import { durationToNumber } from '../../common/schema_utils'; import { checkLicense } from '../lib/license_check'; import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; import { ClientConfigType } from '../plugin'; -import { - ReportDeleteButton, - ReportDownloadButton, - ReportErrorButton, - ReportInfoButton, -} from './buttons'; +import { ReportDeleteButton, ReportDownloadButton, ReportErrorButton, ReportInfoButton } from './'; import { ReportDiagnostic } from './report_diagnostic'; export interface Job { diff --git a/x-pack/plugins/reporting/public/mocks.ts b/x-pack/plugins/reporting/public/mocks.ts index 414d1b0ae70f..a6b6d835499c 100644 --- a/x-pack/plugins/reporting/public/mocks.ts +++ b/x-pack/plugins/reporting/public/mocks.ts @@ -8,7 +8,7 @@ import { coreMock } from 'src/core/public/mocks'; import { ReportingSetup } from '.'; import { getDefaultLayoutSelectors } from '../common'; -import { getSharedComponents } from './components/shared'; +import { getSharedComponents } from './shared'; type Setup = jest.Mocked; diff --git a/x-pack/plugins/reporting/public/components/general_error.tsx b/x-pack/plugins/reporting/public/notifier/general_error.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/general_error.tsx rename to x-pack/plugins/reporting/public/notifier/general_error.tsx diff --git a/x-pack/plugins/reporting/public/components/index.ts b/x-pack/plugins/reporting/public/notifier/index.ts similarity index 81% rename from x-pack/plugins/reporting/public/components/index.ts rename to x-pack/plugins/reporting/public/notifier/index.ts index b8cccda2a661..b44f1e916974 100644 --- a/x-pack/plugins/reporting/public/components/index.ts +++ b/x-pack/plugins/reporting/public/notifier/index.ts @@ -5,10 +5,8 @@ * 2.0. */ -export { getSuccessToast } from './job_success'; export { getFailureToast } from './job_failure'; +export { getGeneralErrorToast } from './general_error'; +export { getSuccessToast } from './job_success'; export { getWarningFormulasToast } from './job_warning_formulas'; export { getWarningMaxSizeToast } from './job_warning_max_size'; -export { getGeneralErrorToast } from './general_error'; -export { ScreenCapturePanelContent } from './screen_capture_panel_content'; -export { getSharedComponents } from './shared'; diff --git a/x-pack/plugins/reporting/public/lib/job_completion_notifications.ts b/x-pack/plugins/reporting/public/notifier/job_completion_notifications.ts similarity index 100% rename from x-pack/plugins/reporting/public/lib/job_completion_notifications.ts rename to x-pack/plugins/reporting/public/notifier/job_completion_notifications.ts diff --git a/x-pack/plugins/reporting/public/components/job_download_button.tsx b/x-pack/plugins/reporting/public/notifier/job_download_button.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/job_download_button.tsx rename to x-pack/plugins/reporting/public/notifier/job_download_button.tsx diff --git a/x-pack/plugins/reporting/public/components/job_failure.tsx b/x-pack/plugins/reporting/public/notifier/job_failure.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/job_failure.tsx rename to x-pack/plugins/reporting/public/notifier/job_failure.tsx diff --git a/x-pack/plugins/reporting/public/components/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/job_success.tsx rename to x-pack/plugins/reporting/public/notifier/job_success.tsx diff --git a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/job_warning_formulas.tsx rename to x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx diff --git a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/job_warning_max_size.tsx rename to x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx diff --git a/x-pack/plugins/reporting/public/components/report_link.tsx b/x-pack/plugins/reporting/public/notifier/report_link.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/report_link.tsx rename to x-pack/plugins/reporting/public/notifier/report_link.tsx diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index 577732fdb139..a2881af90207 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -29,10 +29,11 @@ import { constants, getDefaultLayoutSelectors } from '../common'; import { durationToNumber } from '../common/schema_utils'; import { JobId, JobSummarySet } from '../common/types'; import { ReportingSetup, ReportingStart } from './'; -import { getGeneralErrorToast, getSharedComponents } from './components'; import { ReportingAPIClient } from './lib/reporting_api_client'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; +import { getGeneralErrorToast } from './notifier'; import { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action'; +import { getSharedComponents } from './shared'; import { ReportingCsvShareProvider } from './share_context_menu/register_csv_reporting'; import { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting'; @@ -150,7 +151,7 @@ export class ReportingPublicPlugin params.setBreadcrumbs([{ text: this.breadcrumbText }]); const [[start], { mountManagementSection }] = await Promise.all([ getStartServices(), - import('./mount_management_section'), + import('./management/mount_management_section'), ]); return await mountManagementSection( core, diff --git a/x-pack/plugins/reporting/public/components/__snapshots__/screen_capture_panel_content.test.tsx.snap b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap similarity index 100% rename from x-pack/plugins/reporting/public/components/__snapshots__/screen_capture_panel_content.test.tsx.snap rename to x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap diff --git a/x-pack/plugins/reporting/public/components/panel_spinner.tsx b/x-pack/plugins/reporting/public/share_context_menu/panel_spinner.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/panel_spinner.tsx rename to x-pack/plugins/reporting/public/share_context_menu/panel_spinner.tsx diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 7fe5268fc991..7165fcf6f868 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -16,9 +16,9 @@ import type { ShareContext } from '../../../../../src/plugins/share/public'; import type { LicensingPluginSetup } from '../../../licensing/public'; import { CSV_JOB_TYPE } from '../../common/constants'; import type { JobParamsCSV } from '../../server/export_types/csv_searchsource/types'; -import { ReportingPanelContent } from '../components/reporting_panel_content_lazy'; import { checkLicense } from '../lib/license_check'; import type { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ReportingPanelContent } from './reporting_panel_content_lazy'; export const ReportingCsvShareProvider = ({ apiClient, diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index 42f6ee5fcb89..eb80f64be55e 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -16,9 +16,9 @@ import type { LicensingPluginSetup } from '../../../licensing/public'; import type { LayoutParams } from '../../common/types'; import type { JobParamsPNG } from '../../server/export_types/png/types'; import type { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; -import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content_lazy'; import { checkLicense } from '../lib/license_check'; import type { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ScreenCapturePanelContent } from './screen_capture_panel_content_lazy'; interface JobParamsProviderOptions { shareableUrl: string; diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.test.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.test.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/reporting_panel_content.test.tsx rename to x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.test.tsx diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/reporting_panel_content.tsx rename to x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content_lazy.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content_lazy.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/reporting_panel_content_lazy.tsx rename to x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content_lazy.tsx index 8a937f6d2eb3..a231d7aa881d 100644 --- a/x-pack/plugins/reporting/public/components/reporting_panel_content_lazy.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content_lazy.tsx @@ -6,7 +6,7 @@ */ import * as React from 'react'; -import { lazy, Suspense, FC } from 'react'; +import { FC, lazy, Suspense } from 'react'; import { PanelSpinner } from './panel_spinner'; import type { Props } from './reporting_panel_content'; diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.test.tsx b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.test.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/screen_capture_panel_content.test.tsx rename to x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.test.tsx diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx rename to x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.tsx diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content_lazy.tsx b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content_lazy.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/screen_capture_panel_content_lazy.tsx rename to x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content_lazy.tsx index 253b4fb88dcd..a162dd749ff0 100644 --- a/x-pack/plugins/reporting/public/components/screen_capture_panel_content_lazy.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content_lazy.tsx @@ -6,7 +6,7 @@ */ import * as React from 'react'; -import { lazy, Suspense, FC } from 'react'; +import { FC, lazy, Suspense } from 'react'; import { PanelSpinner } from './panel_spinner'; import type { Props } from './screen_capture_panel_content'; diff --git a/x-pack/plugins/reporting/public/components/shared/get_shared_components.tsx b/x-pack/plugins/reporting/public/shared/get_shared_components.tsx similarity index 79% rename from x-pack/plugins/reporting/public/components/shared/get_shared_components.tsx rename to x-pack/plugins/reporting/public/shared/get_shared_components.tsx index 12d70c870197..87ddf0cfdb38 100644 --- a/x-pack/plugins/reporting/public/components/shared/get_shared_components.tsx +++ b/x-pack/plugins/reporting/public/shared/get_shared_components.tsx @@ -7,10 +7,10 @@ import { CoreSetup } from 'kibana/public'; import React from 'react'; -import { ReportingAPIClient } from '../..'; -import { PDF_REPORT_TYPE } from '../../../common/constants'; -import type { Props as PanelPropsScreenCapture } from '../screen_capture_panel_content'; -import { ScreenCapturePanelContent } from '../screen_capture_panel_content_lazy'; +import { ReportingAPIClient } from '../'; +import { PDF_REPORT_TYPE } from '../../common/constants'; +import type { Props as PanelPropsScreenCapture } from '../share_context_menu/screen_capture_panel_content'; +import { ScreenCapturePanelContent } from '../share_context_menu/screen_capture_panel_content_lazy'; interface IncludeOnCloseFn { onClose: () => void; diff --git a/x-pack/plugins/reporting/public/components/shared/index.tsx b/x-pack/plugins/reporting/public/shared/index.ts similarity index 100% rename from x-pack/plugins/reporting/public/components/shared/index.tsx rename to x-pack/plugins/reporting/public/shared/index.ts From 633649460a59931eeb3f459da5e3eb334afdc83d Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 28 Jun 2021 17:25:24 -0700 Subject: [PATCH 61/65] [Enterprise Search] Improve flash messages screen reader UX (#103412) * Remove role region on flash messages - just `aria-live` is enough for screen readers to read it out, and `role` was causing "Flash messages" to get read out loud repeatedly between page navigation even when empty which was annoying and not good * Further a11y attribute recommendations from @myasonik --- .../shared/flash_messages/flash_messages.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx index ba42b89d6ab5..a96a179bd58c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx @@ -10,7 +10,6 @@ import React, { Fragment } from 'react'; import { useValues, useActions } from 'kea'; import { EuiCallOut, EuiSpacer, EuiGlobalToastList } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FLASH_MESSAGE_TYPES, DEFAULT_TOAST_TIMEOUT } from './constants'; import { FlashMessagesLogic } from './flash_messages_logic'; @@ -19,14 +18,7 @@ export const FlashMessages: React.FC = ({ children }) => { const { messages } = useValues(FlashMessagesLogic); return ( -
+
{messages.map(({ type, message, description }, index) => ( Date: Mon, 28 Jun 2021 20:35:27 -0400 Subject: [PATCH 62/65] [Alerting] Enable rule import/export and allow rule types to exclude themselves from export (#102999) * Removing feature flag changes * Adding isExportable flag to rule type definition * Adding isExportable flag to rule type definition * Adding isExportable flag to rule type definition * Filtering rule on export by rule type isExportable flag * Fixing types * Adding docs * Fix condition when exportCount is 0 * Unit test for fix condition when exportCount is 0 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/api/alerting/legacy/list.asciidoc | 4 + docs/api/alerting/list_rule_types.asciidoc | 4 + .../alerting/create-and-manage-rules.asciidoc | 19 ++ .../server/saved_objects/routes/utils.test.ts | 16 ++ src/core/server/saved_objects/routes/utils.ts | 2 +- .../server/alert_types/always_firing.ts | 1 + .../server/alert_types/astros.ts | 1 + x-pack/plugins/alerting/README.md | 2 + x-pack/plugins/alerting/common/alert_type.ts | 1 + .../plugins/alerting/public/alert_api.test.ts | 3 + .../alert_navigation_registry.test.ts | 1 + .../server/alert_type_registry.test.ts | 16 ++ .../alerting/server/alert_type_registry.ts | 3 + .../alerts_client/tests/aggregate.test.ts | 2 + .../server/alerts_client/tests/create.test.ts | 1 + .../server/alerts_client/tests/find.test.ts | 2 + .../server/alerts_client/tests/lib.ts | 1 + .../tests/list_alert_types.test.ts | 5 + .../server/alerts_client/tests/update.test.ts | 3 + .../alerts_client_conflict_retries.test.ts | 2 + .../alerting_authorization.test.ts | 18 ++ .../alerting_authorization_kuery.test.ts | 10 + x-pack/plugins/alerting/server/config.test.ts | 1 - x-pack/plugins/alerting/server/config.ts | 1 - .../alerting/server/health/get_state.test.ts | 8 - .../alerting/server/lib/license_state.test.ts | 2 + x-pack/plugins/alerting/server/plugin.test.ts | 72 +----- x-pack/plugins/alerting/server/plugin.ts | 9 +- .../routes/legacy/list_alert_types.test.ts | 4 + .../alerting/server/routes/rule_types.test.ts | 5 + .../alerting/server/routes/rule_types.ts | 2 + .../alerting/server/saved_objects/index.ts | 111 +++++----- .../saved_objects/is_rule_exportable.test.ts | 208 ++++++++++++++++++ .../saved_objects/is_rule_exportable.ts | 33 +++ .../create_execution_handler.test.ts | 1 + .../server/task_runner/task_runner.test.ts | 1 + .../task_runner/task_runner_factory.test.ts | 1 + x-pack/plugins/alerting/server/types.ts | 1 + x-pack/plugins/apm/common/alert_types.ts | 5 + .../alerts/register_error_count_alert_type.ts | 1 + ...egister_transaction_duration_alert_type.ts | 1 + ...transaction_duration_anomaly_alert_type.ts | 1 + ...ister_transaction_error_rate_alert_type.ts | 1 + ...r_inventory_metric_threshold_alert_type.ts | 1 + .../register_log_threshold_alert_type.ts | 1 + .../register_metric_anomaly_alert_type.ts | 1 + .../register_metric_threshold_alert_type.ts | 1 + .../register_anomaly_detection_alert_type.ts | 1 + .../monitoring/server/alerts/base_alert.ts | 1 + .../utils/create_lifecycle_rule_type.test.ts | 1 + .../rules_notification_alert_type.ts | 1 + .../detection_engine/reference_rules/eql.ts | 1 + .../detection_engine/reference_rules/ml.ts | 1 + .../detection_engine/reference_rules/query.ts | 1 + .../reference_rules/threshold.ts | 1 + .../signals/signal_rule_alert_type.ts | 1 + .../server/alert_types/es_query/alert_type.ts | 1 + .../alert_types/geo_containment/alert_type.ts | 1 + .../alert_types/index_threshold/alert_type.ts | 1 + .../server/lib/alerts/duration_anomaly.ts | 1 + .../uptime/server/lib/alerts/status_check.ts | 1 + .../plugins/uptime/server/lib/alerts/tls.ts | 1 + .../uptime/server/lib/alerts/tls_legacy.ts | 1 + .../plugins/alerts/server/alert_types.ts | 13 ++ .../alerts_restricted/server/alert_types.ts | 2 + .../tests/alerting/rule_types.ts | 2 + .../spaces_only/tests/alerting/rule_types.ts | 2 + .../fixtures/plugins/alerts/server/plugin.ts | 3 + 68 files changed, 489 insertions(+), 139 deletions(-) create mode 100644 x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts diff --git a/docs/api/alerting/legacy/list.asciidoc b/docs/api/alerting/legacy/list.asciidoc index be37be36cd0e..07307797c422 100644 --- a/docs/api/alerting/legacy/list.asciidoc +++ b/docs/api/alerting/legacy/list.asciidoc @@ -80,6 +80,7 @@ The API returns the following: }, "producer":"stackAlerts", "minimumLicenseRequired":"basic", + "isExportable":true, "enabledInLicense":true, "authorizedConsumers":{ "alerts":{ @@ -113,6 +114,9 @@ Each alert type contains the following properties: | `minimumLicenseRequired` | The license required to use the alert type. +| `isExportable` +| Whether the rule type is exportable through the Saved Objects Management UI. + | `enabledInLicense` | Whether the alert type is enabled or disabled based on the license. diff --git a/docs/api/alerting/list_rule_types.asciidoc b/docs/api/alerting/list_rule_types.asciidoc index 31c8416e7505..21ace9f3105c 100644 --- a/docs/api/alerting/list_rule_types.asciidoc +++ b/docs/api/alerting/list_rule_types.asciidoc @@ -82,6 +82,7 @@ The API returns the following: }, "producer":"stackAlerts", "minimum_license_required":"basic", + "is_exportable":true, "enabled_in_license":true, "authorized_consumers":{ "alerts":{ @@ -115,6 +116,9 @@ Each rule type contains the following properties: | `minimum_license_required` | The license required to use the rule type. +| `is_exportable` +| Whether the rule type is exportable through the Saved Objects Management UI. + | `enabled_in_license` | Whether the rule type is enabled or disabled based on the license. diff --git a/docs/user/alerting/create-and-manage-rules.asciidoc b/docs/user/alerting/create-and-manage-rules.asciidoc index af6714aef662..cc91ebcd99be 100644 --- a/docs/user/alerting/create-and-manage-rules.asciidoc +++ b/docs/user/alerting/create-and-manage-rules.asciidoc @@ -152,6 +152,25 @@ You can perform these operations in bulk by multi-selecting rules, and then clic [role="screenshot"] image:images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, enable/disable, and delete in bulk,width=75%] +[float] +[[importing-and-exporting-rules]] +=== Import and export rules + +To import and export rules, use the <>. + +[NOTE] +============================================== +Some rule types cannot be exported through this interface: + +**Security rules** can be imported and exported using the {security-guide}/rules-ui-management.html#import-export-rules-ui[Security UI]. + +**Stack monitoring rules** are <> for you and therefore cannot be managed via the Saved Objects Management UI. +============================================== + +Rules are disabled on export. You are prompted to re-enable rule on successful import. +[role="screenshot"] +image::images/rules-imported-banner.png[Rules import banner, width=50%] + [float] [[rule-details]] === Drilldown to rule details diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index 623d2dcc71fa..2127352e4c60 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -101,6 +101,22 @@ describe('createSavedObjectsStreamFromNdJson', () => { }, ]); }); + + it('handles an ndjson stream that only contains excluded saved objects', async () => { + const savedObjectsStream = await createSavedObjectsStreamFromNdJson( + new Readable({ + read() { + this.push( + '{"excludedObjects":[{"id":"foo","reason":"excluded","type":"foo-type"}],"excludedObjectsCount":1,"exportedCount":0,"missingRefCount":0,"missingReferences":[]}\n' + ); + this.push(null); + }, + }) + ); + + const result = await readStreamToCompletion(savedObjectsStream); + expect(result).toEqual([]); + }); }); describe('validateTypes', () => { diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index e933badfe80f..47996847a838 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -32,7 +32,7 @@ export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) } }), createFilterStream( - (obj) => !!obj && !(obj as SavedObjectsExportResultDetails).exportedCount + (obj) => !!obj && (obj as SavedObjectsExportResultDetails).exportedCount === undefined ), createConcatStream([]), ]); diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index 6e9ec0d367c9..f056c292b018 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -53,6 +53,7 @@ export const alertType: AlertType< ], defaultActionGroupId: DEFAULT_ACTION_GROUP, minimumLicenseRequired: 'basic', + isExportable: true, async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE, thresholds }, diff --git a/x-pack/examples/alerting_example/server/alert_types/astros.ts b/x-pack/examples/alerting_example/server/alert_types/astros.ts index 45ea6b48bf6f..8f9a29351830 100644 --- a/x-pack/examples/alerting_example/server/alert_types/astros.ts +++ b/x-pack/examples/alerting_example/server/alert_types/astros.ts @@ -51,6 +51,7 @@ export const alertType: AlertType< name: 'People In Space Right Now', actionGroups: [{ id: 'default', name: 'default' }], minimumLicenseRequired: 'basic', + isExportable: true, defaultActionGroupId: 'default', recoveryActionGroup: { id: 'hasLandedBackOnEarth', diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 9d314cc048b7..62d2f2b57b8e 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -118,6 +118,7 @@ The following table describes the properties of the `options` object. |executor|This is where the code for the rule type lives. This is a function to be called when executing a rule on an interval basis. For full details, see the executor section below.|Function| |producer|The id of the application producing this rule type.|string| |minimumLicenseRequired|The value of a minimum license. Most of the rules are licensed as "basic".|string| +|isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean| ### Executor @@ -262,6 +263,7 @@ const myRuleType: AlertType< ], }, minimumLicenseRequired: 'basic', + isExportable: true, async executor({ alertId, startedAt, diff --git a/x-pack/plugins/alerting/common/alert_type.ts b/x-pack/plugins/alerting/common/alert_type.ts index e39c6d0a66f6..e56034a4c41f 100644 --- a/x-pack/plugins/alerting/common/alert_type.ts +++ b/x-pack/plugins/alerting/common/alert_type.ts @@ -20,6 +20,7 @@ export interface AlertType< defaultActionGroupId: ActionGroupIds; producer: string; minimumLicenseRequired: LicenseType; + isExportable: boolean; } export interface ActionGroup { diff --git a/x-pack/plugins/alerting/public/alert_api.test.ts b/x-pack/plugins/alerting/public/alert_api.test.ts index 023ea255e1c4..dd2f7d167c1c 100644 --- a/x-pack/plugins/alerting/public/alert_api.test.ts +++ b/x-pack/plugins/alerting/public/alert_api.test.ts @@ -24,6 +24,7 @@ describe('loadAlertTypes', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }, @@ -49,6 +50,7 @@ describe('loadAlertType', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }; @@ -71,6 +73,7 @@ describe('loadAlertType', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }; diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts index 7eb599631138..e7e311902d08 100644 --- a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts @@ -20,6 +20,7 @@ const mockAlertType = (id: string): AlertType => ({ defaultActionGroupId: 'default', producer: 'alerts', minimumLicenseRequired: 'basic', + isExportable: true, }); describe('AlertNavigationRegistry', () => { diff --git a/x-pack/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/plugins/alerting/server/alert_type_registry.test.ts index 7f34760c7319..63e381bc66c0 100644 --- a/x-pack/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/alert_type_registry.test.ts @@ -47,6 +47,7 @@ describe('has()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }); @@ -67,6 +68,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }; @@ -99,6 +101,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }; @@ -129,6 +132,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }; @@ -159,6 +163,7 @@ describe('register()', () => { executor: jest.fn(), producer: 'alerts', minimumLicenseRequired: 'basic', + isExportable: true, }; const registry = new AlertTypeRegistry(alertTypeRegistryParams); registry.register(alertType); @@ -203,6 +208,7 @@ describe('register()', () => { }, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }; @@ -227,6 +233,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }; @@ -257,6 +264,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }; @@ -279,6 +287,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }); @@ -294,6 +303,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }) @@ -315,6 +325,7 @@ describe('get()', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, executor: jest.fn(), producer: 'alerts', }); @@ -339,6 +350,7 @@ describe('get()', () => { "defaultActionGroupId": "default", "executor": [MockFunction], "id": "test", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "Test", "producer": "alerts", @@ -377,6 +389,7 @@ describe('list()', () => { }, ], defaultActionGroupId: 'testActionGroup', + isExportable: true, minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', @@ -403,6 +416,7 @@ describe('list()', () => { "defaultActionGroupId": "testActionGroup", "enabledInLicense": false, "id": "test", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "Test", "producer": "alerts", @@ -467,6 +481,7 @@ describe('ensureAlertTypeEnabled', () => { defaultActionGroupId: 'default', executor: jest.fn(), producer: 'alerts', + isExportable: true, minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, }); @@ -497,6 +512,7 @@ function alertTypeWithVariables( name: `${id}-name`, actionGroups: [], defaultActionGroupId: id, + isExportable: true, minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', diff --git a/x-pack/plugins/alerting/server/alert_type_registry.ts b/x-pack/plugins/alerting/server/alert_type_registry.ts index 21feb7692679..64fca58c25e6 100644 --- a/x-pack/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/plugins/alerting/server/alert_type_registry.ts @@ -46,6 +46,7 @@ export interface RegistryAlertType | 'actionVariables' | 'producer' | 'minimumLicenseRequired' + | 'isExportable' > { id: string; enabledInLicense: boolean; @@ -250,6 +251,7 @@ export class AlertTypeRegistry { actionVariables, producer, minimumLicenseRequired, + isExportable, }, ]: [string, UntypedNormalizedAlertType]) => ({ id, @@ -260,6 +262,7 @@ export class AlertTypeRegistry { actionVariables, producer, minimumLicenseRequired, + isExportable, enabledInLicense: !!this.licenseState.getLicenseCheckForAlertType( id, name, diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/aggregate.test.ts index bf966d38f6bc..611ff23e4625 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/aggregate.test.ts @@ -58,6 +58,7 @@ describe('aggregate()', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myType', name: 'myType', @@ -110,6 +111,7 @@ describe('aggregate()', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', authorizedConsumers: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts index 793357215d38..e231d1e3c27a 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts @@ -1293,6 +1293,7 @@ describe('create()', () => { }), }, minimumLicenseRequired: 'basic', + isExportable: true, async executor() {}, producer: 'alerts', }); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts index fe788cd43bc2..5ec39681a758 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts @@ -67,6 +67,7 @@ describe('find()', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, id: 'myType', name: 'myType', producer: 'myApp', @@ -126,6 +127,7 @@ describe('find()', () => { recoveryActionGroup: RecoveredActionGroup, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, producer: 'alerts', authorizedConsumers: { myApp: { read: true, all: true }, diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/lib.ts b/x-pack/plugins/alerting/server/alerts_client/tests/lib.ts index e4cd24ca7e49..e0f4f9f6da0f 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/lib.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/lib.ts @@ -88,6 +88,7 @@ export function getBeforeSetup( recoveryActionGroup: RecoveredActionGroup, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor() {}, producer: 'alerts', })); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/list_alert_types.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/list_alert_types.test.ts index 9fe33996b9ed..0f849423409d 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/list_alert_types.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/list_alert_types.test.ts @@ -58,6 +58,7 @@ describe('listAlertTypes', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'alertingAlertType', name: 'alertingAlertType', @@ -69,6 +70,7 @@ describe('listAlertTypes', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -110,6 +112,7 @@ describe('listAlertTypes', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myType', name: 'myType', @@ -122,6 +125,7 @@ describe('listAlertTypes', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', enabledInLicense: true, @@ -139,6 +143,7 @@ describe('listAlertTypes', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', authorizedConsumers: { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts index 350c9ed31298..2de56d20702f 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts @@ -127,6 +127,7 @@ describe('update()', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', @@ -773,6 +774,7 @@ describe('update()', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, validate: { params: schema.object({ @@ -1096,6 +1098,7 @@ describe('update()', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', diff --git a/x-pack/plugins/alerting/server/alerts_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/alerts_client_conflict_retries.test.ts index 98ad427d0c37..e45b3513eef2 100644 --- a/x-pack/plugins/alerting/server/alerts_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client_conflict_retries.test.ts @@ -335,6 +335,7 @@ beforeEach(() => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', @@ -346,6 +347,7 @@ beforeEach(() => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts index 2227e0cecd0a..c07148f03c68 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts @@ -203,6 +203,7 @@ beforeEach(() => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'myApp', @@ -892,6 +893,7 @@ describe('AlertingAuthorization', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', @@ -903,6 +905,7 @@ describe('AlertingAuthorization', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -914,6 +917,7 @@ describe('AlertingAuthorization', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'mySecondAppAlertType', name: 'mySecondAppAlertType', @@ -1242,6 +1246,7 @@ describe('AlertingAuthorization', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', @@ -1253,6 +1258,7 @@ describe('AlertingAuthorization', () => { actionVariables: undefined, defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -1300,6 +1306,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", @@ -1328,6 +1335,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myOtherAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", @@ -1387,6 +1395,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", @@ -1423,6 +1432,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myOtherAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", @@ -1502,6 +1512,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myOtherAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", @@ -1526,6 +1537,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", @@ -1605,6 +1617,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myOtherAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", @@ -1633,6 +1646,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", @@ -1703,6 +1717,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", @@ -1807,6 +1822,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myOtherAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", @@ -1831,6 +1847,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", @@ -1914,6 +1931,7 @@ describe('AlertingAuthorization', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "myOtherAppAlertType", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization_kuery.test.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization_kuery.test.ts index 8a558b642738..7d39380f7bd1 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization_kuery.test.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization_kuery.test.ts @@ -26,6 +26,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => { name: 'myAppAlertType', producer: 'myApp', minimumLicenseRequired: 'basic', + isExportable: true, authorizedConsumers: { myApp: { read: true, all: true }, }, @@ -53,6 +54,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -88,6 +90,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -104,6 +107,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', @@ -120,6 +124,7 @@ describe('asKqlFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'mySecondAppAlertType', name: 'mySecondAppAlertType', @@ -162,6 +167,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => { name: 'myAppAlertType', producer: 'myApp', minimumLicenseRequired: 'basic', + isExportable: true, authorizedConsumers: { myApp: { read: true, all: true }, }, @@ -216,6 +222,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -283,6 +290,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -299,6 +307,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', @@ -315,6 +324,7 @@ describe('asEsDslFiltersByRuleTypeAndConsumer', () => { actionGroups: [], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, id: 'mySecondAppAlertType', name: 'mySecondAppAlertType', diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index a8befe521075..f7280e05b78f 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -12,7 +12,6 @@ describe('config validation', () => { const config: Record = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { - "enableImportExport": false, "healthCheck": Object { "interval": "60m", }, diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index d50917fd1357..e42955b385bf 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -16,7 +16,6 @@ export const configSchema = schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1h' }), }), - enableImportExport: schema.boolean({ defaultValue: false }), }); export type AlertsConfig = TypeOf; diff --git a/x-pack/plugins/alerting/server/health/get_state.test.ts b/x-pack/plugins/alerting/server/health/get_state.test.ts index 2dddf81e3b76..24f3c101b26b 100644 --- a/x-pack/plugins/alerting/server/health/get_state.test.ts +++ b/x-pack/plugins/alerting/server/health/get_state.test.ts @@ -71,7 +71,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }), pollInterval ).subscribe(); @@ -105,7 +104,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }), pollInterval, retryDelay @@ -150,7 +148,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }) ).toPromise(); @@ -181,7 +178,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }) ).toPromise(); @@ -212,7 +208,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }) ).toPromise(); @@ -240,7 +235,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }), retryDelay ).subscribe((status) => { @@ -271,7 +265,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }), retryDelay ).subscribe((status) => { @@ -308,7 +301,6 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }) ).toPromise(); diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts index a1c326656f73..e04ce85b3537 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts @@ -70,6 +70,7 @@ describe('getLicenseCheckForAlertType', () => { executor: jest.fn(), producer: 'alerts', minimumLicenseRequired: 'gold', + isExportable: true, recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, }; @@ -204,6 +205,7 @@ describe('ensureLicenseForAlertType()', () => { executor: jest.fn(), producer: 'alerts', minimumLicenseRequired: 'gold', + isExportable: true, recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, }; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 4e9249944a6b..9adc3cc9d656 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -18,7 +18,6 @@ import { AlertsConfig } from './config'; import { AlertType } from './types'; import { eventLogMock } from '../../event_log/server/mocks'; import { actionsMock } from '../../actions/server/mocks'; -import mappings from './saved_objects/mappings.json'; describe('Alerting Plugin', () => { describe('setup()', () => { @@ -37,7 +36,6 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }); plugin = new AlertingPlugin(context); @@ -61,78 +59,13 @@ describe('Alerting Plugin', () => { ); }); - it('should register saved object with no management capability if enableImportExport is false', async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - enableImportExport: false, - }); - plugin = new AlertingPlugin(context); - - const setupMocks = coreMock.createSetup(); - await plugin.setup(setupMocks, { - licensing: licensingMock.createSetup(), - encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), - taskManager: taskManagerMock.createSetup(), - eventLog: eventLogServiceMock.create(), - actions: actionsMock.createSetup(), - statusService: statusServiceMock.createSetupContract(), - }); - - expect(setupMocks.savedObjects.registerType).toHaveBeenCalledTimes(2); - const registerAlertingSavedObject = setupMocks.savedObjects.registerType.mock.calls[0][0]; - expect(registerAlertingSavedObject.name).toEqual('alert'); - expect(registerAlertingSavedObject.hidden).toBe(true); - expect(registerAlertingSavedObject.mappings).toEqual(mappings.alert); - expect(registerAlertingSavedObject.management).toBeUndefined(); - }); - - it('should register saved object with import/export capability if enableImportExport is true', async () => { - const context = coreMock.createPluginInitializerContext({ - healthCheck: { - interval: '5m', - }, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - enableImportExport: true, - }); - plugin = new AlertingPlugin(context); - - const setupMocks = coreMock.createSetup(); - await plugin.setup(setupMocks, { - licensing: licensingMock.createSetup(), - encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), - taskManager: taskManagerMock.createSetup(), - eventLog: eventLogServiceMock.create(), - actions: actionsMock.createSetup(), - statusService: statusServiceMock.createSetupContract(), - }); - - expect(setupMocks.savedObjects.registerType).toHaveBeenCalledTimes(2); - const registerAlertingSavedObject = setupMocks.savedObjects.registerType.mock.calls[0][0]; - expect(registerAlertingSavedObject.name).toEqual('alert'); - expect(registerAlertingSavedObject.hidden).toBe(true); - expect(registerAlertingSavedObject.mappings).toEqual(mappings.alert); - expect(registerAlertingSavedObject.management).not.toBeUndefined(); - expect(registerAlertingSavedObject.management?.importableAndExportable).toBe(true); - expect(registerAlertingSavedObject.management?.getTitle).not.toBeUndefined(); - expect(registerAlertingSavedObject.management?.onImport).not.toBeUndefined(); - expect(registerAlertingSavedObject.management?.onExport).not.toBeUndefined(); - }); - describe('registerType()', () => { let setup: PluginSetupContract; const sampleAlertType: AlertType = { id: 'test', name: 'test', minimumLicenseRequired: 'basic', + isExportable: true, actionGroups: [], defaultActionGroupId: 'default', producer: 'test', @@ -189,7 +122,6 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }); const plugin = new AlertingPlugin(context); @@ -229,7 +161,6 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }); const plugin = new AlertingPlugin(context); @@ -283,7 +214,6 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, - enableImportExport: false, }); const plugin = new AlertingPlugin(context); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 5afa1b235a8c..df63625bf242 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -192,8 +192,6 @@ export class AlertingPlugin { event: { provider: EVENT_LOG_PROVIDER }, }); - setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects, this.config); - this.eventLogService = plugins.eventLog; plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS)); @@ -221,6 +219,13 @@ export class AlertingPlugin { }); } + setupSavedObjects( + core.savedObjects, + plugins.encryptedSavedObjects, + this.alertTypeRegistry, + this.logger + ); + initializeApiKeyInvalidator( this.logger, core.getStartServices(), diff --git a/x-pack/plugins/alerting/server/routes/legacy/list_alert_types.test.ts b/x-pack/plugins/alerting/server/routes/legacy/list_alert_types.test.ts index 3e6f2f484a6d..e2bf1afdb0f6 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/list_alert_types.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/list_alert_types.test.ts @@ -47,6 +47,7 @@ describe('listAlertTypesRoute', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -79,6 +80,7 @@ describe('listAlertTypesRoute', () => { "defaultActionGroupId": "default", "enabledInLicense": true, "id": "1", + "isExportable": true, "minimumLicenseRequired": "basic", "name": "name", "producer": "test", @@ -120,6 +122,7 @@ describe('listAlertTypesRoute', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -172,6 +175,7 @@ describe('listAlertTypesRoute', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { diff --git a/x-pack/plugins/alerting/server/routes/rule_types.test.ts b/x-pack/plugins/alerting/server/routes/rule_types.test.ts index 58c9a4b4c46f..4f04f8c7575c 100644 --- a/x-pack/plugins/alerting/server/routes/rule_types.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule_types.test.ts @@ -48,6 +48,7 @@ describe('ruleTypesRoute', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -70,6 +71,7 @@ describe('ruleTypesRoute', () => { ], default_action_group_id: 'default', minimum_license_required: 'basic', + is_exportable: true, recovery_action_group: RecoveredActionGroup, authorized_consumers: {}, action_variables: { @@ -102,6 +104,7 @@ describe('ruleTypesRoute', () => { "default_action_group_id": "default", "enabled_in_license": true, "id": "1", + "is_exportable": true, "minimum_license_required": "basic", "name": "name", "producer": "test", @@ -143,6 +146,7 @@ describe('ruleTypesRoute', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -195,6 +199,7 @@ describe('ruleTypesRoute', () => { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { diff --git a/x-pack/plugins/alerting/server/routes/rule_types.ts b/x-pack/plugins/alerting/server/routes/rule_types.ts index a3a44f9b013c..f67e07f13fee 100644 --- a/x-pack/plugins/alerting/server/routes/rule_types.ts +++ b/x-pack/plugins/alerting/server/routes/rule_types.ts @@ -19,6 +19,7 @@ const rewriteBodyRes: RewriteResponseCase = (result actionGroups, defaultActionGroupId, minimumLicenseRequired, + isExportable, actionVariables, authorizedConsumers, ...rest @@ -29,6 +30,7 @@ const rewriteBodyRes: RewriteResponseCase = (result action_groups: actionGroups, default_action_group_id: defaultActionGroupId, minimum_license_required: minimumLicenseRequired, + is_exportable: isExportable, action_variables: actionVariables, authorized_consumers: authorizedConsumers, }) diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 1ad0f972b2ec..88ee3179ab3d 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -6,6 +6,7 @@ */ import type { + Logger, SavedObject, SavedObjectsExportTransformContext, SavedObjectsServiceSetup, @@ -17,7 +18,9 @@ import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objec import { transformRulesForExport } from './transform_rule_for_export'; import { RawAlert } from '../types'; import { getImportWarnings } from './get_import_warnings'; -import { AlertsConfig } from '../config'; +import { isRuleExportable } from './is_rule_exportable'; +import { AlertTypeRegistry } from '../alert_type_registry'; + export { partiallyUpdateAlert } from './partially_update_alert'; export const AlertAttributesExcludedFromAAD = [ @@ -44,65 +47,63 @@ export type AlertAttributesExcludedFromAADType = export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, - alertingConfig: Promise + ruleTypeRegistry: AlertTypeRegistry, + logger: Logger ) { - alertingConfig.then((config: AlertsConfig) => { - savedObjects.registerType({ - name: 'alert', - hidden: true, - namespaceType: 'single', - migrations: getMigrations(encryptedSavedObjects), - mappings: mappings.alert as SavedObjectsTypeMappingDefinition, - ...(config.enableImportExport - ? { - management: { - importableAndExportable: true, - getTitle(ruleSavedObject: SavedObject) { - return `Rule: [${ruleSavedObject.attributes.name}]`; - }, - onImport(ruleSavedObjects) { - return { - warnings: getImportWarnings(ruleSavedObjects), - }; - }, - onExport( - context: SavedObjectsExportTransformContext, - objects: Array> - ) { - return transformRulesForExport(objects); - }, - }, - } - : {}), - }); + savedObjects.registerType({ + name: 'alert', + hidden: true, + namespaceType: 'single', + migrations: getMigrations(encryptedSavedObjects), + mappings: mappings.alert as SavedObjectsTypeMappingDefinition, + management: { + importableAndExportable: true, + getTitle(ruleSavedObject: SavedObject) { + return `Rule: [${ruleSavedObject.attributes.name}]`; + }, + onImport(ruleSavedObjects) { + return { + warnings: getImportWarnings(ruleSavedObjects), + }; + }, + onExport( + context: SavedObjectsExportTransformContext, + objects: Array> + ) { + return transformRulesForExport(objects); + }, + isExportable(ruleSavedObject: SavedObject) { + return isRuleExportable(ruleSavedObject, ruleTypeRegistry, logger); + }, + }, + }); - savedObjects.registerType({ - name: 'api_key_pending_invalidation', - hidden: true, - namespaceType: 'agnostic', - mappings: { - properties: { - apiKeyId: { - type: 'keyword', - }, - createdAt: { - type: 'date', - }, + savedObjects.registerType({ + name: 'api_key_pending_invalidation', + hidden: true, + namespaceType: 'agnostic', + mappings: { + properties: { + apiKeyId: { + type: 'keyword', + }, + createdAt: { + type: 'date', }, }, - }); + }, + }); - // Encrypted attributes - encryptedSavedObjects.registerType({ - type: 'alert', - attributesToEncrypt: new Set(['apiKey']), - attributesToExcludeFromAAD: new Set(AlertAttributesExcludedFromAAD), - }); + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: 'alert', + attributesToEncrypt: new Set(['apiKey']), + attributesToExcludeFromAAD: new Set(AlertAttributesExcludedFromAAD), + }); - // Encrypted attributes - encryptedSavedObjects.registerType({ - type: 'api_key_pending_invalidation', - attributesToEncrypt: new Set(['apiKeyId']), - }); + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: 'api_key_pending_invalidation', + attributesToEncrypt: new Set(['apiKeyId']), }); } diff --git a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts new file mode 100644 index 000000000000..cc2dfbd3e2d2 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockedLogger, loggerMock } from '@kbn/logging/target/mocks'; +import { TaskRunnerFactory } from '../task_runner'; +import { AlertTypeRegistry, ConstructorOptions } from '../alert_type_registry'; +import { taskManagerMock } from '../../../task_manager/server/mocks'; +import { ILicenseState } from '../lib/license_state'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { licensingMock } from '../../../licensing/server/mocks'; +import { isRuleExportable } from './is_rule_exportable'; + +let ruleTypeRegistryParams: ConstructorOptions; +let logger: MockedLogger; +let mockedLicenseState: jest.Mocked; +const taskManager = taskManagerMock.createSetup(); + +beforeEach(() => { + jest.resetAllMocks(); + mockedLicenseState = licenseStateMock.create(); + logger = loggerMock.create(); + ruleTypeRegistryParams = { + taskManager, + taskRunnerFactory: new TaskRunnerFactory(), + licenseState: mockedLicenseState, + licensing: licensingMock.createSetup(), + }; +}); + +describe('isRuleExportable', () => { + it('should return true if rule type isExportable is true', () => { + const registry = new AlertTypeRegistry(ruleTypeRegistryParams); + registry.register({ + id: 'foo', + name: 'Foo', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + }); + expect( + isRuleExportable( + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'foo', + consumer: 'alert-consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: 'me', + updatedBy: 'me', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + apiKey: '4tndskbuhewotw4klrhgjewrt9u', + apiKeyOwner: 'me', + throttle: null, + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + }, + scheduledTaskId: '2q5tjbf3q45twer', + }, + references: [], + }, + registry, + logger + ) + ).toEqual(true); + }); + + it('should return false and log warning if rule type isExportable is false', () => { + const registry = new AlertTypeRegistry(ruleTypeRegistryParams); + registry.register({ + id: 'foo', + name: 'Foo', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: false, + executor: jest.fn(), + producer: 'alerts', + }); + expect( + isRuleExportable( + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'foo', + consumer: 'alert-consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: 'me', + updatedBy: 'me', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + apiKey: '4tndskbuhewotw4klrhgjewrt9u', + apiKeyOwner: 'me', + throttle: null, + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + }, + scheduledTaskId: '2q5tjbf3q45twer', + }, + references: [], + }, + registry, + logger + ) + ).toEqual(false); + expect(logger.warn).toHaveBeenCalledWith( + `Skipping export of rule \"1\" because rule type \"foo\" is not exportable through this interface.` + ); + }); + + it('should return false and log warning if rule type is not registered', () => { + const registry = new AlertTypeRegistry(ruleTypeRegistryParams); + registry.register({ + id: 'foo', + name: 'Foo', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: false, + executor: jest.fn(), + producer: 'alerts', + }); + expect( + isRuleExportable( + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'bar', + consumer: 'alert-consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: 'me', + updatedBy: 'me', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + apiKey: '4tndskbuhewotw4klrhgjewrt9u', + apiKeyOwner: 'me', + throttle: null, + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + }, + scheduledTaskId: '2q5tjbf3q45twer', + }, + references: [], + }, + registry, + logger + ) + ).toEqual(false); + expect(logger.warn).toHaveBeenCalledWith( + `Skipping export of rule \"1\" because rule type \"bar\" is not recognized.` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts new file mode 100644 index 000000000000..38290e5f465c --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, SavedObject } from 'kibana/server'; +import { RawAlert } from '../types'; +import { AlertTypeRegistry } from '../alert_type_registry'; + +export function isRuleExportable( + rule: SavedObject, + ruleTypeRegistry: AlertTypeRegistry, + logger: Logger +): boolean { + const ruleSO = rule as SavedObject; + try { + const ruleType = ruleTypeRegistry.get(ruleSO.attributes.alertTypeId); + if (!ruleType.isExportable) { + logger.warn( + `Skipping export of rule "${ruleSO.id}" because rule type "${ruleSO.attributes.alertTypeId}" is not exportable through this interface.` + ); + } + + return ruleType.isExportable; + } catch (err) { + logger.warn( + `Skipping export of rule "${ruleSO.id}" because rule type "${ruleSO.attributes.alertTypeId}" is not recognized.` + ); + return false; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 1dcd19119b6f..b264428b4d6f 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -44,6 +44,7 @@ const alertType: NormalizedAlertType< ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: { id: 'recovered', name: 'Recovered', diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 88d1b1b24a4e..4f650975f830 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -44,6 +44,7 @@ const alertType: jest.Mocked = { actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 343dffa0d5e7..050345f3e617 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -26,6 +26,7 @@ const alertType: UntypedNormalizedAlertType = { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: { id: 'recovered', name: 'Recovered', diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index f8846035e6b0..f21e17adc841 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -146,6 +146,7 @@ export interface AlertType< params?: ActionVariable[]; }; minimumLicenseRequired: LicenseType; + isExportable: boolean; } export type UntypedAlertType = AlertType< diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 12df93d54b29..ad233c7f6df9 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -33,6 +33,7 @@ export const ALERT_TYPES_CONFIG: Record< actionGroups: Array>; defaultActionGroupId: ThresholdMetActionGroupId; minimumLicenseRequired: string; + isExportable: boolean; producer: string; } > = { @@ -44,6 +45,7 @@ export const ALERT_TYPES_CONFIG: Record< defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', producer: 'apm', + isExportable: true, }, [AlertType.TransactionDuration]: { name: i18n.translate('xpack.apm.transactionDurationAlert.name', { @@ -53,6 +55,7 @@ export const ALERT_TYPES_CONFIG: Record< defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', producer: 'apm', + isExportable: true, }, [AlertType.TransactionDurationAnomaly]: { name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { @@ -62,6 +65,7 @@ export const ALERT_TYPES_CONFIG: Record< defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', producer: 'apm', + isExportable: true, }, [AlertType.TransactionErrorRate]: { name: i18n.translate('xpack.apm.transactionErrorRateAlert.name', { @@ -71,6 +75,7 @@ export const ALERT_TYPES_CONFIG: Record< defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', producer: 'apm', + isExportable: true, }, }; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 2a63c53b626c..7548d6eba060 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -71,6 +71,7 @@ export function registerErrorCountAlertType({ }, producer: 'apm', minimumLicenseRequired: 'basic', + isExportable: true, executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); const alertParams = params; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index 24a6376761a7..ca7806251f75 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -79,6 +79,7 @@ export function registerTransactionDurationAlertType({ }, producer: 'apm', minimumLicenseRequired: 'basic', + isExportable: true, executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); const alertParams = params; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index f640925b0a0f..de0657d075d7 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -87,6 +87,7 @@ export function registerTransactionDurationAnomalyAlertType({ }, producer: 'apm', minimumLicenseRequired: 'basic', + isExportable: true, executor: async ({ services, params }) => { if (!ml) { return {}; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 47ed5236ce74..718ffd9c9216 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -77,6 +77,7 @@ export function registerTransactionErrorRateAlertType({ }, producer: 'apm', minimumLicenseRequired: 'basic', + isExportable: true, executor: async ({ services, params: alertParams }) => { const config = await config$.pipe(take(1)).toPromise(); const indices = await getApmIndices({ diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index 81204c7b71a6..a2d8e522c7c8 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -89,6 +89,7 @@ export const registerMetricInventoryThresholdAlertType = ( actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS], producer: 'infrastructure', minimumLicenseRequired: 'basic', + isExportable: true, executor: createInventoryMetricThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts index b7c6881b2039..62d92d0487ff 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -107,6 +107,7 @@ export async function registerLogThresholdAlertType( defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], minimumLicenseRequired: 'basic', + isExportable: true, executor: createLogThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts index 2adf37c60cf3..63354111a1a9 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts @@ -64,6 +64,7 @@ export const registerMetricAnomalyAlertType = ( actionGroups: [FIRED_ACTIONS], producer: 'infrastructure', minimumLicenseRequired: 'basic', + isExportable: true, executor: createMetricAnomalyExecutor(libs, ml), actionVariables: { context: [ diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 7dbae03f20fb..e519d67b446a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -100,6 +100,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs): Metric defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS], minimumLicenseRequired: 'basic', + isExportable: true, executor: createMetricThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts index f39b3850b71b..93c627c0f631 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts @@ -119,6 +119,7 @@ export function registerAnomalyDetectionAlertType({ }, producer: PLUGIN_ID, minimumLicenseRequired: MINIMUM_FULL_LICENSE, + isExportable: true, async executor({ services, params, alertId, state, previousStartedAt, startedAt }) { const fakeRequest = {} as KibanaRequest; const { execute } = mlSharedServices.alertingServiceProvider( diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index bb80d84210a4..8954a4ae2486 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -96,6 +96,7 @@ export class BaseAlert { ], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: false, executor: ( options: AlertExecutorOptions & { state: ExecutedState; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 85e69eb51fd0..a362dcccc2f0 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -40,6 +40,7 @@ function createRule() { }, id: 'test_type', minimumLicenseRequired: 'basic', + isExportable: true, name: 'Test type', producer: 'test', actionVariables: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index a4863e577c6b..c85848ba6dcf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -37,6 +37,7 @@ export const rulesNotificationAlertType = ({ }), }, minimumLicenseRequired: 'basic', + isExportable: false, async executor({ startedAt, previousStartedAt, alertId, services, params }) { const ruleAlertSavedObject = await services.savedObjectsClient.get( 'alert', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.ts index 39d02c808d09..b98bd9b3551c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.ts @@ -45,6 +45,7 @@ export const createEqlAlertType = (ruleDataClient: RuleDataClient, logger: Logge context: [{ name: 'server', description: 'the server' }], }, minimumLicenseRequired: 'basic', + isExportable: false, producer: 'security-solution', async executor({ startedAt, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/ml.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/ml.ts index c07d0436cc90..14252bf62ef8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/ml.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/ml.ts @@ -57,6 +57,7 @@ export const mlAlertType = createSecurityMlRuleType({ context: [{ name: 'server', description: 'the server' }], }, minimumLicenseRequired: 'basic', + isExportable: false, producer: 'security-solution', async executor({ services: { alertWithPersistence, findAlerts }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/query.ts index 39f325fd6cf8..4ca9448f5e3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/query.ts @@ -43,6 +43,7 @@ export const createQueryAlertType = (ruleDataClient: RuleDataClient, logger: Log context: [{ name: 'server', description: 'the server' }], }, minimumLicenseRequired: 'basic', + isExportable: false, producer: 'security-solution', async executor({ services: { alertWithPersistence, findAlerts }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/threshold.ts index d4721e8bab11..fa291ef3139c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/threshold.ts @@ -113,6 +113,7 @@ export const createThresholdAlertType = (ruleDataClient: RuleDataClient, logger: context: [{ name: 'server', description: 'the server' }], }, minimumLicenseRequired: 'basic', + isExportable: false, producer: 'security-solution', async executor({ startedAt, services, params, alertId }) { const fromDate = moment(startedAt).subtract(moment.duration(5, 'm')); // hardcoded 5-minute rule interval diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 32bd6d71bfb1..ba665fa43e8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -103,6 +103,7 @@ export const signalRulesAlertType = ({ }, producer: SERVER_APP_ID, minimumLicenseRequired: 'basic', + isExportable: false, async executor({ previousStartedAt, startedAt, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index b81bc19d5c73..c9f233002d79 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -140,6 +140,7 @@ export function getAlertType( ], }, minimumLicenseRequired: 'basic', + isExportable: true, executor, producer: STACK_ALERTS_FEATURE_ID, }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts index e3d379f2869b..43ae726fa247 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts @@ -177,5 +177,6 @@ export function getAlertType(logger: Logger): GeoContainmentAlertType { }, actionVariables, minimumLicenseRequired: 'gold', + isExportable: true, }; } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index a242c1e0eb29..aa56951b5dcb 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -125,6 +125,7 @@ export function getAlertType( ], }, minimumLicenseRequired: 'basic', + isExportable: true, executor, producer: STACK_ALERTS_FEATURE_ID, }; diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index b3262976b0ca..981a7e7ca392 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -89,6 +89,7 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], }, minimumLicenseRequired: 'basic', + isExportable: true, async executor({ options, uptimeEsClient, savedObjectsClient, dynamicSettings }) { const { services: { alertInstanceFactory }, diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index c5a6ef877c47..6f3e3303f6bd 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -258,6 +258,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( state: [...commonMonitorStateI18, ...commonStateTranslations], }, minimumLicenseRequired: 'basic', + isExportable: true, async executor({ options: { params: rawParams, diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls.ts b/x-pack/plugins/uptime/server/lib/alerts/tls.ts index f29744fdbb70..09f5e2fe0f6d 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls.ts @@ -112,6 +112,7 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, state: [...tlsTranslations.actionVariables, ...commonStateTranslations], }, minimumLicenseRequired: 'basic', + isExportable: true, async executor({ options, dynamicSettings, uptimeEsClient }) { const { services: { alertInstanceFactory }, diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts b/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts index 8f1c0093e60a..5bf91b7c5486 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts @@ -103,6 +103,7 @@ export const tlsLegacyAlertFactory: UptimeAlertTypeFactory = (_s state: [...tlsTranslations.actionVariables, ...commonStateTranslations], }, minimumLicenseRequired: 'basic', + isExportable: true, async executor({ options, dynamicSettings, uptimeEsClient }) { const { services: { alertInstanceFactory }, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 3e622b49d03d..3c9d783f5a35 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -81,6 +81,7 @@ function getAlwaysFiringAlertType() { producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, actionVariables: { state: [{ name: 'instanceStateValue', description: 'the instance state value' }], params: [{ name: 'instanceParamsValue', description: 'the instance params value' }], @@ -167,6 +168,7 @@ function getCumulativeFiringAlertType() { producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor(alertExecutorOptions) { const { services, state } = alertExecutorOptions; const group = 'default'; @@ -212,6 +214,7 @@ function getNeverFiringAlertType() { producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor({ services, params, state }) { await services.scopedClusterClient.asCurrentUser.index({ index: params.index, @@ -252,6 +255,7 @@ function getFailingAlertType() { producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor({ services, params, state }) { await services.scopedClusterClient.asCurrentUser.index({ index: params.index, @@ -290,6 +294,7 @@ function getAuthorizationAlertType(core: CoreSetup) { defaultActionGroupId: 'default', producer: 'alertsFixture', minimumLicenseRequired: 'basic', + isExportable: true, validate: { params: paramsSchema, }, @@ -376,6 +381,7 @@ function getValidationAlertType() { ], producer: 'alertsFixture', minimumLicenseRequired: 'basic', + isExportable: true, defaultActionGroupId: 'default', validate: { params: paramsSchema, @@ -404,6 +410,7 @@ function getPatternFiringAlertType() { producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor(alertExecutorOptions) { const { services, state, params } = alertExecutorOptions; const pattern = params.pattern; @@ -468,6 +475,7 @@ export function defineAlertTypes( producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor() {}, }; const goldNoopAlertType: AlertType<{}, {}, {}, {}, 'default'> = { @@ -477,6 +485,7 @@ export function defineAlertTypes( producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'gold', + isExportable: true, async executor() {}, }; const onlyContextVariablesAlertType: AlertType<{}, {}, {}, {}, 'default'> = { @@ -486,6 +495,7 @@ export function defineAlertTypes( producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, actionVariables: { context: [{ name: 'aContextVariable', description: 'this is a context variable' }], }, @@ -501,6 +511,7 @@ export function defineAlertTypes( state: [{ name: 'aStateVariable', description: 'this is a state variable' }], }, minimumLicenseRequired: 'basic', + isExportable: true, async executor() {}, }; const throwAlertType: AlertType<{}, {}, {}, {}, 'default'> = { @@ -515,6 +526,7 @@ export function defineAlertTypes( producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor() { throw new Error('this alert is intended to fail'); }, @@ -531,6 +543,7 @@ export function defineAlertTypes( producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor() { await new Promise((resolve) => setTimeout(resolve, 5000)); }, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts index 884af7801855..2255d1fa95e2 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts @@ -20,6 +20,7 @@ export function defineAlertTypes( producer: 'alertsRestrictedFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, recoveryActionGroup: { id: 'restrictedRecovered', name: 'Restricted Recovery' }, async executor() {}, }; @@ -30,6 +31,7 @@ export function defineAlertTypes( producer: 'alertsRestrictedFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor() {}, }; alerting.registerType(noopRestrictedAlertType); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts index c851aaf2bbc8..f52f0977a630 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts @@ -30,6 +30,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { }, producer: 'alertsFixture', minimum_license_required: 'basic', + is_exportable: true, recovery_action_group: { id: 'recovered', name: 'Recovered', @@ -56,6 +57,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { }, producer: 'alertsRestrictedFixture', minimum_license_required: 'basic', + is_exportable: true, enabled_in_license: true, }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts index 3d3cec4c3025..86a0e269b26d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts @@ -42,6 +42,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { }, producer: 'alertsFixture', minimum_license_required: 'basic', + is_exportable: true, enabled_in_license: true, }); expect(Object.keys(authorizedConsumers)).to.contain('alertsFixture'); @@ -126,6 +127,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { }, producer: 'alertsFixture', minimumLicenseRequired: 'basic', + isExportable: true, enabledInLicense: true, }); expect(Object.keys(authorizedConsumers)).to.contain('alertsFixture'); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index 10a81e430908..8d0d2c4f0be3 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -24,6 +24,7 @@ export const noopAlertType: AlertType<{}, {}, {}, {}, 'default'> = { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', + isExportable: true, async executor() {}, producer: 'alerts', }; @@ -47,6 +48,7 @@ export const alwaysFiringAlertType: AlertType< defaultActionGroupId: 'default', producer: 'alerts', minimumLicenseRequired: 'basic', + isExportable: true, async executor(alertExecutorOptions) { const { services, state, params } = alertExecutorOptions; @@ -76,6 +78,7 @@ export const failingAlertType: AlertType Date: Tue, 29 Jun 2021 04:19:02 +0300 Subject: [PATCH 63/65] [Osquery] Add Saved queries (#100965) --- .../osquery/common/schemas/common/schemas.ts | 19 +- .../create_saved_query_request_schema.ts | 14 +- x-pack/plugins/osquery/kibana.json | 3 +- .../osquery/public/actions/actions_table.tsx | 19 +- .../public/common/hooks/use_breadcrumbs.tsx | 34 +++ .../osquery/public/common/page_paths.ts | 10 +- .../plugins/osquery/public/components/app.tsx | 9 + .../public/components/osquery_schema_link.tsx | 20 ++ .../plugins/osquery/public/editor/index.tsx | 2 +- .../public/editor/osquery_schema/v4.8.0.json | 1 + .../osquery/public/editor/osquery_tables.ts | 2 +- ...managed_policy_create_import_extension.tsx | 3 +- .../public/live_queries/form/index.tsx | 54 +++- .../form/live_query_query_field.tsx | 19 +- .../common/add_new_pack_query_flyout.tsx | 7 +- .../osquery/public/queries/edit/index.tsx | 53 ---- .../osquery/public/queries/form/index.tsx | 72 ------ .../osquery/public/queries/form/schema.ts | 30 --- .../plugins/osquery/public/queries/index.tsx | 36 --- .../osquery/public/queries/new/index.tsx | 32 --- .../osquery/public/queries/queries/index.tsx | 244 ------------------ .../plugins/osquery/public/routes/index.tsx | 4 + .../routes/live_queries/details/index.tsx | 2 +- .../public/routes/saved_queries/edit/form.tsx | 81 ++++++ .../routes/saved_queries/edit/index.tsx | 124 +++++++++ .../public/routes/saved_queries/edit/tabs.tsx | 78 ++++++ .../public/routes/saved_queries/index.tsx | 35 +++ .../routes/saved_queries/list/index.tsx | 217 ++++++++++++++++ .../public/routes/saved_queries/new/form.tsx | 81 ++++++ .../public/routes/saved_queries/new/index.tsx | 62 +++++ .../osquery/public/saved_queries/constants.ts | 8 + .../form/code_editor_field.tsx | 17 +- .../public/saved_queries/form/index.tsx | 74 ++++++ .../form/use_saved_query_form.tsx | 64 +++++ .../osquery/public/saved_queries/index.tsx | 12 + .../saved_queries/saved_queries_dropdown.tsx | 104 ++++++++ .../saved_queries/saved_query_flyout.tsx | 89 +++++++ .../saved_queries/use_create_saved_query.ts | 70 +++++ .../saved_queries/use_delete_saved_query.ts | 46 ++++ .../public/saved_queries/use_saved_queries.ts | 46 ++++ .../public/saved_queries/use_saved_query.ts | 54 ++++ .../use_scheduled_query_group.ts | 38 +++ .../saved_queries/use_update_saved_query.ts | 66 +++++ .../queries/constants.ts | 3 + .../queries/platform_checkbox_group_field.tsx | 11 +- .../queries/query_flyout.tsx | 48 +++- .../scheduled_query_groups/queries/schema.tsx | 10 + .../use_scheduled_query_group_query_form.tsx | 3 + .../scheduled_query_groups_table.tsx | 25 +- x-pack/plugins/osquery/public/types.ts | 4 +- .../scripts/schema_formatter/script.ts | 2 +- x-pack/plugins/osquery/server/config.ts | 2 +- .../lib/osquery_app_context_services.ts | 3 +- .../lib/saved_query/saved_object_mappings.ts | 30 ++- x-pack/plugins/osquery/server/plugin.ts | 1 + .../routes/action/create_action_route.ts | 6 +- .../saved_query/create_saved_query_route.ts | 6 +- .../saved_query/read_saved_query_route.ts | 8 +- .../saved_query/update_saved_query_route.ts | 6 +- x-pack/plugins/osquery/server/types.ts | 2 + .../translations/translations/ja-JP.json | 2 +- .../translations/translations/zh-CN.json | 2 +- 62 files changed, 1678 insertions(+), 551 deletions(-) create mode 100644 x-pack/plugins/osquery/public/components/osquery_schema_link.tsx create mode 100644 x-pack/plugins/osquery/public/editor/osquery_schema/v4.8.0.json delete mode 100644 x-pack/plugins/osquery/public/queries/edit/index.tsx delete mode 100644 x-pack/plugins/osquery/public/queries/form/index.tsx delete mode 100644 x-pack/plugins/osquery/public/queries/form/schema.ts delete mode 100644 x-pack/plugins/osquery/public/queries/index.tsx delete mode 100644 x-pack/plugins/osquery/public/queries/new/index.tsx delete mode 100644 x-pack/plugins/osquery/public/queries/queries/index.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/index.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/new/form.tsx create mode 100644 x-pack/plugins/osquery/public/routes/saved_queries/new/index.tsx create mode 100644 x-pack/plugins/osquery/public/saved_queries/constants.ts rename x-pack/plugins/osquery/public/{queries => saved_queries}/form/code_editor_field.tsx (69%) create mode 100644 x-pack/plugins/osquery/public/saved_queries/form/index.tsx create mode 100644 x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx create mode 100644 x-pack/plugins/osquery/public/saved_queries/index.tsx create mode 100644 x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx create mode 100644 x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx create mode 100644 x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts create mode 100644 x-pack/plugins/osquery/public/saved_queries/use_delete_saved_query.ts create mode 100644 x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts create mode 100644 x-pack/plugins/osquery/public/saved_queries/use_saved_query.ts create mode 100644 x-pack/plugins/osquery/public/saved_queries/use_scheduled_query_group.ts create mode 100644 x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts diff --git a/x-pack/plugins/osquery/common/schemas/common/schemas.ts b/x-pack/plugins/osquery/common/schemas/common/schemas.ts index f5d0a357b85b..1e52080debb9 100644 --- a/x-pack/plugins/osquery/common/schemas/common/schemas.ts +++ b/x-pack/plugins/osquery/common/schemas/common/schemas.ts @@ -7,10 +7,10 @@ import * as t from 'io-ts'; -export const name = t.string; -export type Name = t.TypeOf; -export const nameOrUndefined = t.union([name, t.undefined]); -export type NameOrUndefined = t.TypeOf; +export const id = t.string; +export type Id = t.TypeOf; +export const idOrUndefined = t.union([id, t.undefined]); +export type IdOrUndefined = t.TypeOf; export const agentSelection = t.type({ agents: t.array(t.string), @@ -18,6 +18,7 @@ export const agentSelection = t.type({ platformsSelected: t.array(t.string), policiesSelected: t.array(t.string), }); + export type AgentSelection = t.TypeOf; export const agentSelectionOrUndefined = t.union([agentSelection, t.undefined]); export type AgentSelectionOrUndefined = t.TypeOf; @@ -36,3 +37,13 @@ export const query = t.string; export type Query = t.TypeOf; export const queryOrUndefined = t.union([query, t.undefined]); export type QueryOrUndefined = t.TypeOf; + +export const version = t.string; +export type Version = t.TypeOf; +export const versionOrUndefined = t.union([version, t.undefined]); +export type VersionOrUndefined = t.TypeOf; + +export const interval = t.string; +export type Interval = t.TypeOf; +export const intervalOrUndefined = t.union([interval, t.undefined]); +export type IntervalOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts b/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts index 9e901be1476b..5aa08d9afde4 100644 --- a/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts +++ b/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts @@ -7,14 +7,24 @@ import * as t from 'io-ts'; -import { name, description, Description, platform, query } from '../../common/schemas'; +import { + id, + description, + Description, + platform, + query, + version, + interval, +} from '../../common/schemas'; import { RequiredKeepUndefined } from '../../../types'; export const createSavedQueryRequestSchema = t.type({ - name, + id, description, platform, query, + version, + interval, }); export type CreateSavedQueryRequestSchema = t.OutputOf; diff --git a/x-pack/plugins/osquery/kibana.json b/x-pack/plugins/osquery/kibana.json index 86a4d817de40..a8f3975430e5 100644 --- a/x-pack/plugins/osquery/kibana.json +++ b/x-pack/plugins/osquery/kibana.json @@ -26,7 +26,8 @@ "features", "fleet", "navigation", - "triggersActionsUi" + "triggersActionsUi", + "security" ], "server": true, "ui": true, diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx index 5d1b9b723d98..0ee928ad8aa1 100644 --- a/x-pack/plugins/osquery/public/actions/actions_table.tsx +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { isArray } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiButtonIcon, EuiCodeBlock, formatDate } from '@elastic/eui'; import React, { useState, useCallback, useMemo } from 'react'; @@ -54,6 +55,8 @@ const ActionsTableComponent = () => { const renderAgentsColumn = useCallback((_, item) => <>{item.fields.agents?.length ?? 0}, []); + const renderCreatedByColumn = useCallback((userId) => (isArray(userId) ? userId[0] : '-'), []); + const renderTimestampColumn = useCallback( (_, item) => <>{formatDate(item.fields['@timestamp'][0])}, [] @@ -90,6 +93,14 @@ const ActionsTableComponent = () => { width: '200px', render: renderTimestampColumn, }, + { + field: 'fields.user_id', + name: i18n.translate('xpack.osquery.liveQueryActions.table.createdByColumnTitle', { + defaultMessage: 'Run by', + }), + width: '200px', + render: renderCreatedByColumn, + }, { name: i18n.translate('xpack.osquery.liveQueryActions.table.viewDetailsColumnTitle', { defaultMessage: 'View details', @@ -101,7 +112,13 @@ const ActionsTableComponent = () => { ], }, ], - [renderActionsColumn, renderAgentsColumn, renderQueryColumn, renderTimestampColumn] + [ + renderActionsColumn, + renderAgentsColumn, + renderCreatedByColumn, + renderQueryColumn, + renderTimestampColumn, + ] ); const pagination = useMemo( diff --git a/x-pack/plugins/osquery/public/common/hooks/use_breadcrumbs.tsx b/x-pack/plugins/osquery/public/common/hooks/use_breadcrumbs.tsx index 660ef87fb57e..7b52b330d014 100644 --- a/x-pack/plugins/osquery/public/common/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/osquery/public/common/hooks/use_breadcrumbs.tsx @@ -67,6 +67,40 @@ const breadcrumbGetters: { text: liveQueryId, }, ], + saved_queries: () => [ + BASE_BREADCRUMB, + { + text: i18n.translate('xpack.osquery.breadcrumbs.savedQueriesPageTitle', { + defaultMessage: 'Saved queries', + }), + }, + ], + saved_query_new: () => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.saved_queries(), + text: i18n.translate('xpack.osquery.breadcrumbs.savedQueriesPageTitle', { + defaultMessage: 'Saved queries', + }), + }, + { + text: i18n.translate('xpack.osquery.breadcrumbs.newSavedQueryPageTitle', { + defaultMessage: 'New', + }), + }, + ], + saved_query_edit: ({ savedQueryName }) => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.saved_queries(), + text: i18n.translate('xpack.osquery.breadcrumbs.savedQueriesPageTitle', { + defaultMessage: 'Saved queries', + }), + }, + { + text: savedQueryName, + }, + ], scheduled_query_groups: () => [ BASE_BREADCRUMB, { diff --git a/x-pack/plugins/osquery/public/common/page_paths.ts b/x-pack/plugins/osquery/public/common/page_paths.ts index b4c7963fb9a0..0e0d8310ae8b 100644 --- a/x-pack/plugins/osquery/public/common/page_paths.ts +++ b/x-pack/plugins/osquery/public/common/page_paths.ts @@ -11,12 +11,15 @@ export type StaticPage = | 'live_queries' | 'live_query_new' | 'scheduled_query_groups' - | 'scheduled_query_group_add'; + | 'scheduled_query_group_add' + | 'saved_queries' + | 'saved_query_new'; export type DynamicPage = | 'live_query_details' | 'scheduled_query_group_details' - | 'scheduled_query_group_edit'; + | 'scheduled_query_group_edit' + | 'saved_query_edit'; export type Page = StaticPage | DynamicPage; @@ -50,6 +53,9 @@ export const pagePathGetters: { live_queries: () => '/live_queries', live_query_new: () => '/live_queries/new', live_query_details: ({ liveQueryId }) => `/live_queries/${liveQueryId}`, + saved_queries: () => '/saved_queries', + saved_query_new: () => '/saved_queries/new', + saved_query_edit: ({ savedQueryId }) => `/saved_queries/${savedQueryId}`, scheduled_query_groups: () => '/scheduled_query_groups', scheduled_query_group_add: () => '/scheduled_query_groups/add', scheduled_query_group_details: ({ scheduledQueryGroupId }) => diff --git a/x-pack/plugins/osquery/public/components/app.tsx b/x-pack/plugins/osquery/public/components/app.tsx index d56aacc99ad5..df61b116a564 100644 --- a/x-pack/plugins/osquery/public/components/app.tsx +++ b/x-pack/plugins/osquery/public/components/app.tsx @@ -50,6 +50,15 @@ const OsqueryAppComponent = () => { defaultMessage="Scheduled query groups" /> + + + diff --git a/x-pack/plugins/osquery/public/components/osquery_schema_link.tsx b/x-pack/plugins/osquery/public/components/osquery_schema_link.tsx new file mode 100644 index 000000000000..d1f346bad335 --- /dev/null +++ b/x-pack/plugins/osquery/public/components/osquery_schema_link.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiText, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const OsquerySchemaLink = React.memo(() => ( + + + + + +)); + +OsquerySchemaLink.displayName = 'OsquerySchemaLink'; diff --git a/x-pack/plugins/osquery/public/editor/index.tsx b/x-pack/plugins/osquery/public/editor/index.tsx index 70da55ca3f00..5be2b1816ad8 100644 --- a/x-pack/plugins/osquery/public/editor/index.tsx +++ b/x-pack/plugins/osquery/public/editor/index.tsx @@ -40,7 +40,7 @@ const OsqueryEditorComponent: React.FC = ({ name="osquery_editor" setOptions={EDITOR_SET_OPTIONS} editorProps={EDITOR_PROPS} - height="200px" + height="150px" width="100%" /> ); diff --git a/x-pack/plugins/osquery/public/editor/osquery_schema/v4.8.0.json b/x-pack/plugins/osquery/public/editor/osquery_schema/v4.8.0.json new file mode 100644 index 000000000000..2a15d54e7d32 --- /dev/null +++ b/x-pack/plugins/osquery/public/editor/osquery_schema/v4.8.0.json @@ -0,0 +1 @@ +[{"name":"account_policy_data"},{"name":"acpi_tables"},{"name":"ad_config"},{"name":"alf"},{"name":"alf_exceptions"},{"name":"alf_explicit_auths"},{"name":"app_schemes"},{"name":"apparmor_events"},{"name":"apparmor_profiles"},{"name":"appcompat_shims"},{"name":"apps"},{"name":"apt_sources"},{"name":"arp_cache"},{"name":"asl"},{"name":"atom_packages"},{"name":"augeas"},{"name":"authenticode"},{"name":"authorization_mechanisms"},{"name":"authorizations"},{"name":"authorized_keys"},{"name":"autoexec"},{"name":"azure_instance_metadata"},{"name":"azure_instance_tags"},{"name":"background_activities_moderator"},{"name":"battery"},{"name":"bitlocker_info"},{"name":"block_devices"},{"name":"bpf_process_events"},{"name":"bpf_socket_events"},{"name":"browser_plugins"},{"name":"carbon_black_info"},{"name":"carves"},{"name":"certificates"},{"name":"chassis_info"},{"name":"chocolatey_packages"},{"name":"chrome_extension_content_scripts"},{"name":"chrome_extensions"},{"name":"connectivity"},{"name":"cpu_info"},{"name":"cpu_time"},{"name":"cpuid"},{"name":"crashes"},{"name":"crontab"},{"name":"cups_destinations"},{"name":"cups_jobs"},{"name":"curl"},{"name":"curl_certificate"},{"name":"deb_packages"},{"name":"default_environment"},{"name":"device_file"},{"name":"device_firmware"},{"name":"device_hash"},{"name":"device_partitions"},{"name":"disk_encryption"},{"name":"disk_events"},{"name":"disk_info"},{"name":"dns_cache"},{"name":"dns_resolvers"},{"name":"docker_container_fs_changes"},{"name":"docker_container_labels"},{"name":"docker_container_mounts"},{"name":"docker_container_networks"},{"name":"docker_container_ports"},{"name":"docker_container_processes"},{"name":"docker_container_stats"},{"name":"docker_containers"},{"name":"docker_image_history"},{"name":"docker_image_labels"},{"name":"docker_image_layers"},{"name":"docker_images"},{"name":"docker_info"},{"name":"docker_network_labels"},{"name":"docker_networks"},{"name":"docker_version"},{"name":"docker_volume_labels"},{"name":"docker_volumes"},{"name":"drivers"},{"name":"ec2_instance_metadata"},{"name":"ec2_instance_tags"},{"name":"elf_dynamic"},{"name":"elf_info"},{"name":"elf_sections"},{"name":"elf_segments"},{"name":"elf_symbols"},{"name":"etc_hosts"},{"name":"etc_protocols"},{"name":"etc_services"},{"name":"event_taps"},{"name":"example"},{"name":"extended_attributes"},{"name":"fan_speed_sensors"},{"name":"fbsd_kmods"},{"name":"file"},{"name":"file_events"},{"name":"firefox_addons"},{"name":"gatekeeper"},{"name":"gatekeeper_approved_apps"},{"name":"groups"},{"name":"hardware_events"},{"name":"hash"},{"name":"homebrew_packages"},{"name":"hvci_status"},{"name":"ibridge_info"},{"name":"ie_extensions"},{"name":"intel_me_info"},{"name":"interface_addresses"},{"name":"interface_details"},{"name":"interface_ipv6"},{"name":"iokit_devicetree"},{"name":"iokit_registry"},{"name":"iptables"},{"name":"kernel_extensions"},{"name":"kernel_info"},{"name":"kernel_modules"},{"name":"kernel_panics"},{"name":"keychain_acls"},{"name":"keychain_items"},{"name":"known_hosts"},{"name":"kva_speculative_info"},{"name":"last"},{"name":"launchd"},{"name":"launchd_overrides"},{"name":"listening_ports"},{"name":"lldp_neighbors"},{"name":"load_average"},{"name":"location_services"},{"name":"logged_in_users"},{"name":"logical_drives"},{"name":"logon_sessions"},{"name":"lxd_certificates"},{"name":"lxd_cluster"},{"name":"lxd_cluster_members"},{"name":"lxd_images"},{"name":"lxd_instance_config"},{"name":"lxd_instance_devices"},{"name":"lxd_instances"},{"name":"lxd_networks"},{"name":"lxd_storage_pools"},{"name":"magic"},{"name":"managed_policies"},{"name":"md_devices"},{"name":"md_drives"},{"name":"md_personalities"},{"name":"mdfind"},{"name":"mdls"},{"name":"memory_array_mapped_addresses"},{"name":"memory_arrays"},{"name":"memory_device_mapped_addresses"},{"name":"memory_devices"},{"name":"memory_error_info"},{"name":"memory_info"},{"name":"memory_map"},{"name":"mounts"},{"name":"msr"},{"name":"nfs_shares"},{"name":"npm_packages"},{"name":"ntdomains"},{"name":"ntfs_acl_permissions"},{"name":"ntfs_journal_events"},{"name":"nvram"},{"name":"oem_strings"},{"name":"office_mru"},{"name":"os_version"},{"name":"osquery_events"},{"name":"osquery_extensions"},{"name":"osquery_flags"},{"name":"osquery_info"},{"name":"osquery_packs"},{"name":"osquery_registry"},{"name":"osquery_schedule"},{"name":"package_bom"},{"name":"package_install_history"},{"name":"package_receipts"},{"name":"patches"},{"name":"pci_devices"},{"name":"physical_disk_performance"},{"name":"pipes"},{"name":"pkg_packages"},{"name":"platform_info"},{"name":"plist"},{"name":"portage_keywords"},{"name":"portage_packages"},{"name":"portage_use"},{"name":"power_sensors"},{"name":"powershell_events"},{"name":"preferences"},{"name":"process_envs"},{"name":"process_events"},{"name":"process_file_events"},{"name":"process_memory_map"},{"name":"process_namespaces"},{"name":"process_open_files"},{"name":"process_open_pipes"},{"name":"process_open_sockets"},{"name":"processes"},{"name":"programs"},{"name":"prometheus_metrics"},{"name":"python_packages"},{"name":"quicklook_cache"},{"name":"registry"},{"name":"routes"},{"name":"rpm_package_files"},{"name":"rpm_packages"},{"name":"running_apps"},{"name":"safari_extensions"},{"name":"sandboxes"},{"name":"scheduled_tasks"},{"name":"screenlock"},{"name":"seccomp_events"},{"name":"selinux_events"},{"name":"selinux_settings"},{"name":"services"},{"name":"shadow"},{"name":"shared_folders"},{"name":"shared_memory"},{"name":"shared_resources"},{"name":"sharing_preferences"},{"name":"shell_history"},{"name":"shellbags"},{"name":"shimcache"},{"name":"shortcut_files"},{"name":"signature"},{"name":"sip_config"},{"name":"smart_drive_info"},{"name":"smbios_tables"},{"name":"smc_keys"},{"name":"socket_events"},{"name":"ssh_configs"},{"name":"startup_items"},{"name":"sudoers"},{"name":"suid_bin"},{"name":"syslog_events"},{"name":"system_controls"},{"name":"system_extensions"},{"name":"system_info"},{"name":"systemd_units"},{"name":"temperature_sensors"},{"name":"time"},{"name":"time_machine_backups"},{"name":"time_machine_destinations"},{"name":"ulimit_info"},{"name":"uptime"},{"name":"usb_devices"},{"name":"user_events"},{"name":"user_groups"},{"name":"user_interaction_events"},{"name":"user_ssh_keys"},{"name":"userassist"},{"name":"users"},{"name":"video_info"},{"name":"virtual_memory_info"},{"name":"wifi_networks"},{"name":"wifi_status"},{"name":"wifi_survey"},{"name":"winbaseobj"},{"name":"windows_crashes"},{"name":"windows_eventlog"},{"name":"windows_events"},{"name":"windows_optional_features"},{"name":"windows_security_center"},{"name":"windows_security_products"},{"name":"wmi_bios_info"},{"name":"wmi_cli_event_consumers"},{"name":"wmi_event_filters"},{"name":"wmi_filter_consumer_binding"},{"name":"wmi_script_event_consumers"},{"name":"xprotect_entries"},{"name":"xprotect_meta"},{"name":"xprotect_reports"},{"name":"yara"},{"name":"yara_events"},{"name":"ycloud_instance_metadata"},{"name":"yum_sources"}] \ No newline at end of file diff --git a/x-pack/plugins/osquery/public/editor/osquery_tables.ts b/x-pack/plugins/osquery/public/editor/osquery_tables.ts index d114cda742f9..d41df4021bae 100644 --- a/x-pack/plugins/osquery/public/editor/osquery_tables.ts +++ b/x-pack/plugins/osquery/public/editor/osquery_tables.ts @@ -20,7 +20,7 @@ let osqueryTables: TablesJSON | null = null; export const getOsqueryTables = () => { if (!osqueryTables) { // eslint-disable-next-line @typescript-eslint/no-var-requires - osqueryTables = normalizeTables(require('./osquery_schema/v4.7.0.json')); + osqueryTables = normalizeTables(require('./osquery_schema/v4.8.0.json')); } return osqueryTables; }; diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx index 2305df807f1c..28d69a6a7b15 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx @@ -207,7 +207,8 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< integrationPolicyId={policy?.id} agentPolicyId={policy?.policy_id} /> - + + {editMode && scheduledQueryGroupTableData.inputs[0].streams.length ? ( diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 6f2d1afec6fe..9e952810e335 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -5,20 +5,28 @@ * 2.0. */ -import { EuiButton, EuiSteps, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiSteps, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useMutation } from 'react-query'; import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports'; import { AgentsTableField } from './agents_table_field'; import { LiveQueryQueryField } from './live_query_query_field'; import { useKibana } from '../../common/lib/kibana'; -import { ResultTabs } from '../../queries/edit/tabs'; +import { ResultTabs } from '../../routes/saved_queries/edit/tabs'; import { queryFieldValidation } from '../../common/validations'; import { fieldValidators } from '../../shared_imports'; +import { SavedQueryFlyout } from '../../saved_queries'; import { useErrorToast } from '../../common/hooks/use_error_toast'; const FORM_ID = 'liveQueryForm'; @@ -27,19 +35,17 @@ export const MAX_QUERY_LENGTH = 2000; interface LiveQueryFormProps { defaultValue?: Partial | undefined; - onSubmit?: (payload: Record) => Promise; onSuccess?: () => void; } -const LiveQueryFormComponent: React.FC = ({ - defaultValue, - // onSubmit, - onSuccess, -}) => { +const LiveQueryFormComponent: React.FC = ({ defaultValue, onSuccess }) => { const { http } = useKibana().services; - + const [showSavedQueryFlyout, setShowSavedQueryFlyout] = useState(false); const setErrorToast = useErrorToast(); + const handleShowSaveQueryFlout = useCallback(() => setShowSavedQueryFlyout(true), []); + const handleCloseSaveQueryFlout = useCallback(() => setShowSavedQueryFlyout(false), []); + const { data, isLoading, @@ -139,6 +145,8 @@ const LiveQueryFormComponent: React.FC = ({ [queryStatus] ); + const flyoutFormDefaultValue = useMemo(() => ({ query }), [query]); + const formSteps: EuiContainedStepProps[] = useMemo( () => [ { @@ -161,6 +169,17 @@ const LiveQueryFormComponent: React.FC = ({ /> + + + + + = ({ actionId, agentIds, agentSelected, + handleShowSaveQueryFlout, queryComponentProps, queryStatus, queryValueProvided, @@ -203,9 +223,17 @@ const LiveQueryFormComponent: React.FC = ({ ); return ( -
- - + <> +
+ + + {showSavedQueryFlyout ? ( + + ) : null} + ); }; diff --git a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx index 07c13b930e14..9f0b5acd8994 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx @@ -5,11 +5,13 @@ * 2.0. */ +import { EuiFormRow, EuiSpacer } from '@elastic/eui'; import React, { useCallback } from 'react'; -import { EuiFormRow } from '@elastic/eui'; +import { OsquerySchemaLink } from '../../components/osquery_schema_link'; import { FieldHook } from '../../shared_imports'; import { OsqueryEditor } from '../../editor'; +import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown'; interface LiveQueryQueryFieldProps { disabled?: boolean; @@ -20,6 +22,13 @@ const LiveQueryQueryFieldComponent: React.FC = ({ disa const { value, setValue, errors } = field; const error = errors[0]?.message; + const handleSavedQueryChange = useCallback( + (savedQuery) => { + setValue(savedQuery.query); + }, + [setValue] + ); + const handleEditorChange = useCallback( (newValue) => { setValue(newValue); @@ -29,7 +38,13 @@ const LiveQueryQueryFieldComponent: React.FC = ({ disa return ( - + <> + + + }> + + + ); }; diff --git a/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx b/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx index 2680b5198fad..85578564b1eb 100644 --- a/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx @@ -8,7 +8,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; import React from 'react'; -import { SavedQueryForm } from '../../queries/form'; +import { SavedQueryForm } from '../../saved_queries/form'; // @ts-expect-error update types const AddNewPackQueryFlyoutComponent = ({ handleClose, handleSubmit }) => ( @@ -19,7 +19,10 @@ const AddNewPackQueryFlyoutComponent = ({ handleClose, handleSubmit }) => ( - + { + // @ts-expect-error update types + + } ); diff --git a/x-pack/plugins/osquery/public/queries/edit/index.tsx b/x-pack/plugins/osquery/public/queries/edit/index.tsx deleted file mode 100644 index 61094b2d0794..000000000000 --- a/x-pack/plugins/osquery/public/queries/edit/index.tsx +++ /dev/null @@ -1,53 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEmpty } from 'lodash/fp'; -import React from 'react'; -import { useMutation, useQuery } from 'react-query'; - -import { SavedQueryForm } from '../form'; -import { useKibana } from '../../common/lib/kibana'; - -interface EditSavedQueryPageProps { - onSuccess: () => void; - savedQueryId: string; -} - -const EditSavedQueryPageComponent: React.FC = ({ - onSuccess, - savedQueryId, -}) => { - const { http } = useKibana().services; - - const { isLoading, data: savedQueryDetails } = useQuery(['savedQuery', { savedQueryId }], () => - http.get(`/internal/osquery/saved_query/${savedQueryId}`) - ); - const updateSavedQueryMutation = useMutation( - (payload) => - http.put(`/internal/osquery/saved_query/${savedQueryId}`, { body: JSON.stringify(payload) }), - { onSuccess } - ); - - if (isLoading) { - return <>{'Loading...'}; - } - - return ( - <> - {!isEmpty(savedQueryDetails) && ( - - )} - - ); -}; - -export const EditSavedQueryPage = React.memo(EditSavedQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/queries/form/index.tsx b/x-pack/plugins/osquery/public/queries/form/index.tsx deleted file mode 100644 index 02468fbfde22..000000000000 --- a/x-pack/plugins/osquery/public/queries/form/index.tsx +++ /dev/null @@ -1,72 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiButton, EuiSpacer } from '@elastic/eui'; -import React from 'react'; - -import { Field, getUseField, useForm, UseField, Form } from '../../shared_imports'; -import { CodeEditorField } from './code_editor_field'; -import { formSchema } from './schema'; - -export const CommonUseField = getUseField({ component: Field }); - -const SAVED_QUERY_FORM_ID = 'savedQueryForm'; - -interface SavedQueryFormProps { - defaultValue?: unknown; - handleSubmit: () => Promise; - type?: string; -} - -const SavedQueryFormComponent: React.FC = ({ - defaultValue, - handleSubmit, - type, -}) => { - const { form } = useForm({ - // @ts-expect-error update types - id: defaultValue ? SAVED_QUERY_FORM_ID + defaultValue.id : SAVED_QUERY_FORM_ID, - schema: formSchema, - onSubmit: handleSubmit, - options: { - stripEmptyFields: false, - }, - // @ts-expect-error update types - defaultValue, - }); - - const { submit } = form; - - return ( -
- - - - - - - - - {type === 'edit' ? 'Update' : 'Save'} - - ); -}; - -export const SavedQueryForm = React.memo(SavedQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/queries/form/schema.ts b/x-pack/plugins/osquery/public/queries/form/schema.ts deleted file mode 100644 index 33200e45dc8e..000000000000 --- a/x-pack/plugins/osquery/public/queries/form/schema.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FIELD_TYPES, FormSchema } from '../../shared_imports'; - -export const formSchema: FormSchema = { - name: { - type: FIELD_TYPES.TEXT, - label: 'Query name', - }, - description: { - type: FIELD_TYPES.TEXTAREA, - label: 'Description', - validations: [], - }, - platform: { - type: FIELD_TYPES.SELECT, - label: 'Platform', - defaultValue: 'all', - }, - query: { - label: 'Query', - type: FIELD_TYPES.TEXTAREA, - validations: [], - }, -}; diff --git a/x-pack/plugins/osquery/public/queries/index.tsx b/x-pack/plugins/osquery/public/queries/index.tsx deleted file mode 100644 index 7ecce3cfb22f..000000000000 --- a/x-pack/plugins/osquery/public/queries/index.tsx +++ /dev/null @@ -1,36 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useState } from 'react'; - -import { QueriesPage } from './queries'; -import { NewSavedQueryPage } from './new'; -import { EditSavedQueryPage } from './edit'; - -const QueriesComponent = () => { - const [showNewSavedQueryForm, setShowNewSavedQueryForm] = useState(false); - const [editSavedQueryId, setEditSavedQueryId] = useState(null); - - const goBack = useCallback(() => { - setShowNewSavedQueryForm(false); - setEditSavedQueryId(null); - }, []); - - const handleNewQueryClick = useCallback(() => setShowNewSavedQueryForm(true), []); - - if (showNewSavedQueryForm) { - return ; - } - - if (editSavedQueryId?.length) { - return ; - } - - return ; -}; - -export const Queries = React.memo(QueriesComponent); diff --git a/x-pack/plugins/osquery/public/queries/new/index.tsx b/x-pack/plugins/osquery/public/queries/new/index.tsx deleted file mode 100644 index 2682db126ea0..000000000000 --- a/x-pack/plugins/osquery/public/queries/new/index.tsx +++ /dev/null @@ -1,32 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useMutation } from 'react-query'; - -import { useKibana } from '../../common/lib/kibana'; -import { SavedQueryForm } from '../form'; - -interface NewSavedQueryPageProps { - onSuccess: () => void; -} - -const NewSavedQueryPageComponent: React.FC = ({ onSuccess }) => { - const { http } = useKibana().services; - - const createSavedQueryMutation = useMutation( - (payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), - { - onSuccess, - } - ); - - // @ts-expect-error update types - return ; -}; - -export const NewSavedQueryPage = React.memo(NewSavedQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/queries/queries/index.tsx b/x-pack/plugins/osquery/public/queries/queries/index.tsx deleted file mode 100644 index bf766a15a44a..000000000000 --- a/x-pack/plugins/osquery/public/queries/queries/index.tsx +++ /dev/null @@ -1,244 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { map } from 'lodash/fp'; -import { - EuiBasicTable, - EuiButton, - EuiButtonIcon, - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - RIGHT_ALIGNMENT, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useQuery, useQueryClient, useMutation } from 'react-query'; -import { useHistory } from 'react-router-dom'; -import qs from 'query-string'; - -import { useKibana } from '../../common/lib/kibana'; - -interface QueriesPageProps { - onEditClick: (savedQueryId: string) => void; - onNewClick: () => void; -} - -const QueriesPageComponent: React.FC = ({ onEditClick, onNewClick }) => { - const { push } = useHistory(); - const queryClient = useQueryClient(); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('updated_at'); - const [sortDirection, setSortDirection] = useState('desc'); - const [selectedItems, setSelectedItems] = useState([]); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>({}); - const { http } = useKibana().services; - - const deleteSavedQueriesMutation = useMutation( - (payload) => http.delete(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), - { - onSuccess: () => queryClient.invalidateQueries('savedQueryList'), - } - ); - - const { data = {} } = useQuery( - ['savedQueryList', { pageIndex, pageSize, sortField, sortDirection }], - () => - http.get('/internal/osquery/saved_query', { - query: { - pageIndex, - pageSize, - sortField, - sortDirection, - }, - }), - { - keepPreviousData: true, - // Refetch the data every 10 seconds - refetchInterval: 5000, - } - ); - const { total = 0, saved_objects: savedQueries } = data; - - const toggleDetails = useCallback( - (item) => () => { - const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - if (itemIdToExpandedRowMapValues[item.id]) { - delete itemIdToExpandedRowMapValues[item.id]; - } else { - itemIdToExpandedRowMapValues[item.id] = ( - - {item.attributes.query} - - ); - } - setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); - }, - [itemIdToExpandedRowMap] - ); - - const renderExtendedItemToggle = useCallback( - (item) => ( - - ), - [itemIdToExpandedRowMap, toggleDetails] - ); - - const handleEditClick = useCallback((item) => onEditClick(item.id), [onEditClick]); - - const handlePlayClick = useCallback( - (item) => - push({ - search: qs.stringify({ - tab: 'live_query', - }), - state: { - query: { - id: item.id, - query: item.attributes.query, - }, - }, - }), - [push] - ); - - const columns = useMemo( - () => [ - { - field: 'attributes.name', - name: 'Query name', - sortable: true, - truncateText: true, - }, - { - field: 'attributes.description', - name: 'Description', - sortable: true, - truncateText: true, - }, - { - field: 'updated_at', - name: 'Last updated at', - sortable: true, - truncateText: true, - }, - { - name: 'Actions', - actions: [ - { - name: 'Live query', - description: 'Run live query', - type: 'icon', - icon: 'play', - onClick: handlePlayClick, - }, - { - name: 'Edit', - description: 'Edit or run this query', - type: 'icon', - icon: 'documentEdit', - onClick: handleEditClick, - }, - ], - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render: renderExtendedItemToggle, - }, - ], - [handleEditClick, handlePlayClick, renderExtendedItemToggle] - ); - - const onTableChange = useCallback(({ page = {}, sort = {} }) => { - setPageIndex(page.index); - setPageSize(page.size); - setSortField(sort.field); - setSortDirection(sort.direction); - }, []); - - const pagination = useMemo( - () => ({ - pageIndex, - pageSize, - totalItemCount: total, - pageSizeOptions: [3, 5, 8], - }), - [total, pageIndex, pageSize] - ); - - const sorting = useMemo( - () => ({ - sort: { - field: sortField, - direction: sortDirection, - }, - }), - [sortDirection, sortField] - ); - - const selection = useMemo( - () => ({ - selectable: () => true, - onSelectionChange: setSelectedItems, - initialSelected: [], - }), - [] - ); - - const handleDeleteClick = useCallback(() => { - const selectedItemsIds = map('id', selectedItems); - // @ts-expect-error update types - deleteSavedQueriesMutation.mutate({ savedQueryIds: selectedItemsIds }); - }, [deleteSavedQueriesMutation, selectedItems]); - - return ( -
- - - {!selectedItems.length ? ( - - {'New query'} - - ) : ( - - {`Delete ${selectedItems.length} Queries`} - - )} - - - - - - {savedQueries && ( - - )} -
- ); -}; - -export const QueriesPage = React.memo(QueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/routes/index.tsx b/x-pack/plugins/osquery/public/routes/index.tsx index 7007feb19d66..a858a51aad64 100644 --- a/x-pack/plugins/osquery/public/routes/index.tsx +++ b/x-pack/plugins/osquery/public/routes/index.tsx @@ -11,12 +11,16 @@ import { Switch, Redirect, Route } from 'react-router-dom'; import { useBreadcrumbs } from '../common/hooks/use_breadcrumbs'; import { LiveQueries } from './live_queries'; import { ScheduledQueryGroups } from './scheduled_query_groups'; +import { SavedQueries } from './saved_queries'; const OsqueryAppRoutesComponent = () => { useBreadcrumbs('base'); return ( + + + diff --git a/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx b/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx index 64a1fb0791e8..e4f1bb447a15 100644 --- a/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx +++ b/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx @@ -27,7 +27,7 @@ import { useRouterNavigate } from '../../../common/lib/kibana'; import { WithHeaderLayout } from '../../../components/layouts'; import { useActionResults } from '../../../action_results/use_action_results'; import { useActionDetails } from '../../../actions/use_action_details'; -import { ResultTabs } from '../../../queries/edit/tabs'; +import { ResultTabs } from '../../saved_queries/edit/tabs'; import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs'; import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge'; diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx new file mode 100644 index 000000000000..8d77b7819bd3 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBottomBar, + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { Form } from '../../../shared_imports'; +import { SavedQueryForm } from '../../../saved_queries/form'; +import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form'; + +interface EditSavedQueryFormProps { + defaultValue?: unknown; + handleSubmit: () => Promise; +} + +const EditSavedQueryFormComponent: React.FC = ({ + defaultValue, + handleSubmit, +}) => { + const savedQueryListProps = useRouterNavigate('saved_queries'); + + const { form } = useSavedQueryForm({ + defaultValue, + handleSubmit, + }); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const EditSavedQueryForm = React.memo(EditSavedQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx new file mode 100644 index 000000000000..4aaf8e4fc4fc --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/index.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiConfirmModal, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useCallback, useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useParams } from 'react-router-dom'; + +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { WithHeaderLayout } from '../../../components/layouts'; +import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs'; +import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge'; +import { EditSavedQueryForm } from './form'; +import { useDeleteSavedQuery, useUpdateSavedQuery, useSavedQuery } from '../../../saved_queries'; + +const EditSavedQueryPageComponent = () => { + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const { savedQueryId } = useParams<{ savedQueryId: string }>(); + const savedQueryListProps = useRouterNavigate('saved_queries'); + + const { isLoading, data: savedQueryDetails } = useSavedQuery({ savedQueryId }); + const updateSavedQueryMutation = useUpdateSavedQuery({ savedQueryId }); + const deleteSavedQueryMutation = useDeleteSavedQuery({ savedQueryId }); + + useBreadcrumbs('saved_query_edit', { savedQueryId: savedQueryDetails?.attributes?.id ?? '' }); + + const handleCloseDeleteConfirmationModal = useCallback(() => { + setIsDeleteModalVisible(false); + }, []); + + const handleDeleteClick = useCallback(() => { + setIsDeleteModalVisible(true); + }, []); + + const handleDeleteConfirmClick = useCallback(() => { + deleteSavedQueryMutation.mutateAsync().then(() => { + handleCloseDeleteConfirmationModal(); + }); + }, [deleteSavedQueryMutation, handleCloseDeleteConfirmationModal]); + + const LeftColumn = useMemo( + () => ( + + + + + + + + +

+ +

+ +
+
+
+ ), + [savedQueryDetails?.attributes?.id, savedQueryListProps] + ); + + const RightColumn = useMemo( + () => ( + + + + ), + [handleDeleteClick] + ); + + if (isLoading) return null; + + return ( + + {!isLoading && !isEmpty(savedQueryDetails) && ( + + )} + {isDeleteModalVisible ? ( + +

You’re about to delete this query.

+

Are you sure you want to do this?

+
+ ) : null} +
+ ); +}; + +export const EditSavedQueryPage = React.memo(EditSavedQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx new file mode 100644 index 000000000000..1946cd6dd345 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiTabbedContent, EuiSpacer } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { ResultsTable } from '../../../results/results_table'; +import { ActionResultsSummary } from '../../../action_results/action_results_summary'; + +interface ResultTabsProps { + actionId: string; + agentIds?: string[]; + expirationDate: Date; + isLive?: boolean; + startDate?: string; + endDate?: string; +} + +const ResultTabsComponent: React.FC = ({ + actionId, + agentIds, + endDate, + expirationDate, + isLive, + startDate, +}) => { + const tabs = useMemo( + () => [ + { + id: 'results', + name: 'Results', + content: ( + <> + + + + ), + }, + { + id: 'status', + name: 'Status', + content: ( + <> + + + + ), + }, + ], + [actionId, agentIds, endDate, expirationDate, isLive, startDate] + ); + + return ( + + ); +}; + +export const ResultTabs = React.memo(ResultTabsComponent); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/index.tsx new file mode 100644 index 000000000000..f986129bdfef --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; + +import { QueriesPage } from './list'; +import { NewSavedQueryPage } from './new'; +import { EditSavedQueryPage } from './edit'; +import { useBreadcrumbs } from '../../common/hooks/use_breadcrumbs'; + +const SavedQueriesComponent = () => { + useBreadcrumbs('saved_queries'); + const match = useRouteMatch(); + + return ( + + + + + + + + + + + + ); +}; + +export const SavedQueries = React.memo(SavedQueriesComponent); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx new file mode 100644 index 000000000000..7e8e8e543dfa --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { + EuiInMemoryTable, + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { SavedObject } from 'kibana/public'; +import { WithHeaderLayout } from '../../../components/layouts'; +import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs'; +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge'; +import { useSavedQueries } from '../../../saved_queries/use_saved_queries'; + +interface EditButtonProps { + savedQueryId: string; + savedQueryName: string; +} + +const EditButtonComponent: React.FC = ({ savedQueryId, savedQueryName }) => { + const buttonProps = useRouterNavigate(`saved_queries/${savedQueryId}`); + + return ( + + ); +}; + +const EditButton = React.memo(EditButtonComponent); + +const SavedQueriesPageComponent = () => { + useBreadcrumbs('saved_queries'); + const newQueryLinkProps = useRouterNavigate('saved_queries/new'); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [sortField, setSortField] = useState('updated_at'); + const [sortDirection, setSortDirection] = useState('desc'); + + const { data } = useSavedQueries({ isLive: true }); + + // const handlePlayClick = useCallback( + // (item) => + // push({ + // search: qs.stringify({ + // tab: 'live_query', + // }), + // state: { + // query: { + // id: item.id, + // query: item.attributes.query, + // }, + // }, + // }), + // [push] + // ); + + const renderEditAction = useCallback( + (item: SavedObject<{ name: string }>) => ( + + ), + [] + ); + + const renderUpdatedAt = useCallback((updatedAt, item) => { + if (!updatedAt) return '-'; + + const updatedBy = + item.attributes.updated_by !== item.attributes.created_by + ? ` @ ${item.attributes.updated_by}` + : ''; + return updatedAt ? `${moment(updatedAt).fromNow()}${updatedBy}` : '-'; + }, []); + + const columns = useMemo( + () => [ + { + field: 'attributes.id', + name: 'Query ID', + sortable: true, + truncateText: true, + }, + { + field: 'attributes.description', + name: 'Description', + sortable: true, + truncateText: true, + }, + { + field: 'attributes.created_by', + name: 'Created by', + sortable: true, + truncateText: true, + }, + { + field: 'attributes.updated_at', + name: 'Last updated at', + sortable: (item: SavedObject<{ updated_at: string }>) => + item.attributes.updated_at ? Date.parse(item.attributes.updated_at) : 0, + truncateText: true, + render: renderUpdatedAt, + }, + { + name: 'Actions', + actions: [ + // { + // name: 'Live query', + // description: 'Run live query', + // type: 'icon', + // icon: 'play', + // onClick: handlePlayClick, + // }, + { render: renderEditAction }, + ], + }, + ], + [renderEditAction, renderUpdatedAt] + ); + + const onTableChange = useCallback(({ page = {}, sort = {} }) => { + setPageIndex(page.index); + setPageSize(page.size); + setSortField(sort.field); + setSortDirection(sort.direction); + }, []); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: data?.total ?? 0, + pageSizeOptions: [10, 20, 50, 100], + }), + [pageIndex, pageSize, data?.total] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const LeftColumn = useMemo( + () => ( + + + +

+ +

+ +
+
+
+ ), + [] + ); + + const RightColumn = useMemo( + () => ( + + + + ), + [newQueryLinkProps] + ); + + return ( + + {data?.savedObjects && ( + + )} + + ); +}; + +export const QueriesPage = React.memo(SavedQueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/new/form.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/new/form.tsx new file mode 100644 index 000000000000..31d0c5637cc3 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/new/form.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBottomBar, + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { Form } from '../../../shared_imports'; +import { SavedQueryForm } from '../../../saved_queries/form'; +import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form'; + +interface NewSavedQueryFormProps { + defaultValue?: unknown; + handleSubmit: () => Promise; +} + +const NewSavedQueryFormComponent: React.FC = ({ + defaultValue, + handleSubmit, +}) => { + const savedQueryListProps = useRouterNavigate('saved_queries'); + + const { form } = useSavedQueryForm({ + defaultValue, + handleSubmit, + }); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const NewSavedQueryForm = React.memo(NewSavedQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/new/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/new/index.tsx new file mode 100644 index 000000000000..3f5a1af64fe3 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/saved_queries/new/index.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { WithHeaderLayout } from '../../../components/layouts'; +import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs'; +import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge'; +import { NewSavedQueryForm } from './form'; +import { useCreateSavedQuery } from '../../../saved_queries/use_create_saved_query'; + +const NewSavedQueryPageComponent = () => { + useBreadcrumbs('saved_query_new'); + const savedQueryListProps = useRouterNavigate('saved_queries'); + + const createSavedQueryMutation = useCreateSavedQuery({ withRedirect: true }); + + const LeftColumn = useMemo( + () => ( + + + + + + + + +

+ +

+ +
+
+
+ ), + [savedQueryListProps] + ); + + return ( + + { + // @ts-expect-error update types + + } + + ); +}; + +export const NewSavedQueryPage = React.memo(NewSavedQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/saved_queries/constants.ts b/x-pack/plugins/osquery/public/saved_queries/constants.ts new file mode 100644 index 000000000000..69ca805e3e8f --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SAVED_QUERIES_ID = 'savedQueryList'; diff --git a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx similarity index 69% rename from x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx rename to x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx index 77ffdc4457d3..c70aeae66396 100644 --- a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/code_editor_field.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { FormattedMessage } from '@kbn/i18n/react'; import { isEmpty } from 'lodash/fp'; -import { EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; import React from 'react'; +import { OsquerySchemaLink } from '../../components/osquery_schema_link'; import { OsqueryEditor } from '../../editor'; import { FieldHook } from '../../shared_imports'; @@ -17,19 +17,6 @@ interface CodeEditorFieldProps { field: FieldHook; } -const OsquerySchemaLink = React.memo(() => ( - - - - - -)); - -OsquerySchemaLink.displayName = 'OsquerySchemaLink'; - const CodeEditorFieldComponent: React.FC = ({ field }) => { const { value, label, labelAppend, helpText, setValue, errors } = field; const error = errors[0]?.message; diff --git a/x-pack/plugins/osquery/public/saved_queries/form/index.tsx b/x-pack/plugins/osquery/public/saved_queries/form/index.tsx new file mode 100644 index 000000000000..174227eb5e6e --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/form/index.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ALL_OSQUERY_VERSIONS_OPTIONS } from '../../scheduled_query_groups/queries/constants'; +import { PlatformCheckBoxGroupField } from '../../scheduled_query_groups/queries/platform_checkbox_group_field'; +import { Field, getUseField, UseField } from '../../shared_imports'; +import { CodeEditorField } from './code_editor_field'; + +export const CommonUseField = getUseField({ component: Field }); + +const SavedQueryFormComponent = () => ( + <> + + + + + + + + + +
+ +
+
+ + + +
+
+ + + + + + + + + + + + + +); + +export const SavedQueryForm = React.memo(SavedQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx new file mode 100644 index 000000000000..6417b40747e0 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isArray } from 'lodash'; +import uuid from 'uuid'; +import { produce } from 'immer'; + +import { useForm } from '../../shared_imports'; +import { formSchema } from '../../scheduled_query_groups/queries/schema'; +import { ScheduledQueryGroupFormData } from '../../scheduled_query_groups/queries/use_scheduled_query_group_query_form'; + +const SAVED_QUERY_FORM_ID = 'savedQueryForm'; + +interface UseSavedQueryFormProps { + defaultValue?: unknown; + handleSubmit: (payload: unknown) => Promise; +} + +export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryFormProps) => + useForm({ + id: SAVED_QUERY_FORM_ID + uuid.v4(), + schema: formSchema, + onSubmit: handleSubmit, + options: { + stripEmptyFields: false, + }, + // @ts-expect-error update types + defaultValue, + serializer: (payload) => + produce(payload, (draft) => { + // @ts-expect-error update types + if (draft.platform?.split(',').length === 3) { + // if all platforms are checked then use undefined + // @ts-expect-error update types + delete draft.platform; + } + if (isArray(draft.version)) { + if (!draft.version.length) { + // @ts-expect-error update types + delete draft.version; + } else { + draft.version = draft.version[0]; + } + } + return draft; + }), + // @ts-expect-error update types + deserializer: (payload) => { + if (!payload) return {} as ScheduledQueryGroupFormData; + + return { + id: payload.id, + description: payload.description, + query: payload.query, + interval: payload.interval ? parseInt(payload.interval, 10) : undefined, + platform: payload.platform, + version: payload.version ? [payload.version] : [], + }; + }, + }); diff --git a/x-pack/plugins/osquery/public/saved_queries/index.tsx b/x-pack/plugins/osquery/public/saved_queries/index.tsx new file mode 100644 index 000000000000..405af5638c86 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/index.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './saved_query_flyout'; +export * from './use_saved_query'; +export * from './use_saved_queries'; +export * from './use_update_saved_query'; +export * from './use_delete_saved_query'; diff --git a/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx b/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx new file mode 100644 index 000000000000..e30954a695b2 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { find } from 'lodash/fp'; +import { EuiCodeBlock, EuiFormRow, EuiComboBox, EuiText } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import { SimpleSavedObject } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useSavedQueries } from './use_saved_queries'; + +interface SavedQueriesDropdownProps { + disabled?: boolean; + onChange: ( + value: SimpleSavedObject<{ + id: string; + description?: string | undefined; + query: string; + }>['attributes'] + ) => void; +} + +const SavedQueriesDropdownComponent: React.FC = ({ + disabled, + onChange, +}) => { + const [selectedOptions, setSelectedOptions] = useState([]); + + const { data } = useSavedQueries({}); + + const queryOptions = + data?.savedObjects?.map((savedQuery) => ({ + label: savedQuery.attributes.id ?? '', + value: { + id: savedQuery.attributes.id, + description: savedQuery.attributes.description, + query: savedQuery.attributes.query, + }, + })) ?? []; + + const handleSavedQueryChange = useCallback( + (newSelectedOptions) => { + const selectedSavedQuery = find( + ['attributes.id', newSelectedOptions[0].value.id], + data?.savedObjects + ); + + if (selectedSavedQuery) { + onChange(selectedSavedQuery.attributes); + } + setSelectedOptions(newSelectedOptions); + }, + [data?.savedObjects, onChange] + ); + + const renderOption = useCallback( + ({ value }) => ( + <> + {value.id} + +

{value.description}

+
+ + {value.query} + + + ), + [] + ); + + return ( + + } + fullWidth + > + + + ); +}; + +export const SavedQueriesDropdown = React.memo(SavedQueriesDropdownComponent); diff --git a/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx b/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx new file mode 100644 index 000000000000..6d14943a6bc8 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFlyout, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiPortal, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { Form } from '../shared_imports'; +import { useSavedQueryForm } from './form/use_saved_query_form'; +import { SavedQueryForm } from './form'; +import { useCreateSavedQuery } from './use_create_saved_query'; + +interface AddQueryFlyoutProps { + defaultValue: unknown; + onClose: () => void; +} + +const SavedQueryFlyoutComponent: React.FC = ({ defaultValue, onClose }) => { + const createSavedQueryMutation = useCreateSavedQuery({ withRedirect: false }); + + const handleSubmit = useCallback( + (payload) => createSavedQueryMutation.mutateAsync(payload).then(() => onClose()), + [createSavedQueryMutation, onClose] + ); + + const { form } = useSavedQueryForm({ + defaultValue, + handleSubmit, + }); + + return ( + + + + +

+ +

+
+
+ +
+ + +
+ + + + + + + + + + + + + + +
+
+ ); +}; + +export const SavedQueryFlyout = React.memo(SavedQueryFlyoutComponent); diff --git a/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts b/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts new file mode 100644 index 000000000000..cc5c33c6e428 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { i18n } from '@kbn/i18n'; + +import { useKibana } from '../common/lib/kibana'; +import { savedQuerySavedObjectType } from '../../common/types'; +import { PLUGIN_ID } from '../../common'; +import { pagePathGetters } from '../common/page_paths'; +import { SAVED_QUERIES_ID } from './constants'; +import { useErrorToast } from '../common/hooks/use_error_toast'; + +interface UseCreateSavedQueryProps { + withRedirect?: boolean; +} + +export const useCreateSavedQuery = ({ withRedirect }: UseCreateSavedQueryProps) => { + const queryClient = useQueryClient(); + const { + application: { navigateToApp }, + savedObjects, + security, + notifications: { toasts }, + } = useKibana().services; + const setErrorToast = useErrorToast(); + + return useMutation( + async (payload) => { + const currentUser = await security.authc.getCurrentUser(); + + if (!currentUser) { + throw new Error('CurrentUser is missing'); + } + + return savedObjects.client.create(savedQuerySavedObjectType, { + // @ts-expect-error update types + ...payload, + created_by: currentUser.username, + created_at: new Date(Date.now()).toISOString(), + updated_by: currentUser.username, + updated_at: new Date(Date.now()).toISOString(), + }); + }, + { + onError: (error) => { + // @ts-expect-error update types + setErrorToast(error, { title: error.body.error, toastMessage: error.body.message }); + }, + onSuccess: (payload) => { + queryClient.invalidateQueries(SAVED_QUERIES_ID); + if (withRedirect) { + navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() }); + } + toasts.addSuccess( + i18n.translate('xpack.osquery.newSavedQuery.successToastMessageText', { + defaultMessage: 'Successfully saved "{savedQueryId}" query', + values: { + savedQueryId: payload.attributes?.id ?? '', + }, + }) + ); + }, + } + ); +}; diff --git a/x-pack/plugins/osquery/public/saved_queries/use_delete_saved_query.ts b/x-pack/plugins/osquery/public/saved_queries/use_delete_saved_query.ts new file mode 100644 index 000000000000..b2fee8b25f7a --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/use_delete_saved_query.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { i18n } from '@kbn/i18n'; + +import { useKibana } from '../common/lib/kibana'; +import { savedQuerySavedObjectType } from '../../common/types'; +import { PLUGIN_ID } from '../../common'; +import { pagePathGetters } from '../common/page_paths'; +import { SAVED_QUERIES_ID } from './constants'; +import { useErrorToast } from '../common/hooks/use_error_toast'; + +interface UseDeleteSavedQueryProps { + savedQueryId: string; +} + +export const useDeleteSavedQuery = ({ savedQueryId }: UseDeleteSavedQueryProps) => { + const queryClient = useQueryClient(); + const { + application: { navigateToApp }, + savedObjects, + notifications: { toasts }, + } = useKibana().services; + const setErrorToast = useErrorToast(); + + return useMutation(() => savedObjects.client.delete(savedQuerySavedObjectType, savedQueryId), { + onError: (error) => { + // @ts-expect-error update types + setErrorToast(error, { title: error.body.error, toastMessage: error.body.message }); + }, + onSuccess: () => { + queryClient.invalidateQueries(SAVED_QUERIES_ID); + navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() }); + toasts.addSuccess( + i18n.translate('xpack.osquery.editSavedQuery.deleteSuccessToastMessageText', { + defaultMessage: 'Successfully deleted saved query', + }) + ); + }, + }); +}; diff --git a/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts b/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts new file mode 100644 index 000000000000..324d4aace164 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from 'react-query'; + +import { useKibana } from '../common/lib/kibana'; +import { savedQuerySavedObjectType } from '../../common/types'; +import { SAVED_QUERIES_ID } from './constants'; + +export const useSavedQueries = ({ + isLive = false, + pageIndex = 0, + pageSize = 10000, + sortField = 'updated_at', + sortDirection = 'desc', +}) => { + const { savedObjects } = useKibana().services; + + return useQuery( + [SAVED_QUERIES_ID, { pageIndex, pageSize, sortField, sortDirection }], + async () => + savedObjects.client.find<{ + id: string; + description?: string; + query: string; + updated_at: string; + updated_by: string; + created_at: string; + created_by: string; + }>({ + type: savedQuerySavedObjectType, + page: pageIndex + 1, + perPage: pageSize, + sortField, + }), + { + keepPreviousData: true, + // Refetch the data every 10 seconds + refetchInterval: isLive ? 10000 : false, + } + ); +}; diff --git a/x-pack/plugins/osquery/public/saved_queries/use_saved_query.ts b/x-pack/plugins/osquery/public/saved_queries/use_saved_query.ts new file mode 100644 index 000000000000..92662cd24fd2 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/use_saved_query.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from 'react-query'; + +import { PLUGIN_ID } from '../../common'; +import { useKibana } from '../common/lib/kibana'; +import { savedQuerySavedObjectType } from '../../common/types'; +import { pagePathGetters } from '../common/page_paths'; +import { useErrorToast } from '../common/hooks/use_error_toast'; + +export const SAVED_QUERY_ID = 'savedQuery'; + +interface UseSavedQueryProps { + savedQueryId: string; +} + +export const useSavedQuery = ({ savedQueryId }: UseSavedQueryProps) => { + const { + application: { navigateToApp }, + savedObjects, + } = useKibana().services; + const setErrorToast = useErrorToast(); + + return useQuery( + [SAVED_QUERY_ID, { savedQueryId }], + async () => + savedObjects.client.get<{ + id: string; + description?: string; + query: string; + }>(savedQuerySavedObjectType, savedQueryId), + { + keepPreviousData: true, + onSuccess: (data) => { + if (data.error) { + setErrorToast(data.error, { + title: data.error.error, + toastMessage: data.error.message, + }); + navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() }); + } + }, + onError: (error) => { + // @ts-expect-error update types + setErrorToast(error, { title: error.body.error, toastMessage: error.body.message }); + }, + } + ); +}; diff --git a/x-pack/plugins/osquery/public/saved_queries/use_scheduled_query_group.ts b/x-pack/plugins/osquery/public/saved_queries/use_scheduled_query_group.ts new file mode 100644 index 000000000000..93d552b3f71f --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/use_scheduled_query_group.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from 'react-query'; + +import { useKibana } from '../common/lib/kibana'; +import { GetOnePackagePolicyResponse, packagePolicyRouteService } from '../../../fleet/common'; +import { OsqueryManagerPackagePolicy } from '../../common/types'; + +interface UseScheduledQueryGroup { + scheduledQueryGroupId: string; + skip?: boolean; +} + +export const useScheduledQueryGroup = ({ + scheduledQueryGroupId, + skip = false, +}: UseScheduledQueryGroup) => { + const { http } = useKibana().services; + + return useQuery< + Omit & { item: OsqueryManagerPackagePolicy }, + unknown, + OsqueryManagerPackagePolicy + >( + ['scheduledQueryGroup', { scheduledQueryGroupId }], + () => http.get(packagePolicyRouteService.getInfoPath(scheduledQueryGroupId)), + { + keepPreviousData: true, + enabled: !skip, + select: (response) => response.item, + } + ); +}; diff --git a/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts b/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts new file mode 100644 index 000000000000..1260413676a4 --- /dev/null +++ b/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { i18n } from '@kbn/i18n'; + +import { useKibana } from '../common/lib/kibana'; +import { savedQuerySavedObjectType } from '../../common/types'; +import { PLUGIN_ID } from '../../common'; +import { pagePathGetters } from '../common/page_paths'; +import { SAVED_QUERIES_ID } from './constants'; +import { useErrorToast } from '../common/hooks/use_error_toast'; + +interface UseUpdateSavedQueryProps { + savedQueryId: string; +} + +export const useUpdateSavedQuery = ({ savedQueryId }: UseUpdateSavedQueryProps) => { + const queryClient = useQueryClient(); + const { + application: { navigateToApp }, + savedObjects, + security, + notifications: { toasts }, + } = useKibana().services; + const setErrorToast = useErrorToast(); + + return useMutation( + async (payload) => { + const currentUser = await security.authc.getCurrentUser(); + + if (!currentUser) { + throw new Error('CurrentUser is missing'); + } + + return savedObjects.client.update(savedQuerySavedObjectType, savedQueryId, { + // @ts-expect-error update types + ...payload, + updated_by: currentUser.username, + updated_at: new Date(Date.now()).toISOString(), + }); + }, + { + onError: (error) => { + // @ts-expect-error update types + setErrorToast(error, { title: error.body.error, toastMessage: error.body.message }); + }, + onSuccess: (payload) => { + queryClient.invalidateQueries(SAVED_QUERIES_ID); + navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() }); + toasts.addSuccess( + i18n.translate('xpack.osquery.editSavedQuery.successToastMessageText', { + defaultMessage: 'Successfully updated "{savedQueryName}" query', + values: { + savedQueryName: payload.attributes?.name ?? '', + }, + }) + ); + }, + } + ); +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts index 3345c18d07b2..bedca9d5ef8d 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts @@ -6,6 +6,9 @@ */ export const ALL_OSQUERY_VERSIONS_OPTIONS = [ + { + label: '4.8.0', + }, { label: '4.7.0', }, diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx index 4e433e9e240b..0d455486bfa2 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx @@ -6,7 +6,7 @@ */ import { isEmpty, pickBy } from 'lodash'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -112,6 +112,15 @@ export const PlatformCheckBoxGroupField = ({ const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]); + useEffect(() => { + setCheckboxIdToSelectedMap(() => + (options as EuiCheckboxGroupOption[]).reduce((acc, option) => { + acc[option.id] = isEmpty(field.value) ? true : field.value?.includes(option.id) ?? false; + return acc; + }, {} as Record) + ); + }, [field.value, options]); + return ( = ({ onSave, onClose, }) => { + const [isEditMode] = useState(!!defaultValue); const { form } = useScheduledQueryGroupQueryForm({ defaultValue, handleSubmit: (payload, isValid) => @@ -67,7 +70,31 @@ const QueryFlyoutComponent: React.FC = ({ [integrationPackageVersion] ); - const { submit } = form; + const { submit, setFieldValue } = form; + + const handleSetQueryValue = useCallback( + (savedQuery) => { + setFieldValue('id', savedQuery.id); + setFieldValue('query', savedQuery.query); + + if (savedQuery.description) { + setFieldValue('description', savedQuery.description); + } + + if (savedQuery.interval) { + setFieldValue('interval', savedQuery.interval); + } + + if (isFieldSupported && savedQuery.platform) { + setFieldValue('platform', savedQuery.platform); + } + + if (isFieldSupported && savedQuery.version) { + setFieldValue('version', [savedQuery.version]); + } + }, + [isFieldSupported, setFieldValue] + ); return ( @@ -75,7 +102,7 @@ const QueryFlyoutComponent: React.FC = ({

- {defaultValue ? ( + {isEditMode ? ( = ({
+ {!isEditMode ? ( + <> + + + + ) : null} - + Set heading level based on context

} + description={'Will be wrapped in a small, subdued EuiText block.'} + > = ({ euiFieldProps={{ disabled: !isFieldSupported }} /> -
+ {!isFieldSupported ? ( diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx index 344c33b419dd..d8dbaad2f17e 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx @@ -22,6 +22,16 @@ export const formSchema = { }), validations: idFieldValidations.map((validator) => ({ validator })), }, + description: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.descriptionFieldLabel', + { + defaultMessage: 'Description', + } + ), + validations: [], + }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx index bcde5f4b970d..fdf781c6d6f7 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx @@ -45,6 +45,9 @@ export const useScheduledQueryGroupQueryForm = ({ // @ts-expect-error update types serializer: (payload) => produce(payload, (draft) => { + if (isArray(draft.platform)) { + draft.platform.join(','); + } if (draft.platform?.split(',').length === 3) { // if all platforms are checked then use undefined delete draft.platform; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_groups_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_groups_table.tsx index 7b5f91157132..391e20c63653 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_groups_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_groups_table.tsx @@ -6,6 +6,7 @@ */ import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; +import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; @@ -37,6 +38,13 @@ const ScheduledQueryGroupsTableComponent = () => { const renderActive = useCallback((_, item) => , []); + const renderUpdatedAt = useCallback((updatedAt, item) => { + if (!updatedAt) return '-'; + + const updatedBy = item.updated_by !== item.created_by ? ` @ ${item.updated_by}` : ''; + return updatedAt ? `${moment(updatedAt).fromNow()}${updatedBy}` : '-'; + }, []); + const columns: Array> = useMemo( () => [ { @@ -66,6 +74,21 @@ const ScheduledQueryGroupsTableComponent = () => { render: renderQueries, width: '150px', }, + { + field: 'created_by', + name: i18n.translate('xpack.osquery.scheduledQueryGroups.table.createdByColumnTitle', { + defaultMessage: 'Created by', + }), + sortable: true, + truncateText: true, + }, + { + field: 'updated_at', + name: 'Last updated', + sortable: (item) => (item.updated_at ? Date.parse(item.updated_at) : 0), + truncateText: true, + render: renderUpdatedAt, + }, { field: 'enabled', name: i18n.translate('xpack.osquery.scheduledQueryGroups.table.activeColumnTitle', { @@ -77,7 +100,7 @@ const ScheduledQueryGroupsTableComponent = () => { render: renderActive, }, ], - [renderActive, renderAgentPolicy, renderQueries] + [renderActive, renderAgentPolicy, renderQueries, renderUpdatedAt] ); const sorting = useMemo( diff --git a/x-pack/plugins/osquery/public/types.ts b/x-pack/plugins/osquery/public/types.ts index 441c00f2d096..9a466dfc619b 100644 --- a/x-pack/plugins/osquery/public/types.ts +++ b/x-pack/plugins/osquery/public/types.ts @@ -8,7 +8,8 @@ import { DiscoverStart } from '../../../../src/plugins/discover/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FleetStart } from '../../fleet/public'; -import { LensPublicStart } from '../../../plugins/lens/public'; +import { LensPublicStart } from '../../lens/public'; +import { SecurityPluginStart } from '../../security/public'; import { CoreStart } from '../../../../src/core/public'; import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; import { @@ -30,6 +31,7 @@ export interface StartPlugins { data: DataPublicPluginStart; fleet: FleetStart; lens?: LensPublicStart; + security: SecurityPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } diff --git a/x-pack/plugins/osquery/scripts/schema_formatter/script.ts b/x-pack/plugins/osquery/scripts/schema_formatter/script.ts index 578c4a112096..5cdf11d48c69 100644 --- a/x-pack/plugins/osquery/scripts/schema_formatter/script.ts +++ b/x-pack/plugins/osquery/scripts/schema_formatter/script.ts @@ -37,7 +37,7 @@ run( const mapFunc = pullFields.bind(null, { name: true }); const formattedSchema = schemaData.map(mapFunc); await fs.writeFile( - path.join(schemaPath, `${flags.schema_version}-formatted`), + path.join(schemaPath, `${flags.schema_version}-formatted.json`), JSON.stringify(formattedSchema) ); }, diff --git a/x-pack/plugins/osquery/server/config.ts b/x-pack/plugins/osquery/server/config.ts index 56d67400a47d..3ec9213ae6d6 100644 --- a/x-pack/plugins/osquery/server/config.ts +++ b/x-pack/plugins/osquery/server/config.ts @@ -10,7 +10,7 @@ import { TypeOf, schema } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), actionEnabled: schema.boolean({ defaultValue: false }), - savedQueries: schema.boolean({ defaultValue: false }), + savedQueries: schema.boolean({ defaultValue: true }), packs: schema.boolean({ defaultValue: false }), }); diff --git a/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts b/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts index 5b1f8e780494..6ebf469b8fb2 100644 --- a/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts +++ b/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts @@ -6,6 +6,7 @@ */ import { Logger, LoggerFactory } from 'src/core/server'; +import { SecurityPluginStart } from '../../../security/server'; import { AgentService, FleetStartContract, @@ -69,7 +70,7 @@ export class OsqueryAppContextService { export interface OsqueryAppContext { logFactory: LoggerFactory; config(): ConfigType; - + security: SecurityPluginStart; /** * Object readiness is tied to plugin start method */ diff --git a/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts b/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts index dadcea6e2fd4..537b6d7874ab 100644 --- a/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts +++ b/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts @@ -14,34 +14,40 @@ export const savedQuerySavedObjectMappings: SavedObjectsType['mappings'] = { description: { type: 'text', }, - name: { - type: 'text', + id: { + type: 'keyword', }, query: { type: 'text', }, - created: { + created_at: { type: 'date', }, - createdBy: { + created_by: { type: 'text', }, platform: { type: 'keyword', }, - updated: { + version: { + type: 'keyword', + }, + updated_at: { type: 'date', }, - updatedBy: { + updated_by: { type: 'text', }, + interval: { + type: 'keyword', + }, }, }; export const savedQueryType: SavedObjectsType = { name: savedQuerySavedObjectType, hidden: false, - namespaceType: 'single', + namespaceType: 'multiple-isolated', mappings: savedQuerySavedObjectMappings, }; @@ -53,16 +59,16 @@ export const packSavedObjectMappings: SavedObjectsType['mappings'] = { name: { type: 'text', }, - created: { + created_at: { type: 'date', }, - createdBy: { + created_by: { type: 'text', }, - updated: { + updated_at: { type: 'date', }, - updatedBy: { + updated_by: { type: 'text', }, queries: { @@ -81,6 +87,6 @@ export const packSavedObjectMappings: SavedObjectsType['mappings'] = { export const packType: SavedObjectsType = { name: packSavedObjectType, hidden: false, - namespaceType: 'single', + namespaceType: 'multiple-isolated', mappings: packSavedObjectMappings, }; diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts index ae779a978823..6bc12f5736e5 100644 --- a/x-pack/plugins/osquery/server/plugin.ts +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -46,6 +46,7 @@ export class OsqueryPlugin implements Plugin config, + security: plugins.security, }; initSavedObjects(core.savedObjects, osqueryContext); diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 86e871f04116..478bfc1053bd 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -48,13 +48,15 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon } try { + const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username; const action = { action_id: uuid.v4(), '@timestamp': moment().toISOString(), - expiration: moment().add(1, 'days').toISOString(), + expiration: moment().add(5, 'minutes').toISOString(), type: 'INPUT_ACTION', input_type: 'osquery', agents: selectedAgents, + user_id: currentUser, data: { id: uuid.v4(), query: request.body.query, @@ -75,7 +77,7 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon incrementCount(soClient, 'live_query', 'errors'); return response.customError({ statusCode: 500, - body: new Error(`Error occurred whlie processing ${error}`), + body: new Error(`Error occurred while processing ${error}`), }); } } diff --git a/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.ts index 5eb7147d4660..a41cb7cc39b4 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.ts @@ -28,13 +28,15 @@ export const createSavedQueryRoute = (router: IRouter) => { async (context, request, response) => { const savedObjectsClient = context.core.savedObjects.client; - const { name, description, platform, query } = request.body; + const { id, description, platform, query, version, interval } = request.body; const savedQuerySO = await savedObjectsClient.create(savedQuerySavedObjectType, { - name, + id, description, query, platform, + version, + interval, }); return response.ok({ diff --git a/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts index 8be4c6c50d82..2d399648df4c 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts @@ -21,18 +21,14 @@ export const readSavedQueryRoute = (router: IRouter) => { async (context, request, response) => { const savedObjectsClient = context.core.savedObjects.client; - const { attributes, ...savedQuery } = await savedObjectsClient.get( + const savedQuery = await savedObjectsClient.get( savedQuerySavedObjectType, // @ts-expect-error update types request.params.id ); return response.ok({ - body: { - ...savedQuery, - // @ts-expect-error update types - ...attributes, - }, + body: savedQuery, }); } ); diff --git a/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts index 579cd9b654cc..f9ecf675489d 100644 --- a/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts @@ -23,17 +23,19 @@ export const updateSavedQueryRoute = (router: IRouter) => { const savedObjectsClient = context.core.savedObjects.client; // @ts-expect-error update types - const { name, description, platform, query } = request.body; + const { id, description, platform, query, version, interval } = request.body; const savedQuerySO = await savedObjectsClient.update( savedQuerySavedObjectType, // @ts-expect-error update types request.params.id, { - name, + id, description, platform, query, + version, + interval, } ); diff --git a/x-pack/plugins/osquery/server/types.ts b/x-pack/plugins/osquery/server/types.ts index 667fba2bc98e..84b2ff41dc1c 100644 --- a/x-pack/plugins/osquery/server/types.ts +++ b/x-pack/plugins/osquery/server/types.ts @@ -13,6 +13,7 @@ import { import { FleetStartContract } from '../../fleet/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { PluginSetupContract } from '../../features/server'; +import { SecurityPluginStart } from '../../security/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OsqueryPluginSetup {} @@ -24,6 +25,7 @@ export interface SetupPlugins { actions: ActionsPlugin['setup']; data: DataPluginSetup; features: PluginSetupContract; + security: SecurityPluginStart; } export interface StartPlugins { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ca2be10624e6..7ffd5d8f20ff 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17336,7 +17336,7 @@ "xpack.osquery.breadcrumbs.newLiveQueryPageTitle": "新規", "xpack.osquery.breadcrumbs.overviewPageTitle": "概要", "xpack.osquery.breadcrumbs.scheduledQueryGroupsPageTitle": "スケジュールされたクエリグループ", - "xpack.osquery.codeEditorField.osquerySchemaLinkLabel": "Osqueryスキーマ", + "xpack.osquery.osquerySchemaLinkLabel": "Osqueryスキーマ", "xpack.osquery.common.tabBetaBadgeLabel": "ベータ", "xpack.osquery.common.tabBetaBadgeTooltipContent": "この機能は現在開発中です。他にも機能が追加され、機能によっては変更されるものもあります。", "xpack.osquery.editScheduledQuery.pageTitle": "{queryName}を編集", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 341741dd4046..4a964fc5e2fd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17573,7 +17573,7 @@ "xpack.osquery.breadcrumbs.newLiveQueryPageTitle": "新建", "xpack.osquery.breadcrumbs.overviewPageTitle": "概览", "xpack.osquery.breadcrumbs.scheduledQueryGroupsPageTitle": "已计划查询组", - "xpack.osquery.codeEditorField.osquerySchemaLinkLabel": "Osquery 架构", + "xpack.osquery.osquerySchemaLinkLabel": "Osquery 架构", "xpack.osquery.common.tabBetaBadgeLabel": "公测版", "xpack.osquery.common.tabBetaBadgeTooltipContent": "我们正在开发此功能。将会有更多的功能,某些功能可能有变更。", "xpack.osquery.createScheduledQuery.agentPolicyAgentsCountText": "{count, plural, other {# 个代理}}已注册", From da13795ed4e3c511ce44b87da7a5d146971f3866 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Mon, 28 Jun 2021 22:03:08 -0400 Subject: [PATCH 64/65] Task/host isolation status pending (#103549) --- .../event_details/alert_summary_view.tsx | 4 ++-- .../detection_engine/alerts/translations.ts | 5 +++++ .../alerts/use_host_isolation_status.tsx | 22 +++++++++++++++++-- .../body/renderers/agent_statuses.tsx | 13 +++++++++-- .../body/renderers/formatted_field.tsx | 2 +- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index 59acb16c028d..d89f44542318 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -183,7 +183,7 @@ const AlertSummaryViewComponent: React.FC<{ return endpointAlertCheck({ data }); }, [data]); - const agentId = useMemo(() => { + const endpointId = useMemo(() => { const findAgentId = find({ category: 'agent', field: 'agent.id' }, data)?.values; return findAgentId ? findAgentId[0] : ''; }, [data]); @@ -194,7 +194,7 @@ const AlertSummaryViewComponent: React.FC<{ contextId: timelineId, eventId, fieldName: 'agent.status', - value: agentId, + value: endpointId, linkValue: undefined, }, }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts index 9e4497f2f096..d5234e719b86 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts @@ -42,3 +42,8 @@ export const ISOLATION_STATUS_FAILURE = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.isolationStatus.title', { defaultMessage: 'Failed to retrieve current isolation status' } ); + +export const ISOLATION_PENDING_FAILURE = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.isolationPending.title', + { defaultMessage: 'Failed to retrieve isolation pending statuses' } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx index 7419727fff6a..3bdd8c981378 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -9,7 +9,8 @@ import { isEmpty } from 'lodash'; import { useEffect, useState } from 'react'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { getHostMetadata } from './api'; -import { ISOLATION_STATUS_FAILURE } from './translations'; +import { ISOLATION_STATUS_FAILURE, ISOLATION_PENDING_FAILURE } from './translations'; +import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions'; import { isEndpointHostIsolated } from '../../../../common/utils/validators'; import { HostStatus } from '../../../../../common/endpoint/types'; @@ -17,6 +18,8 @@ interface HostIsolationStatusResponse { loading: boolean; isIsolated: boolean; agentStatus: HostStatus; + pendingIsolation: number; + pendingUnisolation: number; } /* @@ -28,6 +31,8 @@ export const useHostIsolationStatus = ({ }): HostIsolationStatusResponse => { const [isIsolated, setIsIsolated] = useState(false); const [agentStatus, setAgentStatus] = useState(HostStatus.UNHEALTHY); + const [pendingIsolation, setPendingIsolation] = useState(0); + const [pendingUnisolation, setPendingUnisolation] = useState(0); const [loading, setLoading] = useState(false); const { addError } = useAppToasts(); @@ -35,16 +40,29 @@ export const useHostIsolationStatus = ({ useEffect(() => { // isMounted tracks if a component is mounted before changing state let isMounted = true; + let fleetAgentId: string; const fetchData = async () => { try { const metadataResponse = await getHostMetadata({ agentId }); if (isMounted) { setIsIsolated(isEndpointHostIsolated(metadataResponse.metadata)); setAgentStatus(metadataResponse.host_status); + fleetAgentId = metadataResponse.metadata.elastic.agent.id; } } catch (error) { addError(error.message, { title: ISOLATION_STATUS_FAILURE }); } + + try { + const { data } = await fetchPendingActionsByAgentId(fleetAgentId); + if (isMounted) { + setPendingIsolation(data[0].pending_actions?.isolate ?? 0); + setPendingUnisolation(data[0].pending_actions?.unisolate ?? 0); + } + } catch (error) { + addError(error.message, { title: ISOLATION_PENDING_FAILURE }); + } + if (isMounted) { setLoading(false); } @@ -64,5 +82,5 @@ export const useHostIsolationStatus = ({ isMounted = false; }; }, [addError, agentId]); - return { loading, isIsolated, agentStatus }; + return { loading, isIsolated, agentStatus, pendingIsolation, pendingUnisolation }; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx index 16f11809dd72..2c88b305c7d0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx @@ -24,7 +24,12 @@ export const AgentStatuses = React.memo( eventId: string; value: string; }) => { - const { isIsolated, agentStatus } = useHostIsolationStatus({ agentId: value }); + const { + isIsolated, + agentStatus, + pendingIsolation, + pendingUnisolation, + } = useHostIsolationStatus({ agentId: value }); const isolationFieldName = 'host.isolation'; return ( @@ -45,7 +50,11 @@ export const AgentStatuses = React.memo( tooltipContent={isolationFieldName} value={`${isIsolated}`} > - +
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 3d5d410abb87..1d04849b198a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -126,7 +126,7 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} - value={value as string} + value={typeof value === 'string' ? value : ''} /> ); } else if ( From 1dce600efecf22138ddd225c10b06c6464515a45 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 28 Jun 2021 21:27:10 -0500 Subject: [PATCH 65/65] Collect `host.os.platform` telemetry for APM (#103520) Fixes #97958. --- .../__snapshots__/apm_telemetry.test.ts.snap | 22 +++++++++ .../collect_data_telemetry/tasks.test.ts | 38 +++++++++++++++ .../collect_data_telemetry/tasks.ts | 48 +++++++++++++++++++ .../apm/server/lib/apm_telemetry/schema.ts | 2 + .../apm/server/lib/apm_telemetry/types.ts | 2 + .../schema/xpack_plugins.json | 33 +++++++++++-- 6 files changed, 141 insertions(+), 4 deletions(-) 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 d7fc8e6442f1..71b092916470 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 @@ -675,6 +675,17 @@ exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the } } }, + "host": { + "properties": { + "os": { + "properties": { + "platform": { + "type": "keyword" + } + } + } + } + }, "counts": { "properties": { "transaction": { @@ -967,6 +978,17 @@ exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the } } }, + "host": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "processor_events": { "properties": { "took": { 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 129da7109786..4bfac442b4a3 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 @@ -209,6 +209,44 @@ describe('data telemetry collection tasks', () => { }); }); + describe('host', () => { + const task = tasks.find((t) => t.name === 'host'); + + it('returns a map of host provider data', async () => { + const search = jest.fn().mockResolvedValueOnce({ + aggregations: { + platform: { + buckets: [ + { doc_count: 1, key: 'linux' }, + { doc_count: 1, key: 'windows' }, + { doc_count: 1, key: 'macos' }, + ], + }, + }, + }); + + expect(await task?.executor({ indices, search } as any)).toEqual({ + host: { + os: { platform: ['linux', 'windows', 'macos'] }, + }, + }); + }); + + describe('with no results', () => { + it('returns an empty map', async () => { + const search = jest.fn().mockResolvedValueOnce({}); + + expect(await task?.executor({ indices, search } as any)).toEqual({ + host: { + os: { + platform: [], + }, + }, + }); + }); + }); + }); + describe('processor_events', () => { const task = tasks.find((t) => t.name === 'processor_events'); 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 3d5b4b754e4a..fd341565c235 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 @@ -20,6 +20,7 @@ import { CONTAINER_ID, ERROR_GROUP_ID, HOST_NAME, + HOST_OS_PLATFORM, OBSERVER_HOSTNAME, PARENT_ID, POD_NAME, @@ -293,6 +294,53 @@ export const tasks: TelemetryTask[] = [ return { cloud }; }, }, + { + name: 'host', + executor: async ({ indices, search }) => { + function getBucketKeys({ + buckets, + }: { + buckets: Array<{ + doc_count: number; + key: string | number; + }>; + }) { + return buckets.map((bucket) => bucket.key as string); + } + + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'], + ], + body: { + size: 0, + timeout, + aggs: { + platform: { + terms: { + field: HOST_OS_PLATFORM, + }, + }, + }, + }, + }); + + const { aggregations } = response; + + if (!aggregations) { + return { host: { os: { platform: [] } } }; + } + const host = { + os: { + platform: getBucketKeys(aggregations.platform), + }, + }; + return { host }; + }, + }, { name: 'environments', executor: async ({ indices, search }) => { diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts index 0b1bc3d50d4c..b04f64c6bccf 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts @@ -135,6 +135,7 @@ export const apmSchema: MakeSchemaFrom = { provider: { type: 'array', items: { type: 'keyword' } }, region: { type: 'array', items: { type: 'keyword' } }, }, + host: { os: { platform: { type: 'array', items: { type: 'keyword' } } } }, counts: { transaction: timeframeMapSchema, span: timeframeMapSchema, @@ -185,6 +186,7 @@ export const apmSchema: MakeSchemaFrom = { tasks: { aggregated_transactions: { took: { ms: long } }, cloud: { took: { ms: long } }, + host: { took: { ms: long } }, processor_events: { took: { ms: long } }, agent_configuration: { took: { ms: long } }, services: { took: { ms: long } }, 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 6dc829425ead..cd4e80ff6bf6 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -52,6 +52,7 @@ export interface APMUsage { provider: string[]; region: string[]; }; + host: { os: { platform: string[] } }; counts: { transaction: TimeframeMap; span: TimeframeMap; @@ -132,6 +133,7 @@ export interface APMUsage { tasks: Record< | 'aggregated_transactions' | 'cloud' + | 'host' | 'processor_events' | 'agent_configuration' | 'services' diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index bab4244139df..5a8b30c5c0f2 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -31,10 +31,10 @@ "__index": { "type": "long" }, - "__swimlane": { + "__pagerduty": { "type": "long" }, - "__pagerduty": { + "__swimlane": { "type": "long" }, "__server-log": { @@ -71,10 +71,10 @@ "__index": { "type": "long" }, - "__swimlane": { + "__pagerduty": { "type": "long" }, - "__pagerduty": { + "__swimlane": { "type": "long" }, "__server-log": { @@ -1260,6 +1260,20 @@ } } }, + "host": { + "properties": { + "os": { + "properties": { + "platform": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + } + } + }, "counts": { "properties": { "transaction": { @@ -1552,6 +1566,17 @@ } } }, + "host": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "processor_events": { "properties": { "took": {