diff --git a/.eslintrc.js b/.eslintrc.js index 6cc62dac1741c..32f59c4d6b3db 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -888,7 +888,7 @@ module.exports = { { // typescript only for front and back end files: [ - 'x-pack/{,legacy/}plugins/{alerting,alerting_builtins,actions,task_manager,event_log}/**/*.{ts,tsx}', + 'x-pack/{,legacy/}plugins/{alerts,alerting_builtins,actions,task_manager,event_log}/**/*.{ts,tsx}', ], rules: { '@typescript-eslint/no-explicit-any': 'error', diff --git a/docs/apm/apm-alerts.asciidoc b/docs/apm/apm-alerts.asciidoc index 75ce5f56c96c6..bc5e1ccc1dd55 100644 --- a/docs/apm/apm-alerts.asciidoc +++ b/docs/apm/apm-alerts.asciidoc @@ -15,15 +15,20 @@ and enables central management of all alerts from <>. + +The APM app supports two different types of threshold alerts: transaction duration, and error rate. Below, we'll create one of each. [float] [[apm-create-transaction-alert]] === Create a transaction duration alert -This guide creates an alert for the `opbeans-java` service based on the following criteria: +Transaction duration alerts trigger when the duration of a specific transaction type in a service exceeds a defined threshold. +This guide will create an alert for the `opbeans-java` service based on the following criteria: +* Environment: Production * Transaction type: `transaction.type:request` * Average request is above `1500ms` for the last 5 minutes * Check every 10 minutes, and repeat the alert every 30 minutes @@ -52,14 +57,22 @@ Enter a name for the connector, and paste the webhook URL. See Slack's webhook documentation if you need to create one. +Add a message body in markdown format. +You can use the https://mustache.github.io/[Mustache] template syntax, i.e., `{{variable}}` +to pass alert values at the time a condition is detected to an action. +A list of available variables can be accessed by selecting the +**add variable** button image:apm/images/add-variable.png[add variable button]. + Select **Save**. The alert has been created and is now active! [float] [[apm-create-error-alert]] === Create an error rate alert +Error rate alerts trigger when the number of errors in a service exceeds a defined threshold. This guide creates an alert for the `opbeans-python` service based on the following criteria: +* Environment: Production * Error rate is above 25 for the last minute * Check every 1 minute, and repeat the alert every 10 minutes * Send the alert via email to the `opbeans-python` team @@ -81,6 +94,12 @@ Based on the alert criteria, define the following alert details: Select the **Email** action type and click **Create a connector**. Fill out the required details: sender, host, port, etc., and click **save**. +Add a message body in markdown format. +You can use the https://mustache.github.io/[Mustache] template syntax, i.e., `{{variable}}` +to pass alert values at the time a condition is detected to an action. +A list of available variables can be accessed by selecting the +**add variable** button image:apm/images/add-variable.png[add variable button]. + Select **Save**. The alert has been created and is now active! [float] diff --git a/docs/apm/images/add-variable.png b/docs/apm/images/add-variable.png new file mode 100644 index 0000000000000..860ab66f22f4e Binary files /dev/null and b/docs/apm/images/add-variable.png differ diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.gettime.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.gettime.md new file mode 100644 index 0000000000000..54e7cf92f500c --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.gettime.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [getTime](./kibana-plugin-plugins-data-server.gettime.md) + +## getTime() function + +Signature: + +```typescript +export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: { + forceNow?: Date; + fieldName?: string; +}): import("../..").RangeFilter | undefined; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| indexPattern | IIndexPattern | undefined | | +| timeRange | TimeRange | | +| options | {
forceNow?: Date;
fieldName?: string;
} | | + +Returns: + +`import("../..").RangeFilter | undefined` + 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 f492ba2843a69..c80112fb17dde 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 @@ -23,6 +23,7 @@ | Function | Description | | --- | --- | | [getDefaultSearchParams(config)](./kibana-plugin-plugins-data-server.getdefaultsearchparams.md) | | +| [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-server.gettime.md) | | | [parseInterval(interval)](./kibana-plugin-plugins-data-server.parseinterval.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-server.plugin.md) | Static code to be shared externally | | [shouldReadFieldFromDocValues(aggregatable, esType)](./kibana-plugin-plugins-data-server.shouldreadfieldfromdocvalues.md) | | diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index 88e84fc87ae53..aaa46ab74714f 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -38,6 +38,7 @@ export const ReactDomServer = require('react-dom/server'); export const ReactIntl = require('react-intl'); export const ReactRouter = require('react-router'); // eslint-disable-line export const ReactRouterDom = require('react-router-dom'); +export const StyledComponents = require('styled-components'); Moment.tz.load(require('moment-timezone/data/packed/latest.json')); diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js index 301d176555847..596c31820e80d 100644 --- a/packages/kbn-ui-shared-deps/index.js +++ b/packages/kbn-ui-shared-deps/index.js @@ -42,6 +42,7 @@ exports.externals = { 'react-intl': '__kbnSharedDeps__.ReactIntl', 'react-router': '__kbnSharedDeps__.ReactRouter', 'react-router-dom': '__kbnSharedDeps__.ReactRouterDom', + 'styled-components': '__kbnSharedDeps__.StyledComponents', '@kbn/monaco': '__kbnSharedDeps__.KbnMonaco', // this is how plugins/consumers from npm load monaco 'monaco-editor/esm/vs/editor/editor.api': '__kbnSharedDeps__.MonacoBarePluginApi', diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index be008c7a6474b..0e3bb235c3d9f 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -26,20 +26,22 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "react-intl": "^2.8.0", + "react-is": "^16.8.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "regenerator-runtime": "^0.13.3", "rxjs": "^6.5.5", + "styled-components": "^5.1.0", "symbol-observable": "^1.2.0", "whatwg-fetch": "^3.0.0" }, "devDependencies": { "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", - "loader-utils": "^1.2.3", - "val-loader": "^1.1.1", "css-loader": "^3.4.2", "del": "^5.1.0", + "loader-utils": "^1.2.3", + "val-loader": "^1.1.1", "webpack": "^4.41.5" } } diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index b40e02b709d30..0fb45fcc739d4 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -26,6 +26,5 @@ export * from './kbn_field_types'; export * from './query'; export * from './search'; export * from './search/aggs'; -export * from './timefilter'; export * from './types'; export * from './utils'; diff --git a/src/plugins/data/common/query/index.ts b/src/plugins/data/common/query/index.ts index 4e90f6f8bb83e..b0dfbbb82355e 100644 --- a/src/plugins/data/common/query/index.ts +++ b/src/plugins/data/common/query/index.ts @@ -18,5 +18,6 @@ */ export * from './filter_manager'; +export * from './timefilter'; export * from './types'; export * from './is_query'; diff --git a/src/plugins/data/public/query/timefilter/get_time.test.ts b/src/plugins/data/common/query/timefilter/get_time.test.ts similarity index 100% rename from src/plugins/data/public/query/timefilter/get_time.test.ts rename to src/plugins/data/common/query/timefilter/get_time.test.ts diff --git a/src/plugins/data/public/query/timefilter/get_time.ts b/src/plugins/data/common/query/timefilter/get_time.ts similarity index 90% rename from src/plugins/data/public/query/timefilter/get_time.ts rename to src/plugins/data/common/query/timefilter/get_time.ts index 3706972ce4a2e..6e4eda95accc7 100644 --- a/src/plugins/data/public/query/timefilter/get_time.ts +++ b/src/plugins/data/common/query/timefilter/get_time.ts @@ -18,14 +18,16 @@ */ import dateMath from '@elastic/datemath'; -import { IIndexPattern } from '../..'; -import { TimeRange, buildRangeFilter } from '../../../common'; +import { buildRangeFilter, IIndexPattern, TimeRange, TimeRangeBounds } from '../..'; interface CalculateBoundsOptions { forceNow?: Date; } -export function calculateBounds(timeRange: TimeRange, options: CalculateBoundsOptions = {}) { +export function calculateBounds( + timeRange: TimeRange, + options: CalculateBoundsOptions = {} +): TimeRangeBounds { return { min: dateMath.parse(timeRange.from, { forceNow: options.forceNow }), max: dateMath.parse(timeRange.to, { roundUp: true, forceNow: options.forceNow }), diff --git a/src/plugins/data/common/timefilter/index.ts b/src/plugins/data/common/query/timefilter/index.ts similarity index 92% rename from src/plugins/data/common/timefilter/index.ts rename to src/plugins/data/common/query/timefilter/index.ts index e0c509e119fda..55739511a0ef5 100644 --- a/src/plugins/data/common/timefilter/index.ts +++ b/src/plugins/data/common/query/timefilter/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { isTimeRange } from './is_time_range'; +export * from './get_time'; +export * from './is_time_range'; diff --git a/src/plugins/data/common/timefilter/is_time_range.ts b/src/plugins/data/common/query/timefilter/is_time_range.ts similarity index 100% rename from src/plugins/data/common/timefilter/is_time_range.ts rename to src/plugins/data/common/query/timefilter/is_time_range.ts diff --git a/src/plugins/data/common/timefilter/types.ts b/src/plugins/data/common/query/timefilter/types.ts similarity index 88% rename from src/plugins/data/common/timefilter/types.ts rename to src/plugins/data/common/query/timefilter/types.ts index b197b16e67dd1..60008ce6054e1 100644 --- a/src/plugins/data/common/timefilter/types.ts +++ b/src/plugins/data/common/query/timefilter/types.ts @@ -17,6 +17,8 @@ * under the License. */ +import { Moment } from 'moment'; + export interface RefreshInterval { pause: boolean; value: number; @@ -27,3 +29,8 @@ export interface TimeRange { to: string; mode?: 'absolute' | 'relative'; } + +export interface TimeRangeBounds { + min: Moment | undefined; + max: Moment | undefined; +} diff --git a/src/plugins/data/common/query/types.ts b/src/plugins/data/common/query/types.ts index 61b5d5b2b7b4a..6b34a1baf293b 100644 --- a/src/plugins/data/common/query/types.ts +++ b/src/plugins/data/common/query/types.ts @@ -17,6 +17,8 @@ * under the License. */ +export * from './timefilter/types'; + export interface Query { query: string | { [key: string]: any }; language: string; diff --git a/src/plugins/data/common/types.ts b/src/plugins/data/common/types.ts index 93629c3dbaf62..e2ec1a031b0ca 100644 --- a/src/plugins/data/common/types.ts +++ b/src/plugins/data/common/types.ts @@ -17,7 +17,6 @@ * under the License. */ -export * from './timefilter/types'; export * from './query/types'; export * from './kbn_field_types/types'; export * from './index_patterns/types'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index efce8d2c021c9..89b0d7e0303b9 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -421,7 +421,6 @@ export { connectToQueryState, syncQueryStateWithUrl, QueryState, - getTime, getQueryLog, getDefaultQuery, FilterManager, @@ -435,6 +434,7 @@ export { } from './query'; export { + getTime, // kbn field types castEsToKbnFieldTypeName, getKbnTypeNames, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 2901bf767520b..51f96f10aa7c7 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -146,7 +146,6 @@ export class DataPublicPlugin implements Plugin { - const forceNow = parseQueryString().forceNow as string; - if (!forceNow) { - return; - } - - const ticks = Date.parse(forceNow); - if (isNaN(ticks)) { - throw new Error(`forceNow query parameter, ${forceNow}, can't be parsed by Date.parse`); - } - return new Date(ticks); + return getForceNow(); }; } diff --git a/src/plugins/data/public/query/timefilter/types.ts b/src/plugins/data/public/query/timefilter/types.ts index 8b8deea43f808..d47a9cbb3bd60 100644 --- a/src/plugins/data/public/query/timefilter/types.ts +++ b/src/plugins/data/public/query/timefilter/types.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ + import { Moment } from 'moment'; + import { TimeRange, RefreshInterval } from '../../../common'; export interface TimefilterConfig { @@ -32,7 +34,4 @@ export type InputTimeRange = to: Moment; }; -export interface TimeRangeBounds { - min: Moment | undefined; - max: Moment | undefined; -} +export { TimeRangeBounds } from '../../../common'; diff --git a/src/plugins/data/public/search/aggs/agg_types.ts b/src/plugins/data/public/search/aggs/agg_types.ts index 2af29d3600246..68542b66e6c35 100644 --- a/src/plugins/data/public/search/aggs/agg_types.ts +++ b/src/plugins/data/public/search/aggs/agg_types.ts @@ -18,7 +18,8 @@ */ import { IUiSettingsClient } from 'src/core/public'; -import { QuerySetup } from '../../query/query_service'; +import { TimeRange, TimeRangeBounds } from '../../../common'; +import { GetInternalStartServicesFn } from '../../types'; import { getCountMetricAgg } from './metrics/count'; import { getAvgMetricAgg } from './metrics/avg'; @@ -54,18 +55,16 @@ import { getBucketAvgMetricAgg } from './metrics/bucket_avg'; import { getBucketMinMetricAgg } from './metrics/bucket_min'; import { getBucketMaxMetricAgg } from './metrics/bucket_max'; -import { GetInternalStartServicesFn } from '../../types'; - export interface AggTypesDependencies { - uiSettings: IUiSettingsClient; - query: QuerySetup; + calculateBounds: (timeRange: TimeRange) => TimeRangeBounds; getInternalStartServices: GetInternalStartServicesFn; + uiSettings: IUiSettingsClient; } export const getAggTypes = ({ - uiSettings, - query, + calculateBounds, getInternalStartServices, + uiSettings, }: AggTypesDependencies) => ({ metrics: [ getCountMetricAgg({ getInternalStartServices }), @@ -91,7 +90,7 @@ export const getAggTypes = ({ getGeoCentroidMetricAgg({ getInternalStartServices }), ], buckets: [ - getDateHistogramBucketAgg({ uiSettings, query, getInternalStartServices }), + getDateHistogramBucketAgg({ calculateBounds, uiSettings, getInternalStartServices }), getHistogramBucketAgg({ uiSettings, getInternalStartServices }), getRangeBucketAgg({ getInternalStartServices }), getDateRangeBucketAgg({ uiSettings, getInternalStartServices }), diff --git a/src/plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts b/src/plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts index bcdc003707e75..518bdbfe0c135 100644 --- a/src/plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts +++ b/src/plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts @@ -30,7 +30,6 @@ import { import { BUCKET_TYPES } from '../bucket_agg_types'; import { RangeFilter } from '../../../../../common'; import { coreMock, notificationServiceMock } from '../../../../../../../core/public/mocks'; -import { queryServiceMock } from '../../../../query/mocks'; import { fieldFormatsServiceMock } from '../../../../field_formats/mocks'; import { InternalStartServices } from '../../../../types'; @@ -46,13 +45,13 @@ describe('AggConfig Filters', () => { const { uiSettings } = coreMock.createSetup(); aggTypesDependencies = { - uiSettings, - query: queryServiceMock.createSetupContract(), + calculateBounds: jest.fn(), getInternalStartServices: () => (({ fieldFormats: fieldFormatsServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), } as unknown) as InternalStartServices), + uiSettings, }; mockDataServices(); diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts index 5b0f2921570c2..e4c4bc0cedc3c 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts @@ -27,28 +27,30 @@ import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterDateHistogram } from './create_filter/date_histogram'; import { intervalOptions } from './_interval_options'; -import { dateHistogramInterval, TimeRange } from '../../../../common'; import { writeParams } from '../agg_params'; import { isMetricAggType } from '../metrics/metric_agg_type'; -import { KBN_FIELD_TYPES, UI_SETTINGS } from '../../../../common'; -import { TimefilterContract } from '../../../query'; -import { QuerySetup } from '../../../query/query_service'; +import { + dateHistogramInterval, + KBN_FIELD_TYPES, + TimeRange, + TimeRangeBounds, + UI_SETTINGS, +} from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; import { BaseAggParams } from '../types'; import { ExtendedBounds } from './lib/extended_bounds'; -const detectedTimezone = moment.tz.guess(); -const tzOffset = moment().format('Z'); +type CalculateBoundsFn = (timeRange: TimeRange) => TimeRangeBounds; const updateTimeBuckets = ( agg: IBucketDateHistogramAggConfig, - timefilter: TimefilterContract, + calculateBounds: CalculateBoundsFn, customBuckets?: IBucketDateHistogramAggConfig['buckets'] ) => { const bounds = agg.params.timeRange && (agg.fieldIsTimeField() || agg.params.interval === 'auto') - ? timefilter.calculateBounds(agg.params.timeRange) + ? calculateBounds(agg.params.timeRange) : undefined; const buckets = customBuckets || agg.buckets; buckets.setBounds(bounds); @@ -56,9 +58,9 @@ const updateTimeBuckets = ( }; export interface DateHistogramBucketAggDependencies { - uiSettings: IUiSettingsClient; - query: QuerySetup; + calculateBounds: CalculateBoundsFn; getInternalStartServices: GetInternalStartServicesFn; + uiSettings: IUiSettingsClient; } export interface IBucketDateHistogramAggConfig extends IBucketAggConfig { @@ -83,9 +85,9 @@ export interface AggParamsDateHistogram extends BaseAggParams { } export const getDateHistogramBucketAgg = ({ - uiSettings, - query, + calculateBounds, getInternalStartServices, + uiSettings, }: DateHistogramBucketAggDependencies) => new BucketAggType( { @@ -123,14 +125,13 @@ export const getDateHistogramBucketAgg = ({ get() { if (buckets) return buckets; - const { timefilter } = query.timefilter; buckets = new TimeBuckets({ 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), dateFormat: uiSettings.get('dateFormat'), 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), }); - updateTimeBuckets(this, timefilter, buckets); + updateTimeBuckets(this, calculateBounds, buckets); return buckets; }, @@ -195,8 +196,7 @@ export const getDateHistogramBucketAgg = ({ default: 'auto', options: intervalOptions, write(agg, output, aggs) { - const { timefilter } = query.timefilter; - updateTimeBuckets(agg, timefilter); + updateTimeBuckets(agg, calculateBounds); const { useNormalizedEsInterval, scaleMetricValues } = agg.params; const interval = agg.buckets.getInterval(useNormalizedEsInterval); @@ -257,6 +257,8 @@ export const getDateHistogramBucketAgg = ({ if (!tz) { // If the index pattern typeMeta data, didn't had a time zone assigned for the selected field use the configured tz const isDefaultTimezone = uiSettings.isDefault('dateFormat:tz'); + const detectedTimezone = moment.tz.guess(); + const tzOffset = moment().format('Z'); tz = isDefaultTimezone ? detectedTimezone || tzOffset : uiSettings.get('dateFormat:tz'); diff --git a/src/plugins/data/public/search/aggs/index.test.ts b/src/plugins/data/public/search/aggs/index.test.ts index 4864a8b9d013b..73068326ca97e 100644 --- a/src/plugins/data/public/search/aggs/index.test.ts +++ b/src/plugins/data/public/search/aggs/index.test.ts @@ -22,7 +22,6 @@ import { getAggTypes } from './index'; import { isBucketAggType } from './buckets/bucket_agg_type'; import { isMetricAggType } from './metrics/metric_agg_type'; -import { QueryStart } from '../../query'; import { FieldFormatsStart } from '../../field_formats'; import { InternalStartServices } from '../../types'; @@ -31,13 +30,13 @@ describe('AggTypesComponent', () => { const coreStart = coreMock.createSetup(); const aggTypes = getAggTypes({ - uiSettings: coreSetup.uiSettings, - query: {} as QueryStart, + calculateBounds: jest.fn(), getInternalStartServices: () => (({ notifications: coreStart.notifications, fieldFormats: {} as FieldFormatsStart, } as unknown) as InternalStartServices), + uiSettings: coreSetup.uiSettings, }); const { buckets, metrics } = aggTypes; diff --git a/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts b/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts index 836aaad2cda0c..385d0cd6c6b39 100644 --- a/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts +++ b/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts @@ -22,7 +22,6 @@ import { AggTypesRegistry, AggTypesRegistryStart } from '../agg_types_registry'; import { getAggTypes } from '../agg_types'; import { BucketAggType } from '../buckets/bucket_agg_type'; import { MetricAggType } from '../metrics/metric_agg_type'; -import { queryServiceMock } from '../../../query/mocks'; import { fieldFormatsServiceMock } from '../../../field_formats/mocks'; import { InternalStartServices } from '../../../types'; import { TimeBucketsConfig } from '../buckets/lib/time_buckets/time_buckets'; @@ -79,8 +78,7 @@ export function mockAggTypesRegistry | MetricAggTyp coreSetup.uiSettings.get = mockUiSettings; const aggTypes = getAggTypes({ - uiSettings: coreSetup.uiSettings, - query: queryServiceMock.createSetupContract(), + calculateBounds: jest.fn(), getInternalStartServices: () => (({ fieldFormats: fieldFormatsServiceMock.createStartContract(), @@ -88,6 +86,7 @@ export function mockAggTypesRegistry | MetricAggTyp uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, } as unknown) as InternalStartServices), + uiSettings: coreSetup.uiSettings, }); aggTypes.buckets.forEach((type) => registrySetup.registerBucket(type)); diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index d9ca378811e51..e9daec0b4191b 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -32,8 +32,16 @@ import { Adapters } from '../../../../../plugins/inspector/public'; import { IAggConfigs } from '../aggs'; import { ISearchSource } from '../search_source'; import { tabifyAggResponse } from '../tabify'; -import { Filter, Query, TimeRange, IIndexPattern, isRangeFilter } from '../../../common'; -import { FilterManager, calculateBounds, getTime } from '../../query'; +import { + calculateBounds, + Filter, + getTime, + IIndexPattern, + isRangeFilter, + Query, + TimeRange, +} from '../../../common'; +import { FilterManager } from '../../query'; import { getFieldFormats, getIndexPatterns, diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 992979ad0dbb0..134dc89c2421a 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -25,10 +25,11 @@ import { ExpressionsSetup } from '../../../../plugins/expressions/public'; import { createSearchSource, SearchSource, SearchSourceDependencies } from './search_source'; import { TStrategyTypes } from './strategy_types'; import { getEsClient, LegacyApiCaller } from './legacy'; +import { getForceNow } from '../query/timefilter/lib/get_force_now'; +import { calculateBounds, TimeRange } from '../../common/query'; import { ES_SEARCH_STRATEGY, DEFAULT_SEARCH_STRATEGY } from '../../common/search'; import { esSearchStrategyProvider } from './es_search'; import { IndexPatternsContract } from '../index_patterns/index_patterns'; -import { QuerySetup } from '../query'; import { GetInternalStartServicesFn } from '../types'; import { SearchInterceptor } from './search_interceptor'; import { @@ -44,7 +45,6 @@ interface SearchServiceSetupDependencies { expressions: ExpressionsSetup; getInternalStartServices: GetInternalStartServicesFn; packageInfo: PackageInfo; - query: QuerySetup; } interface SearchServiceStartDependencies { @@ -83,9 +83,16 @@ export class SearchService implements Plugin { return strategy; }; + /** + * getForceNow uses window.location, so we must have a separate implementation + * of calculateBounds on the client and the server. + */ + private calculateBounds = (timeRange: TimeRange) => + calculateBounds(timeRange, { forceNow: getForceNow() }); + public setup( core: CoreSetup, - { expressions, packageInfo, query, getInternalStartServices }: SearchServiceSetupDependencies + { expressions, packageInfo, getInternalStartServices }: SearchServiceSetupDependencies ): ISearchSetup { this.esClient = getEsClient(core.injectedMetadata, core.http, packageInfo); @@ -98,9 +105,9 @@ export class SearchService implements Plugin { // register each agg type const aggTypes = getAggTypes({ - query, - uiSettings: core.uiSettings, + calculateBounds: this.calculateBounds, getInternalStartServices, + uiSettings: core.uiSettings, }); aggTypes.buckets.forEach((b) => aggTypesSetup.registerBucket(b)); aggTypes.metrics.forEach((m) => aggTypesSetup.registerMetric(m)); diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 19d028c1d41e1..6a4eb38b552ff 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -202,6 +202,7 @@ export { castEsToKbnFieldTypeName, // query Filter, + getTime, Query, // timefilter RefreshInterval, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 2bae7466076e1..24ce42e2c20ae 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -335,6 +335,14 @@ export function getDefaultSearchParams(config: SharedGlobalConfig): { restTotalHitsAsInt: boolean; }; +// Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: { + forceNow?: Date; + fieldName?: string; +}): import("../..").RangeFilter | undefined; + // @internal export function getTotalLoaded({ total, failed, successful }: ShardsResponse): { total: number; diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index e491cd7e4fe40..fb40b946d7fa3 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -570,8 +570,10 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async setScriptedFieldScript(script: string) { log.debug('set scripted field script = ' + script); const aceEditorCssSelector = '[data-test-subj="editorFieldScript"] .ace_editor'; - await find.clickByCssSelector(aceEditorCssSelector); - for (let i = 0; i < 1000; i++) { + const editor = await find.byCssSelector(aceEditorCssSelector); + await editor.click(); + const existingText = await editor.getVisibleText(); + for (let i = 0; i < existingText.length; i++) { await browser.pressKeys(browser.keys.BACK_SPACE); } await browser.pressKeys(...script.split('')); diff --git a/x-pack/package.json b/x-pack/package.json index c56590ba54ae8..0a8bc6f1e6f58 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -170,6 +170,7 @@ "pixelmatch": "^5.1.0", "proxyquire": "1.8.0", "react-docgen-typescript-loader": "^3.1.1", + "react-is": "^16.8.0", "react-test-renderer": "^16.12.0", "rxjs-marbles": "^5.0.6", "sass-loader": "^8.0.2", diff --git a/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature b/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature index eabfaf096731b..bc807d596a272 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature @@ -5,3 +5,35 @@ Feature: RUM Dashboard When the user inspects the real user monitoring tab Then should redirect to rum dashboard And should have correct client metrics + + Scenario Outline: Rum page filters + When the user filters by "" + Then it filters the client metrics + Examples: + | filterName | + | os | + | location | + + Scenario: Page load distribution percentiles + Given a user browses the APM UI application for RUM Data + When the user inspects the real user monitoring tab + Then should redirect to rum dashboard + And should display percentile for page load chart + + Scenario: Page load distribution chart tooltip + Given a user browses the APM UI application for RUM Data + When the user inspects the real user monitoring tab + Then should redirect to rum dashboard + And should display tooltip on hover + + Scenario: Page load distribution chart legends + Given a user browses the APM UI application for RUM Data + When the user inspects the real user monitoring tab + Then should redirect to rum dashboard + And should display chart legend + + Scenario: Breakdown filter + Given a user click page load breakdown filter + When the user selected the breakdown + Then breakdown series should appear in chart + diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js index dd96a57ef8c45..acccd86f3e4d7 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -9,9 +9,28 @@ module.exports = { }, "RUM Dashboard": { "Client metrics": { - "1": "62", + "1": "62 ", "2": "0.07 sec", "3": "0.01 sec" + }, + "Rum page filters (example #1)": { + "1": "15 ", + "2": "0.07 sec", + "3": "0.01 sec" + }, + "Rum page filters (example #2)": { + "1": "35 ", + "2": "0.07 sec", + "3": "0.01 sec" + }, + "Page load distribution percentiles": { + "1": "50th", + "2": "75th", + "3": "90th", + "4": "95th" + }, + "Page load distribution chart legends": { + "1": "Overall" } } } diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/page_load_dist.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/page_load_dist.ts new file mode 100644 index 0000000000000..809b22490abd6 --- /dev/null +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/page_load_dist.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'; + +/** The default time in ms to wait for a Cypress command to complete */ +export const DEFAULT_TIMEOUT = 60 * 1000; + +Given(`a user click page load breakdown filter`, () => { + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + const breakDownBtn = cy.get('[data-cy=breakdown-popover_pageLoad]'); + breakDownBtn.click(); +}); + +When(`the user selected the breakdown`, () => { + cy.get('[data-cy=filter-breakdown-item_Browser]').click(); + // click outside popover to close it + cy.get('[data-cy=pageLoadDist]').click(); +}); + +Then(`breakdown series should appear in chart`, () => { + cy.get('.euiLoadingChart').should('not.be.visible'); + cy.get('div.echLegendItem__label[title=Chrome] ') + .invoke('text') + .should('eq', 'Chrome'); +}); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts similarity index 50% rename from x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum_dashboard.ts rename to x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts index 38eadbf513032..24961ceb3b3c2 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts @@ -5,7 +5,7 @@ */ import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'; -import { loginAndWaitForPage } from '../../integration/helpers'; +import { loginAndWaitForPage } from '../../../integration/helpers'; /** The default time in ms to wait for a Cypress command to complete */ export const DEFAULT_TIMEOUT = 60 * 1000; @@ -41,3 +41,46 @@ Then(`should have correct client metrics`, () => { cy.get(clientMetrics).eq(0).invoke('text').snapshot(); }); + +Then(`should display percentile for page load chart`, () => { + const pMarkers = '[data-cy=percentile-markers] span'; + + cy.get('.euiLoadingChart').should('be.visible'); + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + + cy.get(pMarkers).eq(0).invoke('text').snapshot(); + + cy.get(pMarkers).eq(1).invoke('text').snapshot(); + + cy.get(pMarkers).eq(2).invoke('text').snapshot(); + + cy.get(pMarkers).eq(3).invoke('text').snapshot(); +}); + +Then(`should display chart legend`, () => { + const chartLegend = 'div.echLegendItem__label'; + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.be.visible'); + + cy.get(chartLegend).eq(0).invoke('text').snapshot(); +}); + +Then(`should display tooltip on hover`, () => { + cy.get('.euiLoadingChart').should('not.be.visible'); + + const pMarkers = '[data-cy=percentile-markers] span.euiToolTipAnchor'; + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.be.visible'); + + const marker = cy.get(pMarkers).eq(0); + marker.invoke('show'); + marker.trigger('mouseover', { force: true }); + cy.get('span[data-cy=percentileTooltipTitle]').should('be.visible'); +}); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_filters.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_filters.ts new file mode 100644 index 0000000000000..439003351aedb --- /dev/null +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_filters.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { When, Then } from 'cypress-cucumber-preprocessor/steps'; + +When(/^the user filters by "([^"]*)"$/, (filterName) => { + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + cy.get(`#local-filter-${filterName}`).click(); + + if (filterName === 'os') { + cy.get('button.euiSelectableListItem[title="Mac OS X"]').click(); + } else { + cy.get('button.euiSelectableListItem[title="DE"]').click(); + } + cy.get('[data-cy=applyFilter]').click(); +}); + +Then(`it filters the client metrics`, () => { + const clientMetrics = '[data-cy=client-metrics] .euiStat__title'; + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + + cy.get(clientMetrics).eq(2).invoke('text').snapshot(); + + cy.get(clientMetrics).eq(1).invoke('text').snapshot(); + + cy.get(clientMetrics).eq(0).invoke('text').snapshot(); + + cy.get('[data-cy=clearFilters]').click(); +}); diff --git a/x-pack/plugins/apm/e2e/ingest-data/replay.js b/x-pack/plugins/apm/e2e/ingest-data/replay.js index 3478039f39b50..483cc99df7470 100644 --- a/x-pack/plugins/apm/e2e/ingest-data/replay.js +++ b/x-pack/plugins/apm/e2e/ingest-data/replay.js @@ -35,6 +35,8 @@ const pLimit = require('p-limit'); const pRetry = require('p-retry'); const { argv } = require('yargs'); const ora = require('ora'); +const userAgents = require('./user_agents'); +const userIps = require('./rum_ips'); const APM_SERVER_URL = argv.serverUrl; const SECRET_TOKEN = argv.secretToken; @@ -66,7 +68,7 @@ function incrementSpinnerCount({ success }) { spinner.text = `Remaining: ${remaining}. Succeeded: ${requestProgress.succeeded}. Failed: ${requestProgress.failed}.`; } - +let iterIndex = 0; async function insertItem(item) { try { const url = `${APM_SERVER_URL}${item.url}`; @@ -74,6 +76,15 @@ async function insertItem(item) { 'content-type': 'application/x-ndjson', }; + if (item.url === '/intake/v2/rum/events') { + if (iterIndex === userAgents.length) { + iterIndex = 0; + } + headers['User-Agent'] = userAgents[iterIndex]; + headers['X-Forwarded-For'] = userIps[iterIndex]; + iterIndex++; + } + if (SECRET_TOKEN) { headers.Authorization = `Bearer ${SECRET_TOKEN}`; } @@ -113,7 +124,9 @@ async function init() { items.map(async (item) => { try { // retry 5 times with exponential backoff - await pRetry(() => limit(() => insertItem(item)), { retries: 5 }); + await pRetry(() => limit(() => insertItem(item)), { + retries: 5, + }); incrementSpinnerCount({ success: true }); } catch (e) { incrementSpinnerCount({ success: false }); diff --git a/x-pack/plugins/apm/e2e/ingest-data/rum_ips.js b/x-pack/plugins/apm/e2e/ingest-data/rum_ips.js new file mode 100644 index 0000000000000..59152cd90701b --- /dev/null +++ b/x-pack/plugins/apm/e2e/ingest-data/rum_ips.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const IPS = [ + '89.191.86.214', // check24.de + '167.40.79.24', // canada.ca + '151.101.130.217', // elastic.co + '185.143.68.17', + '151.101.130.217', + '185.143.68.17', + '185.143.68.17', + '151.101.130.217', + '185.143.68.17', +]; + +module.exports = IPS; diff --git a/x-pack/plugins/apm/e2e/ingest-data/user_agents.js b/x-pack/plugins/apm/e2e/ingest-data/user_agents.js new file mode 100644 index 0000000000000..923726c4736b6 --- /dev/null +++ b/x-pack/plugins/apm/e2e/ingest-data/user_agents.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +/* eslint-disable import/no-extraneous-dependencies */ + +const UserAgents = [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36', + 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/69.0.3497.105 Mobile/15E148 Safari/605.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36', + 'Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1', + 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36', +]; + +module.exports = UserAgents; diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index c325a72375359..69699b72a96df 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -27,7 +27,7 @@ import { ServiceOverview } from '../ServiceOverview'; import { TraceOverview } from '../TraceOverview'; import { RumOverview } from '../RumDashboard'; import { RumOverviewLink } from '../../shared/Links/apm/RumOverviewLink'; -import { EndUserExperienceLabel } from '../RumDashboard/translations'; +import { I18LABELS } from '../RumDashboard/translations'; function getHomeTabs({ serviceMapEnabled = true, @@ -111,7 +111,7 @@ export function Home({ tab }: Props) {

{selectedTab.name === 'rum-overview' - ? EndUserExperienceLabel + ? I18LABELS.endUserExperience : 'APM'}

diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx new file mode 100644 index 0000000000000..332cf40a465f9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { BreakdownGroup } from './BreakdownGroup'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, +} from '../../../../../common/elasticsearch_fieldnames'; + +interface Props { + id: string; + selectedBreakdowns: BreakdownItem[]; + onBreakdownChange: (values: BreakdownItem[]) => void; +} + +export const BreakdownFilter = ({ + id, + selectedBreakdowns, + onBreakdownChange, +}: Props) => { + const categories: BreakdownItem[] = [ + { + name: 'Browser', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'Browser'), + fieldName: USER_AGENT_NAME, + }, + { + name: 'OS', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'OS'), + fieldName: USER_AGENT_OS, + }, + { + name: 'Device', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'Device'), + fieldName: USER_AGENT_DEVICE, + }, + { + name: 'Location', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'Location'), + fieldName: CLIENT_GEO_COUNTRY_ISO_CODE, + }, + ]; + + return ( + { + onBreakdownChange(selValues); + }} + /> + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx new file mode 100644 index 0000000000000..007cdab0d2078 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiPopover, + EuiFilterButton, + EuiFilterGroup, + EuiPopoverTitle, + EuiFilterSelectItem, +} from '@elastic/eui'; +import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { I18LABELS } from '../translations'; + +export interface BreakdownGroupProps { + id: string; + disabled?: boolean; + items: BreakdownItem[]; + onChange: (values: BreakdownItem[]) => void; +} + +export const BreakdownGroup = ({ + id, + disabled, + onChange, + items, +}: BreakdownGroupProps) => { + const [isOpen, setIsOpen] = useState(false); + + const [activeItems, setActiveItems] = useState(items); + + useEffect(() => { + setActiveItems(items); + }, [items]); + + const getSelItems = () => activeItems.filter((item) => item.selected); + + const onFilterItemClick = useCallback( + (name: string) => (_event: MouseEvent) => { + setActiveItems((prevItems) => + prevItems.map((item) => ({ + ...item, + selected: name === item.name ? !item.selected : item.selected, + })) + ); + }, + [] + ); + + return ( + + 0} + numFilters={activeItems.length} + numActiveFilters={getSelItems().length} + hasActiveFilters={getSelItems().length !== 0} + iconType="arrowDown" + onClick={() => { + setIsOpen(!isOpen); + }} + size="s" + > + {I18LABELS.breakdown} + + } + closePopover={() => { + setIsOpen(false); + onChange(getSelItems()); + }} + data-cy={`breakdown-popover_${id}`} + id={id} + isOpen={isOpen} + ownFocus={true} + withTitle + zIndex={10000} + > + {I18LABELS.selectBreakdown} +
+ {activeItems.map(({ name, count, selected }) => ( + + {name} + + ))} +
+
+
+ ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx new file mode 100644 index 0000000000000..e17a8046b5c6a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import numeral from '@elastic/numeral'; +import { + Axis, + BrushEndListener, + Chart, + CurveType, + LineSeries, + ScaleType, + Settings, + TooltipValue, + TooltipValueFormatter, + DARK_THEME, + LIGHT_THEME, +} from '@elastic/charts'; +import { + EUI_CHARTS_THEME_DARK, + EUI_CHARTS_THEME_LIGHT, +} from '@elastic/eui/dist/eui_charts_theme'; +import { Position } from '@elastic/charts/dist/utils/commons'; +import styled from 'styled-components'; +import { PercentileAnnotations } from '../PageLoadDistribution/PercentileAnnotations'; +import { I18LABELS } from '../translations'; +import { ChartWrapper } from '../ChartWrapper'; +import { PercentileRange } from '../PageLoadDistribution'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { BreakdownSeries } from '../PageLoadDistribution/BreakdownSeries'; + +interface PageLoadData { + pageLoadDistribution: Array<{ x: number; y: number }>; + percentiles: Record | undefined; + minDuration: number; + maxDuration: number; +} + +interface Props { + onPercentileChange: (min: number, max: number) => void; + data?: PageLoadData | null; + breakdowns: BreakdownItem[]; + percentileRange: PercentileRange; + loading: boolean; +} + +const PageLoadChart = styled(Chart)` + .echAnnotation { + pointer-events: initial; + } +`; + +export function PageLoadDistChart({ + onPercentileChange, + data, + breakdowns, + loading, + percentileRange, +}: Props) { + const [breakdownLoading, setBreakdownLoading] = useState(false); + const onBrushEnd: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [minX, maxX] = x; + onPercentileChange(minX, maxX); + }; + + const headerFormatter: TooltipValueFormatter = (tooltip: TooltipValue) => { + return ( +
+

+ {tooltip.value} {I18LABELS.seconds} +

+
+ ); + }; + + const tooltipProps = { + headerFormatter, + }; + + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + {(!loading || data) && ( + + + + + numeral(d).format('0.0') + '%'} + /> + + {breakdowns.map(({ name, type }) => ( + { + setBreakdownLoading(bLoading); + }} + /> + ))} + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx new file mode 100644 index 0000000000000..934a985dd735a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import numeral from '@elastic/numeral'; +import { + Axis, + BarSeries, + BrushEndListener, + Chart, + niceTimeFormatByDay, + ScaleType, + SeriesNameFn, + Settings, + timeFormatter, +} from '@elastic/charts'; +import { DARK_THEME, LIGHT_THEME } from '@elastic/charts'; + +import { + EUI_CHARTS_THEME_DARK, + EUI_CHARTS_THEME_LIGHT, +} from '@elastic/eui/dist/eui_charts_theme'; +import moment from 'moment'; +import { Position } from '@elastic/charts/dist/utils/commons'; +import { I18LABELS } from '../translations'; +import { history } from '../../../../utils/history'; +import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; +import { ChartWrapper } from '../ChartWrapper'; +import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + data?: Array>; + loading: boolean; +} + +export function PageViewsChart({ data, loading }: Props) { + const formatter = timeFormatter(niceTimeFormatByDay(2)); + + const onBrushEnd: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [minX, maxX] = x; + + const rangeFrom = moment(minX).toISOString(); + const rangeTo = moment(maxX).toISOString(); + + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + rangeFrom, + rangeTo, + }), + }); + }; + + let breakdownAccessors: Set = new Set(); + if (data && data.length > 0) { + data.forEach((item) => { + breakdownAccessors = new Set([ + ...Array.from(breakdownAccessors), + ...Object.keys(item).filter((key) => key !== 'x'), + ]); + }); + } + + const customSeriesNaming: SeriesNameFn = ({ yAccessor }) => { + if (yAccessor === 'y') { + return I18LABELS.overall; + } + + return yAccessor; + }; + + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + {(!loading || data) && ( + + + + numeral(d).format('0.0 a')} + /> + + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index 8c0a7c6a91f67..776f74a169966 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -3,24 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -// @flow import * as React from 'react'; +import numeral from '@elastic/numeral'; import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { BackEndLabel, FrontEndLabel, PageViewsLabel } from '../translations'; - -export const formatBigValue = (val?: number | null, fixed?: number): string => { - if (val && val >= 1000) { - const result = val / 1000; - if (fixed) { - return result.toFixed(fixed) + 'k'; - } - return result + 'k'; - } - return val + ''; -}; +import { I18LABELS } from '../translations'; const ClFlexGroup = styled(EuiFlexGroup)` flex-direction: row; @@ -30,7 +19,7 @@ const ClFlexGroup = styled(EuiFlexGroup)` } `; -export const ClientMetrics = () => { +export function ClientMetrics() { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; @@ -57,7 +46,7 @@ export const ClientMetrics = () => { @@ -65,18 +54,22 @@ export const ClientMetrics = () => { + <>{numeral(data?.pageViews?.value).format('0 a') ?? '-'} + + } + description={I18LABELS.pageViews} isLoading={status !== 'success'} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx new file mode 100644 index 0000000000000..0c47ad24128ef --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect } from 'react'; +import { CurveType, LineSeries, ScaleType } from '@elastic/charts'; +import { PercentileRange } from './index'; +import { useBreakdowns } from './use_breakdowns'; + +interface Props { + field: string; + value: string; + percentileRange: PercentileRange; + onLoadingChange: (loading: boolean) => void; +} + +export const BreakdownSeries: FC = ({ + field, + value, + percentileRange, + onLoadingChange, +}) => { + const { data, status } = useBreakdowns({ + field, + value, + percentileRange, + }); + + useEffect(() => { + onLoadingChange(status !== 'success'); + }, [status, onLoadingChange]); + + return ( + <> + {data?.map(({ data: seriesData, name }) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx index 9c89b8bc161b7..38d53aebd1b7f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx @@ -13,6 +13,7 @@ import { } from '@elastic/charts'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import styled from 'styled-components'; +import { EuiToolTip } from '@elastic/eui'; interface Props { percentiles?: Record; @@ -21,15 +22,15 @@ interface Props { function generateAnnotationData( values?: Record ): LineAnnotationDatum[] { - return Object.entries(values ?? {}).map((value, index) => ({ - dataValue: value[1], + return Object.entries(values ?? {}).map((value) => ({ + dataValue: Math.round(value[1] / 1000), details: `${(+value[0]).toFixed(0)}`, })); } const PercentileMarker = styled.span` position: relative; - bottom: 140px; + bottom: 205px; `; export const PercentileAnnotations = ({ percentiles }: Props) => { @@ -43,6 +44,18 @@ export const PercentileAnnotations = ({ percentiles }: Props) => { }, }; + const PercentileTooltip = ({ + annotation, + }: { + annotation: LineAnnotationDatum; + }) => { + return ( + + {annotation.details}th Percentile + + ); + }; + return ( <> {dataValues.map((annotation, index) => ( @@ -52,7 +65,19 @@ export const PercentileAnnotations = ({ percentiles }: Props) => { domainType={AnnotationDomainTypes.XDomain} dataValues={[annotation]} style={style} - marker={{annotation.details}th} + hideTooltips={true} + marker={ + + } + content={ + Pages loaded: {Math.round(annotation.dataValue)} + } + > + <>{annotation.details}th + + + } /> ))} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index c7a0b64f6a8b8..c6b34c8b76698 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -6,48 +6,36 @@ import React, { useState } from 'react'; import { - EuiButton, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { - Axis, - Chart, - ScaleType, - LineSeries, - CurveType, - BrushEndListener, - Settings, - TooltipValueFormatter, - TooltipValue, -} from '@elastic/charts'; -import { Position } from '@elastic/charts/dist/utils/commons'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useFetcher } from '../../../../hooks/useFetcher'; -import { ChartWrapper } from '../ChartWrapper'; -import { PercentileAnnotations } from './PercentileAnnotations'; -import { - PageLoadDistLabel, - PageLoadTimeLabel, - PercPageLoadedLabel, - ResetZoomLabel, -} from '../translations'; +import { I18LABELS } from '../translations'; +import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; +import { PageLoadDistChart } from '../Charts/PageLoadDistChart'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; + +export interface PercentileRange { + min?: number | null; + max?: number | null; +} export const PageLoadDistribution = () => { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; - const [percentileRange, setPercentileRange] = useState<{ - min: string | null; - max: string | null; - }>({ + const [percentileRange, setPercentileRange] = useState({ min: null, max: null, }); + const [breakdowns, setBreakdowns] = useState([]); + const { data, status } = useFetcher( (callApmApi) => { if (start && end) { @@ -60,8 +48,8 @@ export const PageLoadDistribution = () => { uiFilters: JSON.stringify(uiFilters), ...(percentileRange.min && percentileRange.max ? { - minPercentile: percentileRange.min, - maxPercentile: percentileRange.max, + minPercentile: String(percentileRange.min), + maxPercentile: String(percentileRange.max), } : {}), }, @@ -72,73 +60,51 @@ export const PageLoadDistribution = () => { [end, start, uiFilters, percentileRange.min, percentileRange.max] ); - const onBrushEnd: BrushEndListener = ({ x }) => { - if (!x) { - return; - } - const [minX, maxX] = x; - setPercentileRange({ min: String(minX), max: String(maxX) }); - }; - - const headerFormatter: TooltipValueFormatter = (tooltip: TooltipValue) => { - return ( -
-

{tooltip.value} seconds

-
- ); - }; - - const tooltipProps = { - headerFormatter, + const onPercentileChange = (min: number, max: number) => { + setPercentileRange({ min: min * 1000, max: max * 1000 }); }; return ( -
+
-

{PageLoadDistLabel}

+

{I18LABELS.pageLoadDistribution}

- { setPercentileRange({ min: null, max: null }); }} - fill={percentileRange.min !== null && percentileRange.max !== null} + disabled={ + percentileRange.min === null && percentileRange.max === null + } > - {ResetZoomLabel} - + {I18LABELS.resetZoom} + -
- - - - - - - Number(d).toFixed(1) + ' %'} - /> - + - - + + + +
); }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts new file mode 100644 index 0000000000000..814cf977c9569 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useFetcher } from '../../../../hooks/useFetcher'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { PercentileRange } from './index'; + +interface Props { + percentileRange?: PercentileRange; + field: string; + value: string; +} + +export const useBreakdowns = ({ percentileRange, field, value }: Props) => { + const { urlParams, uiFilters } = useUrlParams(); + + const { start, end } = urlParams; + + const { min: minP, max: maxP } = percentileRange ?? {}; + + return useFetcher( + (callApmApi) => { + if (start && end && field && value) { + return callApmApi({ + pathname: '/api/apm/rum-client/page-load-distribution/breakdown', + params: { + query: { + start, + end, + breakdown: value, + uiFilters: JSON.stringify(uiFilters), + ...(minP && maxP + ? { + minPercentile: String(minP), + maxPercentile: String(maxP), + } + : {}), + }, + }, + }); + } + }, + [end, start, uiFilters, field, value, minP, maxP] + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index cc41bd4352947..34347f3f95947 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -4,34 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { EuiTitle } from '@elastic/eui'; -import { - Axis, - BarSeries, - BrushEndListener, - Chart, - niceTimeFormatByDay, - ScaleType, - Settings, - timeFormatter, -} from '@elastic/charts'; -import moment from 'moment'; -import { Position } from '@elastic/charts/dist/utils/commons'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useFetcher } from '../../../../hooks/useFetcher'; -import { ChartWrapper } from '../ChartWrapper'; -import { DateTimeLabel, PageViewsLabel } from '../translations'; -import { history } from '../../../../utils/history'; -import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; -import { formatBigValue } from '../ClientMetrics'; +import { I18LABELS } from '../translations'; +import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; +import { PageViewsChart } from '../Charts/PageViewsChart'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; export const PageViewsTrend = () => { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; + const [breakdowns, setBreakdowns] = useState([]); + const { data, status } = useFetcher( (callApmApi) => { if (start && end) { @@ -42,70 +30,41 @@ export const PageViewsTrend = () => { start, end, uiFilters: JSON.stringify(uiFilters), + ...(breakdowns.length > 0 + ? { + breakdowns: JSON.stringify(breakdowns), + } + : {}), }, }, }); } }, - [end, start, uiFilters] + [end, start, uiFilters, breakdowns] ); - const formatter = timeFormatter(niceTimeFormatByDay(2)); - - const onBrushEnd: BrushEndListener = ({ x }) => { - if (!x) { - return; - } - const [minX, maxX] = x; - - const rangeFrom = moment(minX).toISOString(); - const rangeTo = moment(maxX).toISOString(); - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - rangeFrom, - rangeTo, - }), - }); + const onBreakdownChange = (values: BreakdownItem[]) => { + setBreakdowns(values); }; return (
- -

{PageViewsLabel}

-
- - - - - formatBigValue(Number(d))} - /> - + + +

{I18LABELS.pageViews}

+
+
+ + -
-
+ + + +
); }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index e3fa7374afb38..cd50f3b575113 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { ClientMetrics } from './ClientMetrics'; import { PageViewsTrend } from './PageViewsTrend'; import { PageLoadDistribution } from './PageLoadDistribution'; -import { getWhatIsGoingOnLabel } from './translations'; +import { I18LABELS } from './translations'; import { useUrlParams } from '../../../hooks/useUrlParams'; export function RumDashboard() { @@ -32,7 +32,7 @@ export function RumDashboard() { return ( <> -

{getWhatIsGoingOnLabel(environmentLabel)}

+

{I18LABELS.getWhatIsGoingOn(environmentLabel)}

@@ -41,7 +41,7 @@ export function RumDashboard() { -

Page load times

+

{I18LABELS.pageLoadTimes}

diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index c2aed41a55c7d..4da7b59ec7fa5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -6,68 +6,55 @@ import { i18n } from '@kbn/i18n'; -export const EndUserExperienceLabel = i18n.translate( - 'xpack.apm.rum.dashboard.title', - { +export const I18LABELS = { + endUserExperience: i18n.translate('xpack.apm.rum.dashboard.title', { defaultMessage: 'End User Experience', - } -); - -export const getWhatIsGoingOnLabel = (environmentVal: string) => - i18n.translate('xpack.apm.rum.dashboard.environment.title', { - defaultMessage: `What's going on in {environmentVal}?`, - values: { environmentVal }, - }); - -export const BackEndLabel = i18n.translate('xpack.apm.rum.dashboard.backend', { - defaultMessage: 'Backend', -}); - -export const FrontEndLabel = i18n.translate( - 'xpack.apm.rum.dashboard.frontend', - { + }), + getWhatIsGoingOn: (environmentVal: string) => + i18n.translate('xpack.apm.rum.dashboard.environment.title', { + defaultMessage: `What's going on in {environmentVal}?`, + values: { environmentVal }, + }), + backEnd: i18n.translate('xpack.apm.rum.dashboard.backend', { + defaultMessage: 'Backend', + }), + frontEnd: i18n.translate('xpack.apm.rum.dashboard.frontend', { defaultMessage: 'Frontend', - } -); - -export const PageViewsLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pageViews', - { + }), + pageViews: i18n.translate('xpack.apm.rum.dashboard.pageViews', { defaultMessage: 'Page views', - } -); - -export const DateTimeLabel = i18n.translate( - 'xpack.apm.rum.dashboard.dateTime.label', - { + }), + dateTime: i18n.translate('xpack.apm.rum.dashboard.dateTime.label', { defaultMessage: 'Date / Time', - } -); - -export const PercPageLoadedLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pagesLoaded.label', - { + }), + percPageLoaded: i18n.translate('xpack.apm.rum.dashboard.pagesLoaded.label', { defaultMessage: 'Pages loaded', - } -); - -export const PageLoadTimeLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pageLoadTime.label', - { + }), + pageLoadTime: i18n.translate('xpack.apm.rum.dashboard.pageLoadTime.label', { defaultMessage: 'Page load time (seconds)', - } -); - -export const PageLoadDistLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pageLoadDistribution.label', - { - defaultMessage: 'Page load distribution', - } -); - -export const ResetZoomLabel = i18n.translate( - 'xpack.apm.rum.dashboard.resetZoom.label', - { + }), + pageLoadTimes: i18n.translate('xpack.apm.rum.dashboard.pageLoadTimes.label', { + defaultMessage: 'Page load times', + }), + pageLoadDistribution: i18n.translate( + 'xpack.apm.rum.dashboard.pageLoadDistribution.label', + { + defaultMessage: 'Page load distribution', + } + ), + resetZoom: i18n.translate('xpack.apm.rum.dashboard.resetZoom.label', { defaultMessage: 'Reset zoom', - } -); + }), + overall: i18n.translate('xpack.apm.rum.dashboard.overall.label', { + defaultMessage: 'Overall', + }), + selectBreakdown: i18n.translate('xpack.apm.rum.filterGroup.selectBreakdown', { + defaultMessage: 'Select breakdown', + }), + breakdown: i18n.translate('xpack.apm.rum.filterGroup.breakdown', { + defaultMessage: 'Breakdown', + }), + seconds: i18n.translate('xpack.apm.rum.filterGroup.seconds', { + defaultMessage: 'seconds', + }), +}; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx index 46fd5d925699b..167574f9aa00d 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx @@ -140,6 +140,7 @@ const Filter = ({ { diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx index 4aae82518ab3c..020b7481c68ea 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx @@ -80,6 +80,7 @@ const LocalUIFilters = ({ iconType="cross" flush="left" onClick={clearValues} + data-cy="clearFilters" > {i18n.translate('xpack.apm.clearFilters', { defaultMessage: 'Clear filters', diff --git a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap index 7d8f31aaeca7f..c006d01637483 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap @@ -67,13 +67,7 @@ exports[`rum client dashboard queries fetches page load distribution 1`] = ` Object { "body": Object { "aggs": Object { - "durationMinMax": Object { - "min": Object { - "field": "transaction.duration.us", - "missing": 0, - }, - }, - "durationPercentiles": Object { + "durPercentiles": Object { "percentiles": Object { "field": "transaction.duration.us", "percents": Array [ @@ -83,13 +77,12 @@ Object { 95, 99, ], - "script": Object { - "lang": "painless", - "params": Object { - "timeUnit": 1000, - }, - "source": "doc['transaction.duration.us'].value / params.timeUnit", - }, + }, + }, + "minDuration": Object { + "min": Object { + "field": "transaction.duration.us", + "missing": 0, }, }, }, @@ -139,13 +132,7 @@ Object { "body": Object { "aggs": Object { "pageViews": Object { - "aggs": Object { - "trans_count": Object { - "value_count": Object { - "field": "transaction.type", - }, - }, - }, + "aggs": Object {}, "auto_date_histogram": Object { "buckets": 50, "field": "@timestamp", diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts index 3c563946e4052..43af18999547d 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts @@ -32,23 +32,16 @@ export async function getPageLoadDistribution({ bool: projection.body.query.bool, }, aggs: { - durationMinMax: { + minDuration: { min: { field: 'transaction.duration.us', missing: 0, }, }, - durationPercentiles: { + durPercentiles: { percentiles: { field: 'transaction.duration.us', percents: [50, 75, 90, 95, 99], - script: { - lang: 'painless', - source: "doc['transaction.duration.us'].value / params.timeUnit", - params: { - timeUnit: 1000, - }, - }, }, }, }, @@ -66,31 +59,32 @@ export async function getPageLoadDistribution({ return null; } - const minDuration = (aggregations?.durationMinMax.value ?? 0) / 1000; + const minDuration = aggregations?.minDuration.value ?? 0; const minPerc = minPercentile ? +minPercentile : minDuration; - const maxPercentileQuery = - aggregations?.durationPercentiles.values['99.0'] ?? 100; + const maxPercQuery = aggregations?.durPercentiles.values['99.0'] ?? 10000; - const maxPerc = maxPercentile ? +maxPercentile : maxPercentileQuery; + const maxPerc = maxPercentile ? +maxPercentile : maxPercQuery; const pageDist = await getPercentilesDistribution(setup, minPerc, maxPerc); return { pageLoadDistribution: pageDist, - percentiles: aggregations?.durationPercentiles.values, + percentiles: aggregations?.durPercentiles.values, + minDuration: minPerc, + maxDuration: maxPerc, }; } const getPercentilesDistribution = async ( setup: Setup & SetupTimeRange & SetupUIFilters, - minPercentiles: number, - maxPercentile: number + minDuration: number, + maxDuration: number ) => { - const stepValue = (maxPercentile - minPercentiles) / 50; + const stepValue = (maxDuration - minDuration) / 50; const stepValues = []; - for (let i = 1; i < 50; i++) { - stepValues.push((stepValue * i + minPercentiles).toFixed(2)); + for (let i = 1; i < 51; i++) { + stepValues.push((stepValue * i + minDuration).toFixed(2)); } const projection = getRumOverviewProjection({ @@ -109,13 +103,6 @@ const getPercentilesDistribution = async ( field: 'transaction.duration.us', values: stepValues, keyed: false, - script: { - lang: 'painless', - source: "doc['transaction.duration.us'].value / params.timeUnit", - params: { - timeUnit: 1000, - }, - }, }, }, }, @@ -126,14 +113,11 @@ const getPercentilesDistribution = async ( const { aggregations } = await client.search(params); - const pageDist = (aggregations?.loadDistribution.values ?? []) as Array<{ - key: number; - value: number; - }>; + const pageDist = aggregations?.loadDistribution.values ?? []; return pageDist.map(({ key, value }, index: number, arr) => { return { - x: key, + x: Math.round(key / 1000), y: index === 0 ? value : value - arr[index - 1].value, }; }); diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts index 126605206d299..30b2677d3c217 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts @@ -11,15 +11,32 @@ import { SetupTimeRange, SetupUIFilters, } from '../helpers/setup_request'; +import { AggregationInputMap } from '../../../typings/elasticsearch/aggregations'; +import { BreakdownItem } from '../../../typings/ui_filters'; export async function getPageViewTrends({ setup, + breakdowns, }: { setup: Setup & SetupTimeRange & SetupUIFilters; + breakdowns?: string; }) { const projection = getRumOverviewProjection({ setup, }); + const breakdownAggs: AggregationInputMap = {}; + if (breakdowns) { + const breakdownList: BreakdownItem[] = JSON.parse(breakdowns); + breakdownList.forEach(({ name, type, fieldName }) => { + breakdownAggs[name] = { + terms: { + field: fieldName, + size: 9, + missing: 'Other', + }, + }; + }); + } const params = mergeProjection(projection, { body: { @@ -33,13 +50,7 @@ export async function getPageViewTrends({ field: '@timestamp', buckets: 50, }, - aggs: { - trans_count: { - value_count: { - field: 'transaction.type', - }, - }, - }, + aggs: breakdownAggs, }, }, }, @@ -50,8 +61,27 @@ export async function getPageViewTrends({ const response = await client.search(params); const result = response.aggregations?.pageViews.buckets ?? []; - return result.map(({ key, trans_count }) => ({ - x: key, - y: trans_count.value, - })); + + return result.map((bucket) => { + const { key: xVal, doc_count: bCount } = bucket; + const res: Record = { + x: xVal, + y: bCount, + }; + + Object.keys(breakdownAggs).forEach((bKey) => { + const categoryBuckets = (bucket[bKey] as any).buckets; + categoryBuckets.forEach( + ({ key, doc_count: docCount }: { key: string; doc_count: number }) => { + if (key === 'Other') { + res[key + `(${bKey})`] = docCount; + } else { + res[key] = docCount; + } + } + ); + }); + + return res; + }); } diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts new file mode 100644 index 0000000000000..5ae6bd1540f7c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getRumOverviewProjection } from '../../../common/projections/rum_overview'; +import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { + Setup, + SetupTimeRange, + SetupUIFilters, +} from '../helpers/setup_request'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, +} from '../../../common/elasticsearch_fieldnames'; + +export const getBreakdownField = (breakdown: string) => { + switch (breakdown) { + case 'Location': + return CLIENT_GEO_COUNTRY_ISO_CODE; + case 'Device': + return USER_AGENT_DEVICE; + case 'OS': + return USER_AGENT_OS; + case 'Browser': + default: + return USER_AGENT_NAME; + } +}; + +export const getPageLoadDistBreakdown = async ( + setup: Setup & SetupTimeRange & SetupUIFilters, + minDuration: number, + maxDuration: number, + breakdown: string +) => { + const stepValue = (maxDuration - minDuration) / 50; + const stepValues = []; + + for (let i = 1; i < 51; i++) { + stepValues.push((stepValue * i + minDuration).toFixed(2)); + } + + const projection = getRumOverviewProjection({ + setup, + }); + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: projection.body.query.bool, + }, + aggs: { + breakdowns: { + terms: { + field: getBreakdownField(breakdown), + size: 9, + }, + aggs: { + page_dist: { + percentile_ranks: { + field: 'transaction.duration.us', + values: stepValues, + keyed: false, + }, + }, + }, + }, + }, + }, + }); + + const { client } = setup; + + const { aggregations } = await client.search(params); + + const pageDistBreakdowns = aggregations?.breakdowns.buckets; + + return pageDistBreakdowns?.map(({ key, page_dist: pageDist }) => { + return { + name: String(key), + data: pageDist.values?.map(({ key: pKey, value }, index: number, arr) => { + return { + x: Math.round(pKey / 1000), + y: index === 0 ? value : value - arr[index - 1].value, + }; + }), + }; + }); +}; diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts index 25a559cb07a3d..7a3d9d94dec8e 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts @@ -14,8 +14,8 @@ import { TRANSACTION_URL, USER_AGENT_NAME, USER_AGENT_DEVICE, - CLIENT_GEO, USER_AGENT_OS, + CLIENT_GEO_COUNTRY_ISO_CODE, } from '../../../../common/elasticsearch_fieldnames'; const filtersByName = { @@ -77,7 +77,7 @@ const filtersByName = { title: i18n.translate('xpack.apm.localFilters.titles.location', { defaultMessage: 'Location', }), - fieldName: CLIENT_GEO, + fieldName: CLIENT_GEO_COUNTRY_ISO_CODE, }, os: { title: i18n.translate('xpack.apm.localFilters.titles.os', { diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 02be2e7e4dcdf..ed1c045616a27 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -75,6 +75,7 @@ import { rumClientMetricsRoute, rumPageViewsTrendRoute, rumPageLoadDistributionRoute, + rumPageLoadDistBreakdownRoute, } from './rum_client'; import { observabilityDashboardHasDataRoute, @@ -164,6 +165,7 @@ const createApmApi = () => { .add(rumOverviewLocalFiltersRoute) .add(rumPageViewsTrendRoute) .add(rumPageLoadDistributionRoute) + .add(rumPageLoadDistBreakdownRoute) .add(rumClientMetricsRoute) // Observability dashboard diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 9b5f6529b1783..75651f646a50d 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -11,6 +11,7 @@ import { getClientMetrics } from '../lib/rum_client/get_client_metrics'; import { rangeRt, uiFiltersRt } from './default_api_types'; import { getPageViewTrends } from '../lib/rum_client/get_page_view_trends'; import { getPageLoadDistribution } from '../lib/rum_client/get_page_load_distribution'; +import { getPageLoadDistBreakdown } from '../lib/rum_client/get_pl_dist_breakdown'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -45,13 +46,48 @@ export const rumPageLoadDistributionRoute = createRoute(() => ({ }, })); +export const rumPageLoadDistBreakdownRoute = createRoute(() => ({ + path: '/api/apm/rum-client/page-load-distribution/breakdown', + params: { + query: t.intersection([ + uiFiltersRt, + rangeRt, + percentileRangeRt, + t.type({ breakdown: t.string }), + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + query: { minPercentile, maxPercentile, breakdown }, + } = context.params; + + return getPageLoadDistBreakdown( + setup, + Number(minPercentile), + Number(maxPercentile), + breakdown + ); + }, +})); + export const rumPageViewsTrendRoute = createRoute(() => ({ path: '/api/apm/rum-client/page-view-trends', params: { - query: t.intersection([uiFiltersRt, rangeRt]), + query: t.intersection([ + uiFiltersRt, + rangeRt, + t.partial({ breakdowns: t.string }), + ]), }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getPageViewTrends({ setup }); + + const { + query: { breakdowns }, + } = context.params; + + return getPageViewTrends({ setup, breakdowns }); }, })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 6ee26caa4ef7c..a340aa24aebfb 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -35,6 +35,11 @@ type MetricsAggregationOptions = interface MetricsAggregationResponsePart { value: number | null; } +interface DateHistogramBucket { + doc_count: number; + key: number; + key_as_string: string; +} type GetCompositeKeys< TAggregationOptionsMap extends AggregationOptionsMap @@ -204,14 +209,8 @@ interface AggregationResponsePart< }; date_histogram: { buckets: Array< - { - doc_count: number; - key: number; - key_as_string: string; - } & BucketSubAggregationResponse< - TAggregationOptionsMap['aggs'], - TDocument - > + DateHistogramBucket & + BucketSubAggregationResponse >; }; avg: MetricsAggregationResponsePart; @@ -312,20 +311,18 @@ interface AggregationResponsePart< }; auto_date_histogram: { buckets: Array< - { - doc_count: number; - key: number; - key_as_string: string; - } & BucketSubAggregationResponse< - TAggregationOptionsMap['aggs'], - TDocument - > + DateHistogramBucket & + AggregationResponseMap >; interval: string; }; percentile_ranks: { - values: Record | Array<{ key: number; value: number }>; + values: TAggregationOptionsMap extends { + percentile_ranks: { keyed: false }; + } + ? Array<{ key: number; value: number }> + : Record; }; } diff --git a/x-pack/plugins/apm/typings/ui_filters.ts b/x-pack/plugins/apm/typings/ui_filters.ts index 3f03e80325b49..2a727dda7241d 100644 --- a/x-pack/plugins/apm/typings/ui_filters.ts +++ b/x-pack/plugins/apm/typings/ui_filters.ts @@ -11,3 +11,11 @@ export type UIFilters = { kuery?: string; environment?: string; } & { [key in LocalUIFilterName]?: string[] }; + +export interface BreakdownItem { + name: string; + count: number; + type: string; + fieldName: string; + selected?: boolean; +} diff --git a/x-pack/plugins/lens/server/routes/existing_fields.test.ts b/x-pack/plugins/lens/server/routes/existing_fields.test.ts index 33541c7206c53..728b78c8e97bc 100644 --- a/x-pack/plugins/lens/server/routes/existing_fields.test.ts +++ b/x-pack/plugins/lens/server/routes/existing_fields.test.ts @@ -172,4 +172,19 @@ describe('buildFieldList', () => { script: '2+2', }); }); + + it('handles missing mappings', () => { + const fields = buildFieldList(indexPattern, {}, fieldDescriptors); + expect(fields.every((f) => f.isAlias === false)).toEqual(true); + }); + + it('handles empty fieldDescriptors by skipping multi-mappings', () => { + const fields = buildFieldList(indexPattern, mappings, []); + expect(fields.find((f) => f.name === 'baz')).toMatchObject({ + isAlias: false, + isScript: false, + name: 'baz', + path: ['baz'], + }); + }); }); diff --git a/x-pack/plugins/lens/server/routes/existing_fields.ts b/x-pack/plugins/lens/server/routes/existing_fields.ts index 82185dd9ab429..7ab3cdceb2145 100644 --- a/x-pack/plugins/lens/server/routes/existing_fields.ts +++ b/x-pack/plugins/lens/server/routes/existing_fields.ts @@ -41,8 +41,7 @@ export interface Field { script?: string; } -// TODO: Pull this from kibana advanced settings -const metaFields = ['_source', '_id', '_type', '_index', '_score']; +const metaFields = ['_source', '_type']; export async function existingFieldsRoute(setup: CoreSetup) { const router = setup.http.createRouter(); @@ -137,6 +136,18 @@ async function fetchIndexPatternDefinition(indexPatternId: string, context: Requ indexPatternId ); const indexPatternTitle = indexPattern.attributes.title; + + if (indexPatternTitle.includes(':')) { + // Cross cluster search patterns include a colon, and we aren't able to fetch + // mapping information. + return { + indexPattern, + indexPatternTitle, + mappings: {}, + fieldDescriptors: [], + }; + } + // TODO: maybe don't use IndexPatternsFetcher at all, since we're only using it // to look up field values in the resulting documents. We can accomplish the same // using the mappings which we're also fetching here. @@ -166,10 +177,10 @@ async function fetchIndexPatternDefinition(indexPatternId: string, context: Requ */ export function buildFieldList( indexPattern: SavedObject, - mappings: MappingResult, + mappings: MappingResult | {}, fieldDescriptors: FieldDescriptor[] ): Field[] { - const aliasMap = Object.entries(Object.values(mappings)[0].mappings.properties) + const aliasMap = Object.entries(Object.values(mappings)[0]?.mappings.properties ?? {}) .map(([name, v]) => ({ ...v, name })) .filter((f) => f.type === 'alias') .reduce((acc, f) => { @@ -242,6 +253,7 @@ async function fetchIndexPatternStats({ body: { size: SAMPLE_SIZE, query, + sort: timeFieldName && fromDate && toDate ? [{ [timeFieldName]: 'desc' }] : [], // _source is required because we are also providing script fields. _source: '*', script_fields: scriptedFields.reduce((acc, field) => { diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 5af34b6a694e8..563e2e4ccc9f2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -48,6 +48,8 @@ const Windows: OSFields[] = [ name: 'windows 10.0', full: 'Windows 10', version: '10.0', + platform: 'Windows', + family: 'Windows', Ext: { variant: 'Windows Pro', }, @@ -56,6 +58,8 @@ const Windows: OSFields[] = [ name: 'windows 10.0', full: 'Windows Server 2016', version: '10.0', + platform: 'Windows', + family: 'Windows', Ext: { variant: 'Windows Server', }, @@ -64,6 +68,8 @@ const Windows: OSFields[] = [ name: 'windows 6.2', full: 'Windows Server 2012', version: '6.2', + platform: 'Windows', + family: 'Windows', Ext: { variant: 'Windows Server', }, @@ -72,6 +78,8 @@ const Windows: OSFields[] = [ name: 'windows 6.3', full: 'Windows Server 2012R2', version: '6.3', + platform: 'Windows', + family: 'Windows', Ext: { variant: 'Windows Server Release 2', }, @@ -316,6 +324,7 @@ export class EndpointDocGenerator { } private createHostData(): HostInfo { + const hostName = this.randomHostname(); return { agent: { version: this.randomVersion(), @@ -329,7 +338,9 @@ export class EndpointDocGenerator { }, host: { id: this.seededUUIDv4(), - hostname: this.randomHostname(), + hostname: hostName, + name: hostName, + architecture: this.randomString(10), ip: this.randomArray(3, () => this.randomIP()), mac: this.randomArray(3, () => this.randomMac()), os: this.randomChoice(OS), @@ -1016,9 +1027,7 @@ export class EndpointDocGenerator { ecs: { version: '1.4.0', }, - host: { - id: this.commonInfo.host.id, - }, + host: this.commonInfo.host, Endpoint: { policy: { applied: { diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index f5c3fd519c9c5..398e2710b3253 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -71,3 +71,19 @@ export const validateChildren = { legacyEndpointID: schema.maybe(schema.string()), }), }; + +/** + * Used to validate GET requests for 'entities' + */ +export const validateEntities = { + query: schema.object({ + /** + * Return the process entities related to the document w/ the matching `_id`. + */ + _id: schema.string(), + /** + * Indices to search in. + */ + indices: schema.arrayOf(schema.string()), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index 42f5f4b220da9..72839a8370495 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -179,6 +179,8 @@ export interface OSFields { full: string; name: string; version: string; + platform: string; + family: string; Ext: OSFieldsExt; } @@ -195,8 +197,10 @@ export interface OSFieldsExt { export interface Host { id: string; hostname: string; + name: string; ip: string[]; mac: string[]; + architecture: string; os: OSFields; } @@ -511,6 +515,11 @@ export interface EndpointEvent { export type ResolverEvent = EndpointEvent | LegacyEndpointEvent; +/** + * The response body for the resolver '/entity' index API + */ +export type ResolverEntityIndex = Array<{ entity_id: string }>; + /** * Takes a @kbn/config-schema 'schema' type and returns a type that represents valid inputs. * Similar to `TypeOf`, but allows strings as input for `schema.number()` (which is inline diff --git a/x-pack/plugins/security_solution/cypress/integration/detections.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts similarity index 98% rename from x-pack/plugins/security_solution/cypress/integration/detections.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts index 2e727be1fc9b4..c8c18696359f7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detections.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts @@ -9,7 +9,7 @@ import { SHOWING_ALERTS, ALERTS, TAKE_ACTION_POPOVER_BTN, -} from '../screens/detections'; +} from '../screens/alerts'; import { closeFirstAlert, @@ -24,13 +24,13 @@ import { waitForAlertsToBeLoaded, markInProgressFirstAlert, goToInProgressAlerts, -} from '../tasks/detections'; +} from '../tasks/alerts'; import { esArchiverLoad } from '../tasks/es_archiver'; import { loginAndWaitForPage } from '../tasks/login'; import { ALERTS_URL } from '../urls/navigation'; -describe('Detections', () => { +describe('Alerts', () => { context('Closing alerts', () => { beforeEach(() => { esArchiverLoad('alerts'); diff --git a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts similarity index 91% rename from x-pack/plugins/security_solution/cypress/integration/signal_detection_rules.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts index 72a86e3ffffc5..5cad0b9c3260c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts @@ -10,25 +10,25 @@ import { RULE_SWITCH, SECOND_RULE, SEVENTH_RULE, -} from '../screens/alert_detection_rules'; +} from '../screens/alerts_detection_rules'; import { - goToManageAlertDetectionRules, + goToManageAlertsDetectionRules, waitForAlertsPanelToBeLoaded, waitForAlertsIndexToBeCreated, -} from '../tasks/detections'; -import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; -import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +} from '../tasks/alerts'; import { activateRule, sortByActivatedRules, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForRuleToBeActivated, -} from '../tasks/alert_detection_rules'; +} from '../tasks/alerts_detection_rules'; +import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { ALERTS_URL } from '../urls/navigation'; -describe('Detection rules', () => { +describe('Alerts detection rules', () => { before(() => { esArchiverLoad('prebuilt_rules_loaded'); }); @@ -41,7 +41,7 @@ describe('Detection rules', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); cy.get(RULE_NAME) .eq(FIFTH_RULE) diff --git a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts similarity index 97% rename from x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_custom.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index 48d0c2e7238cd..9e9732a403f8f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -6,6 +6,15 @@ import { newRule, totalNumberOfPrebuiltRulesInEsArchive } from '../objects/rule'; +import { + CUSTOM_RULES_BTN, + RISK_SCORE, + RULE_NAME, + RULES_ROW, + RULES_TABLE, + SEVERITY, + SHOWING_RULES_TEXT, +} from '../screens/alerts_detection_rules'; import { ABOUT_FALSE_POSITIVES, ABOUT_INVESTIGATION_NOTES, @@ -28,26 +37,12 @@ import { SCHEDULE_RUNS, SCHEDULE_STEP, } from '../screens/rule_details'; -import { - CUSTOM_RULES_BTN, - RISK_SCORE, - RULE_NAME, - RULES_ROW, - RULES_TABLE, - SEVERITY, - SHOWING_RULES_TEXT, -} from '../screens/alert_detection_rules'; import { - createAndActivateRule, - fillAboutRuleAndContinue, - fillDefineCustomRuleWithImportedQueryAndContinue, -} from '../tasks/create_new_rule'; -import { - goToManageAlertDetectionRules, + goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, -} from '../tasks/detections'; +} from '../tasks/alerts'; import { changeToThreeHundredRowsPerPage, deleteFirstRule, @@ -58,7 +53,12 @@ import { selectNumberOfRules, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForRulesToBeLoaded, -} from '../tasks/alert_detection_rules'; +} from '../tasks/alerts_detection_rules'; +import { + createAndActivateRule, + fillAboutRuleAndContinue, + fillDefineCustomRuleWithImportedQueryAndContinue, +} from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; @@ -77,7 +77,7 @@ describe('Detection rules, custom', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); goToCreateNewRule(); fillDefineCustomRuleWithImportedQueryAndContinue(newRule); @@ -172,7 +172,7 @@ describe('Deletes custom rules', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); }); after(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts similarity index 88% rename from x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_export.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts index edb559bf6a279..25fc1fc3a7c11 100644 --- a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_export.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts @@ -5,13 +5,13 @@ */ import { - goToManageAlertDetectionRules, + goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, -} from '../tasks/detections'; +} from '../tasks/alerts'; +import { exportFirstRule } from '../tasks/alerts_detection_rules'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { exportFirstRule } from '../tasks/alert_detection_rules'; import { ALERTS_URL } from '../urls/navigation'; @@ -35,7 +35,7 @@ describe('Export rules', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); exportFirstRule(); cy.wait('@export').then((xhr) => { cy.readFile(EXPECTED_EXPORTED_RULE_FILE_PATH).then(($expectedExportedJson) => { diff --git a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_ml.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts similarity index 96% rename from x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_ml.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts index 3e0fc2e1b37fd..19957a53dd701 100644 --- a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_ml.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts @@ -6,6 +6,15 @@ import { machineLearningRule, totalNumberOfPrebuiltRulesInEsArchive } from '../objects/rule'; +import { + CUSTOM_RULES_BTN, + RISK_SCORE, + RULE_NAME, + RULE_SWITCH, + RULES_ROW, + RULES_TABLE, + SEVERITY, +} from '../screens/alerts_detection_rules'; import { ABOUT_FALSE_POSITIVES, ABOUT_MITRE, @@ -26,27 +35,12 @@ import { SCHEDULE_STEP, RULE_TYPE, } from '../screens/rule_details'; -import { - CUSTOM_RULES_BTN, - RISK_SCORE, - RULE_NAME, - RULE_SWITCH, - RULES_ROW, - RULES_TABLE, - SEVERITY, -} from '../screens/alert_detection_rules'; import { - createAndActivateRule, - fillAboutRuleAndContinue, - fillDefineMachineLearningRuleAndContinue, - selectMachineLearningRuleType, -} from '../tasks/create_new_rule'; -import { - goToManageAlertDetectionRules, + goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, -} from '../tasks/detections'; +} from '../tasks/alerts'; import { changeToThreeHundredRowsPerPage, filterByCustomRules, @@ -54,7 +48,13 @@ import { goToRuleDetails, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForRulesToBeLoaded, -} from '../tasks/alert_detection_rules'; +} from '../tasks/alerts_detection_rules'; +import { + createAndActivateRule, + fillAboutRuleAndContinue, + fillDefineMachineLearningRuleAndContinue, + selectMachineLearningRuleType, +} from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; @@ -73,7 +73,7 @@ describe('Detection rules, machine learning', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); goToCreateNewRule(); selectMachineLearningRuleType(); diff --git a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_prebuilt.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts similarity index 95% rename from x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_prebuilt.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts index f819c91a77374..d3cbb05d7fc17 100644 --- a/x-pack/plugins/security_solution/cypress/integration/signal_detection_rules_prebuilt.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts @@ -10,8 +10,13 @@ import { RELOAD_PREBUILT_RULES_BTN, RULES_ROW, RULES_TABLE, -} from '../screens/alert_detection_rules'; +} from '../screens/alerts_detection_rules'; +import { + goToManageAlertsDetectionRules, + waitForAlertsIndexToBeCreated, + waitForAlertsPanelToBeLoaded, +} from '../tasks/alerts'; import { changeToThreeHundredRowsPerPage, deleteFirstRule, @@ -22,12 +27,7 @@ import { waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForPrebuiltDetectionRulesToBeLoaded, waitForRulesToBeLoaded, -} from '../tasks/alert_detection_rules'; -import { - goToManageAlertDetectionRules, - waitForAlertsIndexToBeCreated, - waitForAlertsPanelToBeLoaded, -} from '../tasks/detections'; +} from '../tasks/alerts_detection_rules'; import { esArchiverLoadEmptyKibana, esArchiverUnloadEmptyKibana } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; @@ -35,7 +35,7 @@ import { ALERTS_URL } from '../urls/navigation'; import { totalNumberOfPrebuiltRules } from '../objects/rule'; -describe('Detection rules, prebuilt rules', () => { +describe('Alerts rules, prebuilt rules', () => { before(() => { esArchiverLoadEmptyKibana(); }); @@ -51,7 +51,7 @@ describe('Detection rules, prebuilt rules', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); loadPrebuiltDetectionRules(); waitForPrebuiltDetectionRulesToBeLoaded(); @@ -76,7 +76,7 @@ describe('Deleting prebuilt rules', () => { loginAndWaitForPageWithoutDateRange(ALERTS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - goToManageAlertDetectionRules(); + goToManageAlertsDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); loadPrebuiltDetectionRules(); waitForPrebuiltDetectionRulesToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/integration/detections_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts similarity index 90% rename from x-pack/plugins/security_solution/cypress/integration/detections_timeline.spec.ts rename to x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts index 91617981ab14c..10dc4fdd44486 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detections_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ALERT_ID } from '../screens/detections'; +import { ALERT_ID } from '../screens/alerts'; import { PROVIDER_BADGE } from '../screens/timeline'; import { expandFirstAlert, investigateFirstAlertInTimeline, waitForAlertsPanelToBeLoaded, -} from '../tasks/detections'; +} from '../tasks/alerts'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPage } from '../tasks/login'; import { ALERTS_URL } from '../urls/navigation'; -describe('Detections timeline', () => { +describe('Alerts timeline', () => { beforeEach(() => { esArchiverLoad('timeline_alerts'); loginAndWaitForPage(ALERTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts b/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts index 10759cc7de6e9..ce053d1ac7616 100644 --- a/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts @@ -12,8 +12,7 @@ import { hostIpFilter } from '../objects/filter'; import { HOSTS_URL } from '../urls/navigation'; import { waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; -// FAILING: https://github.com/elastic/kibana/issues/69595 -describe.skip('SearchBar', () => { +describe('SearchBar', () => { before(() => { loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index 53460d1dfcbc6..6c456c2f5e100 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -234,7 +234,7 @@ describe('url state', () => { cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"'); }); - it.skip('sets and reads the url state for timeline by id', () => { + it('sets and reads the url state for timeline by id', () => { loginAndWaitForPage(HOSTS_URL); openTimeline(); executeTimelineKQL('host.name: *'); @@ -258,7 +258,7 @@ describe('url state', () => { expect(matched).to.have.lengthOf(1); closeTimeline(); cy.visit('/app/home'); - cy.visit(`/app/security/timelines?timeline=(id:'${newTimelineId}',isOpen:!t)`); + cy.visit(`/app/security/timelines?timeline=(id:%27${newTimelineId}%27,isOpen:!t)`); cy.contains('a', 'Security'); cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).invoke('text').should('not.equal', 'Updating'); cy.get(TIMELINE_TITLE).should('be.visible'); diff --git a/x-pack/plugins/security_solution/cypress/screens/detections.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts similarity index 100% rename from x-pack/plugins/security_solution/cypress/screens/detections.ts rename to x-pack/plugins/security_solution/cypress/screens/alerts.ts diff --git a/x-pack/plugins/security_solution/cypress/screens/alert_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts similarity index 100% rename from x-pack/plugins/security_solution/cypress/screens/alert_detection_rules.ts rename to x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts diff --git a/x-pack/plugins/security_solution/cypress/tasks/detections.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts similarity index 97% rename from x-pack/plugins/security_solution/cypress/tasks/detections.ts rename to x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 8b1e8b41b6da1..ebd37b522924f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/detections.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -22,7 +22,7 @@ import { OPEN_SELECTED_ALERTS_BTN, MARK_ALERT_IN_PROGRESS_BTN, MARK_SELECTED_ALERTS_IN_PROGRESS_BTN, -} from '../screens/detections'; +} from '../screens/alerts'; import { REFRESH_BUTTON } from '../screens/security_header'; export const closeFirstAlert = () => { @@ -43,7 +43,7 @@ export const goToClosedAlerts = () => { cy.get(CLOSED_ALERTS_FILTER_BTN).click({ force: true }); }; -export const goToManageAlertDetectionRules = () => { +export const goToManageAlertsDetectionRules = () => { cy.get(MANAGE_ALERT_DETECTION_RULES_BTN).should('exist').click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alert_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts similarity index 98% rename from x-pack/plugins/security_solution/cypress/tasks/alert_detection_rules.ts rename to x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 9710e0e808ac5..79756621ef502 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alert_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -24,7 +24,7 @@ import { SORT_RULES_BTN, THREE_HUNDRED_ROWS, EXPORT_ACTION_BTN, -} from '../screens/alert_detection_rules'; +} from '../screens/alerts_detection_rules'; export const activateRule = (rulePosition: number) => { cy.get(RULE_SWITCH).eq(rulePosition).click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts b/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts index 01d712460b447..a32c38a97fce5 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts @@ -23,6 +23,8 @@ export const openAddFilterPopover = () => { }; export const fillAddFilterForm = ({ key, value }: SearchBarFilter) => { + cy.get(ADD_FILTER_FORM_FIELD_INPUT).should('exist'); + cy.get(ADD_FILTER_FORM_FIELD_INPUT).should('be.visible'); cy.get(ADD_FILTER_FORM_FIELD_INPUT).type(key); cy.get(ADD_FILTER_FORM_FIELD_INPUT).click(); cy.get(ADD_FILTER_FORM_FIELD_OPTION(key)).click({ force: true }); diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/alert_details.test.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/alert_details.test.tsx deleted file mode 100644 index de939ad4f54c6..0000000000000 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/alert_details.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as reactTestingLibrary from '@testing-library/react'; -import { MemoryHistory } from 'history'; -import { Store } from 'redux'; - -import { mockAlertDetailsResult } from '../store/mock_alert_result_list'; -import { alertPageTestRender } from './test_helpers/render_alert_page'; -import { AppAction } from '../../common/store/actions'; -import { State } from '../../common/store/types'; - -describe('when the alert details flyout is open', () => { - let render: () => reactTestingLibrary.RenderResult; - let history: MemoryHistory; - let store: Store; - - beforeEach(async () => { - // Creates the render elements for the tests to use - ({ render, history, store } = alertPageTestRender()); - }); - describe('when the alerts details flyout is open', () => { - beforeEach(() => { - reactTestingLibrary.act(() => { - history.push({ - search: '?selected_alert=1', - }); - }); - }); - describe('when the data loads', () => { - beforeEach(() => { - reactTestingLibrary.act(() => { - const action: AppAction = { - type: 'serverReturnedAlertDetailsData', - payload: mockAlertDetailsResult(), - }; - store.dispatch(action); - }); - }); - it('should display take action button', async () => { - await render().findByTestId('alertDetailTakeActionDropdownButton'); - }); - describe('when the user clicks the take action button on the flyout', () => { - let renderResult: reactTestingLibrary.RenderResult; - beforeEach(async () => { - renderResult = render(); - const takeActionButton = await renderResult.findByTestId( - 'alertDetailTakeActionDropdownButton' - ); - if (takeActionButton) { - reactTestingLibrary.fireEvent.click(takeActionButton); - } - }); - it('should display the correct fields in the dropdown', async () => { - await renderResult.findByTestId('alertDetailTakeActionCloseAlertButton'); - await renderResult.findByTestId('alertDetailTakeActionWhitelistButton'); - }); - }); - describe('when the user navigates to the resolver tab', () => { - beforeEach(() => { - reactTestingLibrary.act(() => { - history.push({ - ...history.location, - search: '?selected_alert=1&active_details_tab=overviewResolver', - }); - }); - }); - it('should show the resolver view', async () => { - const resolver = await render().findByTestId('alertResolver'); - expect(resolver).toBeInTheDocument(); - }); - }); - describe('when the user navigates to the overview tab', () => { - let renderResult: reactTestingLibrary.RenderResult; - beforeEach(async () => { - renderResult = render(); - const overviewTab = await renderResult.findByTestId('overviewMetadata'); - if (overviewTab) { - reactTestingLibrary.fireEvent.click(overviewTab); - } - }); - it('should render all accordion panels', async () => { - await renderResult.findAllByTestId('alertDetailsAlertAccordion'); - await renderResult.findAllByTestId('alertDetailsHostAccordion'); - await renderResult.findAllByTestId('alertDetailsFileAccordion'); - await renderResult.findAllByTestId('alertDetailsHashAccordion'); - await renderResult.findAllByTestId('alertDetailsSourceProcessAccordion'); - await renderResult.findAllByTestId('alertDetailsSourceProcessTokenAccordion'); - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/overview/index.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/overview/index.tsx index 86c8e00c0a56f..60adea44ab0ab 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/overview/index.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/overview/index.tsx @@ -20,8 +20,6 @@ import { useAlertListSelector } from '../../hooks/use_alerts_selector'; import * as selectors from '../../../store/selectors'; import { MetadataPanel } from './metadata_panel'; import { FormattedDate } from '../../formatted_date'; -import { AlertDetailResolver } from '../../resolver'; -import { ResolverEvent } from '../../../../../common/endpoint/types'; import { TakeActionDropdown } from './take_action_dropdown'; import { urlFromQueryParams } from '../../url_from_query_params'; @@ -65,12 +63,11 @@ const AlertDetailsOverviewComponent = memo(() => { content: ( <> - ), }, ]; - }, [alertDetailsData]); + }, []); /* eslint-disable-next-line react-hooks/rules-of-hooks */ const activeTab = useMemo( diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/resolver.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/resolver.tsx deleted file mode 100644 index 92213a8bd3925..0000000000000 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/resolver.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { Provider } from 'react-redux'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { ResolverEvent } from '../../../common/endpoint/types'; -import { StartServices } from '../../types'; -import { storeFactory } from '../../resolver/store'; -import { Resolver } from '../../resolver/view'; - -const AlertDetailResolverComponents = React.memo( - ({ className, selectedEvent }: { className?: string; selectedEvent?: ResolverEvent }) => { - const context = useKibana(); - const { store } = storeFactory(context); - - return ( -
- - - -
- ); - } -); - -AlertDetailResolverComponents.displayName = 'AlertDetailResolver'; - -export const AlertDetailResolver = styled(AlertDetailResolverComponents)` - height: 100%; - width: 100%; - display: flex; - flex-grow: 1; - min-height: calc(100vh - 505px); -`; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap similarity index 83% rename from x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap rename to x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap index 8525ccd7b1548..74efb41c4c595 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap @@ -56,12 +56,12 @@ Object { }, "points": Array [ Array [ - 0, - -229.43553924069099, + -98.99494936611666, + -286.5902999056318, ], Array [ - 395.9797974644666, - -0.8164965809277259, + 593.9696961966999, + 113.49302474895391, ], ], }, @@ -71,12 +71,12 @@ Object { }, "points": Array [ Array [ - 0, - -229.43553924069099, + -98.99494936611666, + -286.5902999056318, ], Array [ - 197.9898987322333, - -343.7450605705726, + 98.99494936611666, + -400.8998212355134, ], ], }, @@ -86,12 +86,27 @@ Object { }, "points": Array [ Array [ - 395.9797974644666, - -0.8164965809277259, + 296.98484809834997, + -57.97125724586854, ], + Array [ + 494.9747468305833, + -172.28077857575016, + ], + ], + }, + Object { + "metadata": Object { + "uniqueId": "", + }, + "points": Array [ Array [ 593.9696961966999, - -115.12601791080935, + 113.49302474895391, + ], + Array [ + 791.9595949289333, + -0.8164965809277259, ], ], }, @@ -101,12 +116,12 @@ Object { }, "points": Array [ Array [ - 197.9898987322333, - -343.7450605705726, + 98.99494936611666, + -400.8998212355134, ], Array [ - 395.9797974644666, - -458.05458190045425, + 296.98484809834997, + -515.2093425653951, ], ], }, @@ -116,12 +131,12 @@ Object { }, "points": Array [ Array [ - 296.98484809834997, - -515.2093425653951, + 197.9898987322333, + -572.3641032303359, ], Array [ - 494.9747468305833, - -400.8998212355134, + 395.9797974644666, + -458.05458190045425, ], ], }, @@ -131,12 +146,12 @@ Object { }, "points": Array [ Array [ - 296.98484809834997, - -515.2093425653951, + 197.9898987322333, + -572.3641032303359, ], Array [ - 494.9747468305833, - -629.5188638952767, + 395.9797974644666, + -686.6736245602175, ], ], }, @@ -146,12 +161,12 @@ Object { }, "points": Array [ Array [ - 494.9747468305833, - -400.8998212355134, + 395.9797974644666, + -458.05458190045425, ], Array [ - 692.9646455628166, - -515.2093425653951, + 593.9696961966999, + -572.3641032303359, ], ], }, @@ -161,12 +176,12 @@ Object { }, "points": Array [ Array [ - 593.9696961966999, - -115.12601791080935, + 494.9747468305833, + -172.28077857575016, ], Array [ - 791.9595949289333, - -229.43553924069096, + 692.9646455628166, + -286.5902999056318, ], ], }, @@ -176,12 +191,12 @@ Object { }, "points": Array [ Array [ - 692.9646455628166, - -286.5902999056318, + 593.9696961966999, + -343.7450605705726, ], Array [ - 890.9545442950499, - -172.28077857575016, + 791.9595949289333, + -229.43553924069096, ], ], }, @@ -191,12 +206,12 @@ Object { }, "points": Array [ Array [ - 692.9646455628166, - -286.5902999056318, + 593.9696961966999, + -343.7450605705726, ], Array [ - 890.9545442950499, - -400.89982123551346, + 791.9595949289333, + -458.05458190045425, ], ], }, @@ -206,12 +221,12 @@ Object { }, "points": Array [ Array [ - 890.9545442950499, - -172.28077857575016, + 791.9595949289333, + -229.43553924069096, ], Array [ - 1088.9444430272833, - -286.5902999056318, + 989.9494936611666, + -343.7450605705726, ], ], }, @@ -221,12 +236,12 @@ Object { }, "points": Array [ Array [ - 1088.9444430272833, - -286.5902999056318, + 989.9494936611666, + -343.7450605705726, ], Array [ - 1286.9343417595164, - -400.89982123551346, + 1187.9393923933999, + -458.05458190045425, ], ], }, @@ -263,8 +278,8 @@ Object { "unique_ppid": 0, }, } => Array [ - 197.9898987322333, - -343.7450605705726, + 98.99494936611666, + -400.8998212355134, ], Object { "@timestamp": 1582233383000, @@ -280,8 +295,25 @@ Object { "unique_ppid": 0, }, } => Array [ - 593.9696961966999, - -115.12601791080935, + 494.9747468305833, + -172.28077857575016, + ], + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "termination_event", + "event_type_full": "process_event", + "unique_pid": 8, + "unique_ppid": 0, + }, + } => Array [ + 791.9595949289333, + -0.8164965809277259, ], Object { "@timestamp": 1582233383000, @@ -297,8 +329,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 494.9747468305833, - -629.5188638952767, + 395.9797974644666, + -686.6736245602175, ], Object { "@timestamp": 1582233383000, @@ -314,8 +346,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 692.9646455628166, - -515.2093425653951, + 593.9696961966999, + -572.3641032303359, ], Object { "@timestamp": 1582233383000, @@ -331,8 +363,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 890.9545442950499, - -400.89982123551346, + 791.9595949289333, + -458.05458190045425, ], Object { "@timestamp": 1582233383000, @@ -348,8 +380,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 1088.9444430272833, - -286.5902999056318, + 989.9494936611666, + -343.7450605705726, ], Object { "@timestamp": 1582233383000, @@ -365,8 +397,8 @@ Object { "unique_ppid": 6, }, } => Array [ - 1286.9343417595164, - -400.89982123551346, + 1187.9393923933999, + -458.05458190045425, ], }, } diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts similarity index 95% rename from x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts rename to x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts index db00ca2d59968..b322de0f34526 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { uniquePidForProcess, uniqueParentPidForProcess } from './process_event'; -import { IndexedProcessTree, AdjacentProcessMap } from '../types'; -import { ResolverEvent } from '../../../common/endpoint/types'; -import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; +import { uniquePidForProcess, uniqueParentPidForProcess } from '../process_event'; +import { IndexedProcessTree, AdjacentProcessMap } from '../../types'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; /** * Create a new IndexedProcessTree from an array of ProcessEvents diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts new file mode 100644 index 0000000000000..72d8e878465f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IsometricTaxiLayout } from '../../types'; +import { LegacyEndpointEvent } from '../../../../common/endpoint/types'; +import { isometricTaxiLayout } from './isometric_taxi_layout'; +import { mockProcessEvent } from '../../models/process_event_test_helpers'; +import { factory } from './index'; + +describe('resolver graph layout', () => { + let processA: LegacyEndpointEvent; + let processB: LegacyEndpointEvent; + let processC: LegacyEndpointEvent; + let processD: LegacyEndpointEvent; + let processE: LegacyEndpointEvent; + let processF: LegacyEndpointEvent; + let processG: LegacyEndpointEvent; + let processH: LegacyEndpointEvent; + let processI: LegacyEndpointEvent; + let events: LegacyEndpointEvent[]; + let layout: () => IsometricTaxiLayout; + + beforeEach(() => { + /* + * A + * ____|____ + * | | + * B C + * ___|___ ___|___ + * | | | | + * D E F G + * | + * H + * + */ + processA = mockProcessEvent({ + endgame: { + process_name: '', + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 0, + }, + }); + processB = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'already_running', + unique_pid: 1, + unique_ppid: 0, + }, + }); + processC = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 2, + unique_ppid: 0, + }, + }); + processD = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 3, + unique_ppid: 1, + }, + }); + processE = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 4, + unique_ppid: 1, + }, + }); + processF = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 5, + unique_ppid: 2, + }, + }); + processG = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 6, + unique_ppid: 2, + }, + }); + processH = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'creation_event', + unique_pid: 7, + unique_ppid: 6, + }, + }); + processI = mockProcessEvent({ + endgame: { + event_type_full: 'process_event', + event_subtype_full: 'termination_event', + unique_pid: 8, + unique_ppid: 0, + }, + }); + layout = () => isometricTaxiLayout(factory(events)); + events = []; + }); + describe('when rendering no nodes', () => { + it('renders right', () => { + expect(layout()).toMatchSnapshot(); + }); + }); + describe('when rendering one node', () => { + beforeEach(() => { + events = [processA]; + }); + it('renders right', () => { + expect(layout()).toMatchSnapshot(); + }); + }); + describe('when rendering two nodes, one being the parent of the other', () => { + beforeEach(() => { + events = [processA, processB]; + }); + it('renders right', () => { + expect(layout()).toMatchSnapshot(); + }); + }); + describe('when rendering two forks, and one fork has an extra long tine', () => { + beforeEach(() => { + events = [ + processA, + processB, + processC, + processD, + processE, + processF, + processG, + processH, + processI, + ]; + }); + it('renders right', () => { + expect(layout()).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts new file mode 100644 index 0000000000000..9095f061ee73a --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -0,0 +1,453 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as vector2 from '../../lib/vector2'; +import { + IndexedProcessTree, + Vector2, + EdgeLineSegment, + ProcessWidths, + ProcessPositions, + EdgeLineMetadata, + ProcessWithWidthMetadata, + Matrix3, + IsometricTaxiLayout, +} from '../../types'; +import * as event from '../../../../common/endpoint/models/event'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import * as model from './index'; +import { getFriendlyElapsedTime as elapsedTime } from '../../lib/date'; + +/** + * Graph the process tree + */ +export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): IsometricTaxiLayout { + /** + * Walk the tree in reverse level order, calculating the 'width' of subtrees. + */ + const widths = widthsOfProcessSubtrees(indexedProcessTree); + + /** + * Walk the tree in level order. Using the precalculated widths, calculate the position of nodes. + * Nodes are positioned relative to their parents and preceding siblings. + */ + const positions = processPositions(indexedProcessTree, widths); + + /** + * With the widths and positions precalculated, we calculate edge line segments (arrays of vector2s) + * which connect them in a 'pitchfork' design. + */ + const edgeLineSegments = processEdgeLineSegments(indexedProcessTree, widths, positions); + + /** + * Transform the positions of nodes and edges so they seem like they are on an isometric grid. + */ + const transformedEdgeLineSegments: EdgeLineSegment[] = []; + const transformedPositions = new Map(); + + for (const [processEvent, position] of positions) { + transformedPositions.set( + processEvent, + vector2.applyMatrix3(position, isometricTransformMatrix) + ); + } + + for (const edgeLineSegment of edgeLineSegments) { + const { + points: [startPoint, endPoint], + } = edgeLineSegment; + + const transformedSegment: EdgeLineSegment = { + ...edgeLineSegment, + points: [ + vector2.applyMatrix3(startPoint, isometricTransformMatrix), + vector2.applyMatrix3(endPoint, isometricTransformMatrix), + ], + }; + + transformedEdgeLineSegments.push(transformedSegment); + } + + return { + processNodePositions: transformedPositions, + edgeLineSegments: transformedEdgeLineSegments, + }; +} + +/** + * In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its + * descedants and the rule that each process node must be at least 1 unit apart. Enforcing that all nodes are at least + * 1 unit apart on the x axis makes it easy to prevent the UI components from overlapping. There will always be space. + * + * Example widths: + * + * A and B each have a width of 0 + * + * A + * | + * B + * + * A has a width of 1. B and C have a width of 0. + * B and C must be 1 unit apart, so the A subtree has a width of 1. + * + * A + * ____|____ + * | | + * B C + * + * + * D, E, F, G, H all have a width of 0. + * B has a width of 1 since D->E must be 1 unit apart. + * Similarly, C has a width of 1 since F->G must be 1 unit apart. + * A has width of 3, since B has a width of 1, and C has a width of 1, and E->F must be at least + * 1 unit apart. + * A + * ____|____ + * | | + * B C + * ___|___ ___|___ + * | | | | + * D E F G + * | + * H + * + */ +function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): ProcessWidths { + const widths = new Map(); + + if (model.size(indexedProcessTree) === 0) { + return widths; + } + + const processesInReverseLevelOrder = [...model.levelOrder(indexedProcessTree)].reverse(); + + for (const process of processesInReverseLevelOrder) { + const children = model.children(indexedProcessTree, process); + + const sumOfWidthOfChildren = function sumOfWidthOfChildren() { + return children.reduce(function sum(currentValue, child) { + /** + * `widths.get` will always return a number in this case. + * This loop sequences a tree in reverse level order. Width values are set for each node. + * Therefore a parent can always find a width for its children, since all of its children + * will have been handled already. + */ + return currentValue + widths.get(child)!; + }, 0); + }; + + const width = sumOfWidthOfChildren() + Math.max(0, children.length - 1) * distanceBetweenNodes; + widths.set(process, width); + } + + return widths; +} + +function processEdgeLineSegments( + indexedProcessTree: IndexedProcessTree, + widths: ProcessWidths, + positions: ProcessPositions +): EdgeLineSegment[] { + const edgeLineSegments: EdgeLineSegment[] = []; + for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { + const edgeLineMetadata: EdgeLineMetadata = { uniqueId: '' }; + /** + * We only handle children, drawing lines back to their parents. The root has no parent, so we skip it + */ + if (metadata.parent === null) { + // eslint-disable-next-line no-continue + continue; + } + const { process, parent, parentWidth } = metadata; + const position = positions.get(process); + const parentPosition = positions.get(parent); + const parentId = event.entityId(parent); + const processEntityId = event.entityId(process); + const edgeLineId = parentId ? parentId + processEntityId : parentId; + + if (position === undefined || parentPosition === undefined) { + /** + * All positions have been precalculated, so if any are missing, it's an error. This will never happen. + */ + throw new Error(); + } + + const parentTime = event.eventTimestamp(parent); + const processTime = event.eventTimestamp(process); + if (parentTime && processTime) { + edgeLineMetadata.elapsedTime = elapsedTime(parentTime, processTime) ?? undefined; + } + edgeLineMetadata.uniqueId = edgeLineId; + + /** + * The point halfway between the parent and child on the y axis, we sometimes have a hard angle here in the edge line + */ + const midwayY = parentPosition[1] + (position[1] - parentPosition[1]) / 2; + + /** + * When drawing edge lines between a parent and children (when there are multiple children) we draw a pitchfork type + * design. The 'midway' line, runs along the x axis and joins all the children with a single descendant line from the parent. + * See the ascii diagram below. The underscore characters would be the midway line. + * + * A + * ____|____ + * | | + * B C + */ + const lineFromProcessToMidwayLine: EdgeLineSegment = { + points: [[position[0], midwayY], position], + metadata: edgeLineMetadata, + }; + + const siblings = model.children(indexedProcessTree, parent); + const isFirstChild = process === siblings[0]; + + if (metadata.isOnlyChild) { + // add a single line segment directly from parent to child. We don't do the 'pitchfork' in this case. + edgeLineSegments.push({ points: [parentPosition, position], metadata: edgeLineMetadata }); + } else if (isFirstChild) { + /** + * If the parent has multiple children, we draw the 'midway' line, and the line from the + * parent to the midway line, while handling the first child. + * + * Consider A the parent, and B the first child. We would draw somemthing like what's in the below diagram. The line from the + * midway line to C would be drawn when we handle C. + * + * A + * ____|____ + * | + * B C + */ + const { firstChildWidth, lastChildWidth } = metadata; + + const lineFromParentToMidwayLine: EdgeLineSegment = { + points: [parentPosition, [parentPosition[0], midwayY]], + metadata: { uniqueId: `parentToMid${edgeLineId}` }, + }; + + const widthOfMidline = parentWidth - firstChildWidth / 2 - lastChildWidth / 2; + + const minX = parentWidth / -2 + firstChildWidth / 2; + const maxX = minX + widthOfMidline; + + const midwayLine: EdgeLineSegment = { + points: [ + [ + // Position line relative to the parent's x component + parentPosition[0] + minX, + midwayY, + ], + [ + // Position line relative to the parent's x component + parentPosition[0] + maxX, + midwayY, + ], + ], + metadata: { uniqueId: `midway${edgeLineId}` }, + }; + + edgeLineSegments.push( + /* line from parent to midway line */ + lineFromParentToMidwayLine, + midwayLine, + lineFromProcessToMidwayLine + ); + } else { + // If this isn't the first child, it must have siblings (the first of which drew the midway line and line + // from the parent to the midway line + edgeLineSegments.push(lineFromProcessToMidwayLine); + } + } + return edgeLineSegments; +} + +function processPositions( + indexedProcessTree: IndexedProcessTree, + widths: ProcessWidths +): ProcessPositions { + const positions = new Map(); + /** + * This algorithm iterates the tree in level order. It keeps counters that are reset for each parent. + * By keeping track of the last parent node, we can know when we are dealing with a new set of siblings and + * reset the counters. + */ + let lastProcessedParentNode: ResolverEvent | undefined; + /** + * Nodes are positioned relative to their siblings. We walk this in level order, so we handle + * children left -> right. + * + * The width of preceding siblings is used to left align the node. + * The number of preceding siblings is important because each sibling must be 1 unit apart + * on the x axis. + */ + let numberOfPrecedingSiblings = 0; + let runningWidthOfPrecedingSiblings = 0; + + for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { + // Handle root node + if (metadata.parent === null) { + const { process } = metadata; + /** + * Place the root node at (0, 0) for now. + */ + positions.set(process, [0, 0]); + } else { + const { process, parent, isOnlyChild, width, parentWidth } = metadata; + + // Reinit counters when parent changes + if (lastProcessedParentNode !== parent) { + numberOfPrecedingSiblings = 0; + runningWidthOfPrecedingSiblings = 0; + + // keep track of this so we know when to reinitialize + lastProcessedParentNode = parent; + } + + const parentPosition = positions.get(parent); + + if (parentPosition === undefined) { + /** + * Since this algorithm populates the `positions` map in level order, + * the parent node will have been processed already and the parent position + * will always be available. + * + * This will never happen. + */ + throw new Error(); + } + + /** + * The x 'offset' is added to the x value of the parent to determine the position of the node. + * We add `parentWidth / -2` in order to align the left side of this node with the left side of its parent. + * We add `numberOfPrecedingSiblings * distanceBetweenNodes` in order to keep each node 1 apart on the x axis. + * We add `runningWidthOfPrecedingSiblings` so that we don't overlap with our preceding siblings. We stack em up. + * We add `width / 2` so that we center the node horizontally (in case it has non-0 width.) + */ + const xOffset = + parentWidth / -2 + + numberOfPrecedingSiblings * distanceBetweenNodes + + runningWidthOfPrecedingSiblings + + width / 2; + + /** + * The y axis gains `-distanceBetweenNodes` as we move down the screen 1 unit at a time. + */ + let yDistanceBetweenNodes = -distanceBetweenNodes; + + if (!isOnlyChild) { + // Make space on leaves to show elapsed time + yDistanceBetweenNodes *= 2; + } + + const position = vector2.add([xOffset, yDistanceBetweenNodes], parentPosition); + + positions.set(process, position); + + numberOfPrecedingSiblings += 1; + runningWidthOfPrecedingSiblings += width; + } + } + + return positions; +} +function* levelOrderWithWidths( + tree: IndexedProcessTree, + widths: ProcessWidths +): Iterable { + for (const process of model.levelOrder(tree)) { + const parent = model.parent(tree, process); + const width = widths.get(process); + + if (width === undefined) { + /** + * All widths have been precalcluated, so this will not happen. + */ + throw new Error(); + } + + /** If the parent is undefined, we are processing the root. */ + if (parent === undefined) { + yield { + process, + width, + parent: null, + parentWidth: null, + isOnlyChild: null, + firstChildWidth: null, + lastChildWidth: null, + }; + } else { + const parentWidth = widths.get(parent); + + if (parentWidth === undefined) { + /** + * All widths have been precalcluated, so this will not happen. + */ + throw new Error(); + } + + const metadata: Partial = { + process, + width, + parent, + parentWidth, + }; + + const siblings = model.children(tree, parent); + if (siblings.length === 1) { + metadata.isOnlyChild = true; + metadata.lastChildWidth = width; + metadata.firstChildWidth = width; + } else { + const firstChildWidth = widths.get(siblings[0]); + const lastChildWidth = widths.get(siblings[siblings.length - 1]); + if (firstChildWidth === undefined || lastChildWidth === undefined) { + /** + * All widths have been precalcluated, so this will not happen. + */ + throw new Error(); + } + metadata.isOnlyChild = false; + metadata.firstChildWidth = firstChildWidth; + metadata.lastChildWidth = lastChildWidth; + } + + yield metadata as ProcessWithWidthMetadata; + } + } +} + +/** + * An isometric projection is a method for representing three dimensional objects in 2 dimensions. + * More information about isometric projections can be found here https://en.wikipedia.org/wiki/Isometric_projection. + * In our case, we obtain the isometric projection by rotating the objects 45 degrees in the plane of the screen + * and arctan(1/sqrt(2)) (~35.3 degrees) through the horizontal axis. + * + * A rotation by 45 degrees in the plane of the screen is given by: + * [ sqrt(2)/2 -sqrt(2)/2 0 + * sqrt(2)/2 sqrt(2)/2 0 + * 0 0 1] + * + * A rotation by arctan(1/sqrt(2)) through the horizantal axis is given by: + * [ 1 0 0 + * 0 sqrt(3)/3 -sqrt(6)/3 + * 0 sqrt(6)/3 sqrt(3)/3] + * + * We can multiply both of these matrices to get the final transformation below. + */ +/* prettier-ignore */ +const isometricTransformMatrix: Matrix3 = [ + Math.sqrt(2) / 2, -(Math.sqrt(2) / 2), 0, + Math.sqrt(6) / 6, Math.sqrt(6) / 6, -(Math.sqrt(6) / 3), + 0, 0, 1, +] + +const unit = 140; +const distanceBetweenNodesInUnits = 2; + +/** + * The distance in pixels (at scale 1) between nodes. Change this to space out nodes more + */ +const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; diff --git a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts new file mode 100644 index 0000000000000..cf32988a856b2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverTree, + ResolverEvent, + ResolverNodeStats, + ResolverLifecycleNode, +} from '../../../common/endpoint/types'; +import { uniquePidForProcess } from './process_event'; + +/** + * ResolverTree is a type returned by the server. + */ + +/** + * This returns the 'LifecycleNodes' of the tree. These nodes have + * the entityID and stats for a process. Used by `relatedEventsStats`. + */ +function lifecycleNodes(tree: ResolverTree): ResolverLifecycleNode[] { + return [tree, ...tree.children.childNodes, ...tree.ancestry.ancestors]; +} + +/** + * All the process events + */ +export function lifecycleEvents(tree: ResolverTree) { + const events: ResolverEvent[] = [...tree.lifecycle]; + for (const { lifecycle } of tree.children.childNodes) { + events.push(...lifecycle); + } + for (const { lifecycle } of tree.ancestry.ancestors) { + events.push(...lifecycle); + } + return events; +} + +/** + * This returns a map of entity_ids to stats for the related events and alerts. + */ +export function relatedEventsStats(tree: ResolverTree): Map { + const nodeStats: Map = new Map(); + for (const node of lifecycleNodes(tree)) { + if (node.stats) { + nodeStats.set(node.entityID, node.stats); + } + } + return nodeStats; +} + +/** + * ResolverTree type is returned by the server. It organizes events into a complex structure. The + * organization of events in the tree is done to associate metadata with the events. The client does not + * use this metadata. Instead, the client flattens the tree into an array. Therefore we can safely + * make a malformed ResolverTree for the purposes of the tests, so long as it is flattened in a predictable way. + */ +export function mock({ + events, + cursors = { childrenNextChild: null, ancestryNextAncestor: null }, +}: { + /** + * Events represented by the ResolverTree. + */ + events: ResolverEvent[]; + /** + * Optionally provide cursors for the 'children' and 'ancestry' edges. + */ + cursors?: { childrenNextChild: string | null; ancestryNextAncestor: string | null }; +}): ResolverTree | null { + if (events.length === 0) { + return null; + } + const first = events[0]; + return { + entityID: uniquePidForProcess(first), + // Required + children: { + childNodes: [], + nextChild: cursors.childrenNextChild, + }, + // Required + relatedEvents: { + events: [], + nextEvent: null, + }, + // Required + relatedAlerts: { + alerts: [], + nextAlert: null, + }, + // Required + ancestry: { + ancestors: [], + nextAncestor: cursors.ancestryNextAncestor, + }, + // Normally, this would have only certain events, but for testing purposes, it will have all events, since + // the position of events in the ResolverTree is irrelevant. + lifecycle: events, + // Required + stats: { + events: { + total: 0, + byCategory: {}, + }, + totalAlerts: 0, + }, + }; +} + +/** + * `true` if there are more children to fetch. + */ +export function hasMoreChildren(resolverTree: ResolverTree): boolean { + return resolverTree.children.nextChild !== null; +} + +/** + * `true` if there are more ancestors to fetch. + */ +export function hasMoreAncestors(resolverTree: ResolverTree): boolean { + return resolverTree.ancestry.nextAncestor !== null; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index ae302d0e60911..5292cbb6445dc 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { CameraAction } from './camera'; -import { DataAction } from './data'; import { ResolverEvent } from '../../../common/endpoint/types'; +import { DataAction } from './data/action'; /** * When the user wants to bring a process node front-and-center on the map. @@ -53,26 +53,6 @@ interface AppDetectedNewIdFromQueryParams { }; } -/** - * Used when the alert list selects an alert and the flyout shows resolver. - */ -interface UserChangedSelectedEvent { - readonly type: 'userChangedSelectedEvent'; - readonly payload: { - /** - * Optional because they could have unselected the event. - */ - readonly selectedEvent?: ResolverEvent; - }; -} - -/** - * Triggered by middleware when the data for resolver needs to be loaded. Used to set state in redux to 'loading'. - */ -interface AppRequestedResolverData { - readonly type: 'appRequestedResolverData'; -} - /** * The action dispatched when the app requests related event data for one * subject (whose entity_id should be included as `payload`) @@ -145,8 +125,6 @@ export type ResolverAction = | CameraAction | DataAction | UserBroughtProcessIntoView - | UserChangedSelectedEvent - | AppRequestedResolverData | UserFocusedOnResolverNode | UserSelectedResolverNode | UserRequestedRelatedEventData diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts index 92cbd95bcf5a8..50f4ffd0137dc 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts @@ -6,10 +6,11 @@ import { createStore, Store, Reducer } from 'redux'; import { cameraReducer, cameraInitialState } from './reducer'; -import { CameraState, Vector2, ResolverAction } from '../../types'; +import { CameraState, Vector2 } from '../../types'; import * as selectors from './selectors'; import { animatePanning } from './methods'; import { lerp } from '../../lib/math'; +import { ResolverAction } from '../actions'; type TestAction = | ResolverAction diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/reducer.ts index 0f6ae1b7d904a..f64864edab5b3 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/reducer.ts @@ -11,8 +11,9 @@ import * as vector2 from '../../lib/vector2'; import * as selectors from './selectors'; import { clamp } from '../../lib/math'; -import { CameraState, ResolverAction, Vector2 } from '../../types'; +import { CameraState, Vector2 } from '../../types'; import { scaleToZoom } from './scale_to_zoom'; +import { ResolverAction } from '../actions'; /** * Used in tests. diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 3de6f08f5e015..0d2a6936b4873 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -4,23 +4,44 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ResolverEvent, - ResolverNodeStats, - ResolverRelatedEvents, -} from '../../../../common/endpoint/types'; +import { ResolverRelatedEvents, ResolverTree } from '../../../../common/endpoint/types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; readonly payload: { - readonly events: Readonly; - readonly stats: Readonly>; - readonly lineageLimits: { readonly children: string | null; readonly ancestors: string | null }; + /** + * The result of fetching data + */ + result: ResolverTree; + /** + * The database document ID that was used to fetch the resolver tree + */ + databaseDocumentID: string; }; } +interface AppRequestedResolverData { + readonly type: 'appRequestedResolverData'; + /** + * entity ID used to make the request. + */ + readonly payload: string; +} + interface ServerFailedToReturnResolverData { readonly type: 'serverFailedToReturnResolverData'; + /** + * entity ID used to make the failed request + */ + readonly payload: string; +} + +interface AppAbortedResolverDataRequest { + readonly type: 'appAbortedResolverDataRequest'; + /** + * entity ID used to make the aborted request + */ + readonly payload: string; } /** @@ -39,8 +60,29 @@ interface ServerReturnedRelatedEventData { readonly payload: ResolverRelatedEvents; } +/** + * Used by `useStateSyncingActions` hook. + * This is dispatched when external sources provide new parameters for Resolver. + * When the component receives a new 'databaseDocumentID' prop, this is fired. + */ +interface AppReceivedNewExternalProperties { + type: 'appReceivedNewExternalProperties'; + /** + * Defines the externally provided properties that Resolver acknowledges. + */ + payload: { + /** + * the `_id` of an ES document. This defines the origin of the Resolver graph. + */ + databaseDocumentID?: string; + }; +} + export type DataAction = | ServerReturnedResolverData | ServerFailedToReturnResolverData | ServerFailedToReturnRelatedEventData - | ServerReturnedRelatedEventData; + | ServerReturnedRelatedEventData + | AppReceivedNewExternalProperties + | AppRequestedResolverData + | AppAbortedResolverDataRequest; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts deleted file mode 100644 index 163846e0414db..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Store, createStore } from 'redux'; -import { DataAction } from './action'; -import { dataReducer } from './reducer'; -import { DataState } from '../../types'; -import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; -import { - graphableProcesses, - processNodePositionsAndEdgeLineSegments, - limitsReached, -} from './selectors'; -import { mockProcessEvent } from '../../models/process_event_test_helpers'; -import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; - -describe('resolver graph layout', () => { - let processA: LegacyEndpointEvent; - let processB: LegacyEndpointEvent; - let processC: LegacyEndpointEvent; - let processD: LegacyEndpointEvent; - let processE: LegacyEndpointEvent; - let processF: LegacyEndpointEvent; - let processG: LegacyEndpointEvent; - let processH: LegacyEndpointEvent; - let processI: LegacyEndpointEvent; - let store: Store; - - beforeEach(() => { - /* - * A - * ____|____ - * | | - * B C - * ___|___ ___|___ - * | | | | - * D E F G - * | - * H - * - */ - processA = mockProcessEvent({ - endgame: { - process_name: '', - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 0, - }, - }); - processB = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'already_running', - unique_pid: 1, - unique_ppid: 0, - }, - }); - processC = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 2, - unique_ppid: 0, - }, - }); - processD = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 3, - unique_ppid: 1, - }, - }); - processE = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 4, - unique_ppid: 1, - }, - }); - processF = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 5, - unique_ppid: 2, - }, - }); - processG = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 6, - unique_ppid: 2, - }, - }); - processH = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 7, - unique_ppid: 6, - }, - }); - processI = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'termination_event', - unique_pid: 8, - unique_ppid: 0, - }, - }); - store = createStore(dataReducer, undefined); - }); - describe('when rendering no nodes', () => { - beforeEach(() => { - const events: ResolverEvent[] = []; - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, - }; - store.dispatch(action); - }); - it('the graphableProcesses list should only include nothing', () => { - const actual = graphableProcesses(store.getState()); - expect(actual).toEqual([]); - }); - it('renders right', () => { - expect(processNodePositionsAndEdgeLineSegments(store.getState())).toMatchSnapshot(); - }); - }); - describe('when rendering one node', () => { - beforeEach(() => { - const events = [processA]; - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, - }; - store.dispatch(action); - }); - it('the graphableProcesses list should only include nothing', () => { - const actual = graphableProcesses(store.getState()); - expect(actual).toEqual([processA]); - }); - it('renders right', () => { - expect(processNodePositionsAndEdgeLineSegments(store.getState())).toMatchSnapshot(); - }); - }); - describe('when rendering two nodes, one being the parent of the other', () => { - beforeEach(() => { - const events = [processA, processB]; - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, - }; - store.dispatch(action); - }); - it('the graphableProcesses list should only include nothing', () => { - const actual = graphableProcesses(store.getState()); - expect(actual).toEqual([processA, processB]); - }); - it('renders right', () => { - expect(processNodePositionsAndEdgeLineSegments(store.getState())).toMatchSnapshot(); - }); - }); - describe('when rendering two forks, and one fork has an extra long tine', () => { - beforeEach(() => { - const events = [ - processA, - processB, - processC, - processD, - processE, - processF, - processG, - processH, - processI, - ]; - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, - }; - store.dispatch(action); - }); - it("the graphableProcesses list should only include events with 'processCreated' an 'processRan' eventType", () => { - const actual = graphableProcesses(store.getState()); - expect(actual).toEqual([ - processA, - processB, - processC, - processD, - processE, - processF, - processG, - processH, - ]); - }); - it('renders right', () => { - expect(processNodePositionsAndEdgeLineSegments(store.getState())).toMatchSnapshot(); - }); - }); -}); - -describe('resolver graph with too much lineage', () => { - let generator: EndpointDocGenerator; - let store: Store; - let allEvents: ResolverEvent[]; - let childrenCursor: string; - let ancestorCursor: string; - - beforeEach(() => { - generator = new EndpointDocGenerator('seed'); - allEvents = generator.generateTree({ ancestors: 1, generations: 2, children: 2 }).allEvents; - childrenCursor = 'aValidChildursor'; - ancestorCursor = 'aValidAncestorCursor'; - store = createStore(dataReducer, undefined); - }); - - describe('should select from state properly', () => { - it('should indicate there are too many ancestors', () => { - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { - events: allEvents, - stats: new Map(), - lineageLimits: { children: childrenCursor, ancestors: ancestorCursor }, - }, - }; - store.dispatch(action); - const { ancestors } = limitsReached(store.getState()); - expect(ancestors).toEqual(true); - }); - it('should indicate there are too many children', () => { - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { - events: allEvents, - stats: new Map(), - lineageLimits: { children: childrenCursor, ancestors: ancestorCursor }, - }, - }; - store.dispatch(action); - const { children } = limitsReached(store.getState()); - expect(children).toEqual(true); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/index.ts b/x-pack/plugins/security_solution/public/resolver/store/data/index.ts deleted file mode 100644 index 8db57c5d9681f..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/store/data/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { dataReducer } from './reducer'; -export { DataAction } from './action'; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts new file mode 100644 index 0000000000000..e6cd72ae0924b --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { createStore, Store } from 'redux'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { mock as mockResolverTree } from '../../models/resolver_tree'; +import { dataReducer } from './reducer'; +import * as selectors from './selectors'; +import { DataState } from '../../types'; +import { DataAction } from './action'; + +/** + * Test the data reducer and selector. + */ +describe('Resolver Data Middleware', () => { + let store: Store; + + beforeEach(() => { + store = createStore(dataReducer, undefined); + }); + + describe('when data was received and the ancestry and children edges had cursors', () => { + beforeEach(() => { + const generator = new EndpointDocGenerator('seed'); + const tree = mockResolverTree({ + events: generator.generateTree({ ancestors: 1, generations: 2, children: 2 }).allEvents, + cursors: { + childrenNextChild: 'aValidChildursor', + ancestryNextAncestor: 'aValidAncestorCursor', + }, + }); + if (tree) { + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { + result: tree, + databaseDocumentID: '', + }, + }; + store.dispatch(action); + } + }); + it('should indicate there are additional ancestor', () => { + expect(selectors.hasMoreAncestors(store.getState())).toBe(true); + }); + it('should indicate there are additional children', () => { + expect(selectors.hasMoreChildren(store.getState())).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index a36d43b70b87d..45bf214005872 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -5,41 +5,72 @@ */ import { Reducer } from 'redux'; -import { DataState, ResolverAction } from '../../types'; +import { DataState } from '../../types'; +import { ResolverAction } from '../actions'; -function initialState(): DataState { - return { - results: [], - relatedEventsStats: new Map(), - relatedEvents: new Map(), - relatedEventsReady: new Map(), - lineageLimits: { children: null, ancestors: null }, - isLoading: false, - hasError: false, - }; -} +const initialState: DataState = { + relatedEventsStats: new Map(), + relatedEvents: new Map(), + relatedEventsReady: new Map(), +}; -export const dataReducer: Reducer = (state = initialState(), action) => { - if (action.type === 'serverReturnedResolverData') { - return { +export const dataReducer: Reducer = (state = initialState, action) => { + if (action.type === 'appReceivedNewExternalProperties') { + const nextState: DataState = { ...state, - results: action.payload.events, - relatedEventsStats: action.payload.stats, - lineageLimits: action.payload.lineageLimits, - isLoading: false, - hasError: false, + databaseDocumentID: action.payload.databaseDocumentID, }; + return nextState; } else if (action.type === 'appRequestedResolverData') { + // keep track of what we're requesting, this way we know when to request and when not to. return { ...state, - isLoading: true, - hasError: false, + pendingRequestDatabaseDocumentID: action.payload, }; - } else if (action.type === 'serverFailedToReturnResolverData') { - return { + } else if (action.type === 'appAbortedResolverDataRequest') { + if (action.payload === state.pendingRequestDatabaseDocumentID) { + // the request we were awaiting was aborted + return { + ...state, + pendingRequestDatabaseDocumentID: undefined, + }; + } else { + return state; + } + } else if (action.type === 'serverReturnedResolverData') { + /** Only handle this if we are expecting a response */ + const nextState: DataState = { ...state, - hasError: true, + + /** + * Store the last received data, as well as the databaseDocumentID it relates to. + */ + lastResponse: { + result: action.payload.result, + databaseDocumentID: action.payload.databaseDocumentID, + successful: true, + }, + + // This assumes that if we just received something, there is no longer a pending request. + // This cannot model multiple in-flight requests + pendingRequestDatabaseDocumentID: undefined, }; + return nextState; + } else if (action.type === 'serverFailedToReturnResolverData') { + /** Only handle this if we are expecting a response */ + if (state.pendingRequestDatabaseDocumentID !== undefined) { + const nextState: DataState = { + ...state, + pendingRequestDatabaseDocumentID: undefined, + lastResponse: { + databaseDocumentID: state.pendingRequestDatabaseDocumentID, + successful: false, + }, + }; + return nextState; + } else { + return state; + } } else if ( action.type === 'userRequestedRelatedEventData' || action.type === 'appDetectedMissingEventData' diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts new file mode 100644 index 0000000000000..630dfe555548f --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as selectors from './selectors'; +import { DataState } from '../../types'; +import { dataReducer } from './reducer'; +import { DataAction } from './action'; +import { createStore } from 'redux'; +describe('data state', () => { + let actions: DataAction[] = []; + + /** + * Get state, given an ordered collection of actions. + */ + const state: () => DataState = () => { + const store = createStore(dataReducer); + for (const action of actions) { + store.dispatch(action); + } + return store.getState(); + }; + + /** + * This prints out all of the properties of the data state. + * This way we can see the overall behavior of the selector easily. + */ + const viewAsAString = (dataState: DataState) => { + return [ + ['is loading', selectors.isLoading(dataState)], + ['has an error', selectors.hasError(dataState)], + ['has more children', selectors.hasMoreChildren(dataState)], + ['has more ancestors', selectors.hasMoreAncestors(dataState)], + ['document to fetch', selectors.databaseDocumentIDToFetch(dataState)], + ['requires a pending request to be aborted', selectors.databaseDocumentIDToAbort(dataState)], + ] + .map(([message, value]) => `${message}: ${JSON.stringify(value)}`) + .join('\n'); + }; + + it(`shouldn't initially be loading, or have an error, or have more children or ancestors, or have a document to fetch, or have a pending request that needs to be aborted.`, () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: false + has an error: false + has more children: false + has more ancestors: false + document to fetch: null + requires a pending request to be aborted: null" + `); + }); + + describe('when there is a databaseDocumentID but no pending request', () => { + const databaseDocumentID = 'databaseDocumentID'; + beforeEach(() => { + actions = [ + { + type: 'appReceivedNewExternalProperties', + payload: { databaseDocumentID }, + }, + ]; + }); + it('should need to fetch the databaseDocumentID', () => { + expect(selectors.databaseDocumentIDToFetch(state())).toBe(databaseDocumentID); + }); + it('should not be loading, have an error, have more children or ancestors, or have a pending request that needs to be aborted.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: false + has an error: false + has more children: false + has more ancestors: false + document to fetch: \\"databaseDocumentID\\" + requires a pending request to be aborted: null" + `); + }); + }); + describe('when there is a pending request but no databaseDocumentID', () => { + const databaseDocumentID = 'databaseDocumentID'; + beforeEach(() => { + actions = [ + { + type: 'appRequestedResolverData', + payload: databaseDocumentID, + }, + ]; + }); + it('should be loading', () => { + expect(selectors.isLoading(state())).toBe(true); + }); + it('should have a request to abort', () => { + expect(selectors.databaseDocumentIDToAbort(state())).toBe(databaseDocumentID); + }); + it('should not have an error, more children, more ancestors, or a document to fetch.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: true + has an error: false + has more children: false + has more ancestors: false + document to fetch: null + requires a pending request to be aborted: \\"databaseDocumentID\\"" + `); + }); + }); + describe('when there is a pending request for the current databaseDocumentID', () => { + const databaseDocumentID = 'databaseDocumentID'; + beforeEach(() => { + actions = [ + { + type: 'appReceivedNewExternalProperties', + payload: { databaseDocumentID }, + }, + { + type: 'appRequestedResolverData', + payload: databaseDocumentID, + }, + ]; + }); + it('should be loading', () => { + expect(selectors.isLoading(state())).toBe(true); + }); + it('should not have a request to abort', () => { + expect(selectors.databaseDocumentIDToAbort(state())).toBe(null); + }); + it('should not have an error, more children, more ancestors, a document to begin fetching, or a pending request that should be aborted.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: true + has an error: false + has more children: false + has more ancestors: false + document to fetch: null + requires a pending request to be aborted: null" + `); + }); + describe('when the pending request fails', () => { + beforeEach(() => { + actions.push({ + type: 'serverFailedToReturnResolverData', + payload: databaseDocumentID, + }); + }); + it('should not be loading', () => { + expect(selectors.isLoading(state())).toBe(false); + }); + it('should have an error', () => { + expect(selectors.hasError(state())).toBe(true); + }); + it('should not be loading, have more children, have more ancestors, have a document to fetch, or have a pending request that needs to be aborted.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: false + has an error: true + has more children: false + has more ancestors: false + document to fetch: null + requires a pending request to be aborted: null" + `); + }); + }); + }); + describe('when there is a pending request for a different databaseDocumentID than the current one', () => { + const firstDatabaseDocumentID = 'first databaseDocumentID'; + const secondDatabaseDocumentID = 'second databaseDocumentID'; + beforeEach(() => { + actions = [ + // receive the document ID, this would cause the middleware to starts the request + { + type: 'appReceivedNewExternalProperties', + payload: { databaseDocumentID: firstDatabaseDocumentID }, + }, + // this happens when the middleware starts the request + { + type: 'appRequestedResolverData', + payload: firstDatabaseDocumentID, + }, + // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one + { + type: 'appReceivedNewExternalProperties', + payload: { databaseDocumentID: secondDatabaseDocumentID }, + }, + ]; + }); + it('should be loading', () => { + expect(selectors.isLoading(state())).toBe(true); + }); + it('should need to fetch the second databaseDocumentID', () => { + expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + }); + it('should need to abort the request for the databaseDocumentID', () => { + expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + }); + it('should not have an error, more children, or more ancestors.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: true + has an error: false + has more children: false + has more ancestors: false + document to fetch: \\"second databaseDocumentID\\" + requires a pending request to be aborted: \\"first databaseDocumentID\\"" + `); + }); + describe('and when the old request was aborted', () => { + beforeEach(() => { + actions.push({ + type: 'appAbortedResolverDataRequest', + payload: firstDatabaseDocumentID, + }); + }); + it('should not require a pending request to be aborted', () => { + expect(selectors.databaseDocumentIDToAbort(state())).toBe(null); + }); + it('should have a document to fetch', () => { + expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + }); + it('should not be loading', () => { + expect(selectors.isLoading(state())).toBe(false); + }); + it('should not have an error, more children, or more ancestors.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: false + has an error: false + has more children: false + has more ancestors: false + document to fetch: \\"second databaseDocumentID\\" + requires a pending request to be aborted: null" + `); + }); + describe('and when the next request starts', () => { + beforeEach(() => { + actions.push({ + type: 'appRequestedResolverData', + payload: secondDatabaseDocumentID, + }); + }); + it('should not have a document ID to fetch', () => { + expect(selectors.databaseDocumentIDToFetch(state())).toBe(null); + }); + it('should be loading', () => { + expect(selectors.isLoading(state())).toBe(true); + }); + it('should not have an error, more children, more ancestors, or a pending request that needs to be aborted.', () => { + expect(viewAsAString(state())).toMatchInlineSnapshot(` + "is loading: true + has an error: false + has more children: false + has more ancestors: false + document to fetch: null + requires a pending request to be aborted: null" + `); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 5654f1ca423f3..f15cb6427dccf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -8,449 +8,92 @@ import rbush from 'rbush'; import { createSelector } from 'reselect'; import { DataState, - IndexedProcessTree, - ProcessWidths, - ProcessPositions, - EdgeLineSegment, - ProcessWithWidthMetadata, - Matrix3, AdjacentProcessMap, Vector2, - EdgeLineMetadata, IndexedEntity, IndexedEdgeLineSegment, IndexedProcessNode, AABB, VisibleEntites, } from '../../types'; -import { ResolverEvent } from '../../../../common/endpoint/types'; -import * as event from '../../../../common/endpoint/models/event'; -import { add as vector2Add, applyMatrix3 } from '../../lib/vector2'; import { isGraphableProcess, isTerminatedProcess, uniquePidForProcess, } from '../../models/process_event'; -import { - factory as indexedProcessTreeFactory, - children as indexedProcessTreeChildren, - parent as indexedProcessTreeParent, - size, - levelOrder, -} from '../../models/indexed_process_tree'; -import { getFriendlyElapsedTime } from '../../lib/date'; +import { factory as indexedProcessTreeFactory } from '../../models/indexed_process_tree'; import { isEqual } from '../../lib/aabb'; -const unit = 140; -const distanceBetweenNodesInUnits = 2; - -export function isLoading(state: DataState) { - return state.isLoading; -} - -export function hasError(state: DataState) { - return state.hasError; -} +import { + ResolverEvent, + ResolverTree, + ResolverNodeStats, + ResolverRelatedEvents, +} from '../../../../common/endpoint/types'; +import * as resolverTreeModel from '../../models/resolver_tree'; +import { isometricTaxiLayout } from '../../models/indexed_process_tree/isometric_taxi_layout'; /** - * An isometric projection is a method for representing three dimensional objects in 2 dimensions. - * More information about isometric projections can be found here https://en.wikipedia.org/wiki/Isometric_projection. - * In our case, we obtain the isometric projection by rotating the objects 45 degrees in the plane of the screen - * and arctan(1/sqrt(2)) (~35.3 degrees) through the horizontal axis. - * - * A rotation by 45 degrees in the plane of the screen is given by: - * [ sqrt(2)/2 -sqrt(2)/2 0 - * sqrt(2)/2 sqrt(2)/2 0 - * 0 0 1] - * - * A rotation by arctan(1/sqrt(2)) through the horizantal axis is given by: - * [ 1 0 0 - * 0 sqrt(3)/3 -sqrt(6)/3 - * 0 sqrt(6)/3 sqrt(3)/3] - * - * We can multiply both of these matrices to get the final transformation below. + * If there is currently a request. */ -/* prettier-ignore */ -const isometricTransformMatrix: Matrix3 = [ - Math.sqrt(2) / 2, -(Math.sqrt(2) / 2), 0, - Math.sqrt(6) / 6, Math.sqrt(6) / 6, -(Math.sqrt(6) / 3), - 0, 0, 1, -] +export function isLoading(state: DataState): boolean { + return state.pendingRequestDatabaseDocumentID !== undefined; +} /** - * The distance in pixels (at scale 1) between nodes. Change this to space out nodes more + * If a request was made and it threw an error or returned a failure response code. */ -const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; +export function hasError(state: DataState): boolean { + if (state.lastResponse && state.lastResponse.successful === false) { + return true; + } else { + return false; + } +} /** - * Process events that will be graphed. + * The last ResolverTree we received, if any. It may be stale (it might not be for the same databaseDocumentID that + * we're currently interested in. */ -export const graphableProcesses = createSelector( - ({ results }: DataState) => results, - function (results: DataState['results']) { - return results.filter(isGraphableProcess); +const resolverTree = (state: DataState): ResolverTree | undefined => { + if (state.lastResponse && state.lastResponse.successful) { + return state.lastResponse.result; + } else { + return undefined; } -); +}; /** * Process events that will be displayed as terminated. */ -export const terminatedProcesses = createSelector( - ({ results }: DataState) => results, - function (results: DataState['results']) { - return new Set( - results.filter(isTerminatedProcess).map((terminatedEvent) => { +export const terminatedProcesses = createSelector(resolverTree, function (tree?: ResolverTree) { + if (!tree) { + return new Set(); + } + return new Set( + resolverTreeModel + .lifecycleEvents(tree) + .filter(isTerminatedProcess) + .map((terminatedEvent) => { return uniquePidForProcess(terminatedEvent); }) - ); - } -); + ); +}); /** - * In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its - * descedants and the rule that each process node must be at least 1 unit apart. Enforcing that all nodes are at least - * 1 unit apart on the x axis makes it easy to prevent the UI components from overlapping. There will always be space. - * - * Example widths: - * - * A and B each have a width of 0 - * - * A - * | - * B - * - * A has a width of 1. B and C have a width of 0. - * B and C must be 1 unit apart, so the A subtree has a width of 1. - * - * A - * ____|____ - * | | - * B C - * - * - * D, E, F, G, H all have a width of 0. - * B has a width of 1 since D->E must be 1 unit apart. - * Similarly, C has a width of 1 since F->G must be 1 unit apart. - * A has width of 3, since B has a width of 1, and C has a width of 1, and E->F must be at least - * 1 unit apart. - * A - * ____|____ - * | | - * B C - * ___|___ ___|___ - * | | | | - * D E F G - * | - * H - * + * Process events that will be graphed. */ -function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): ProcessWidths { - const widths = new Map(); - - if (size(indexedProcessTree) === 0) { - return widths; - } - - const processesInReverseLevelOrder = [...levelOrder(indexedProcessTree)].reverse(); - - for (const process of processesInReverseLevelOrder) { - const children = indexedProcessTreeChildren(indexedProcessTree, process); - - const sumOfWidthOfChildren = function sumOfWidthOfChildren() { - return children.reduce(function sum(currentValue, child) { - /** - * `widths.get` will always return a number in this case. - * This loop sequences a tree in reverse level order. Width values are set for each node. - * Therefore a parent can always find a width for its children, since all of its children - * will have been handled already. - */ - return currentValue + widths.get(child)!; - }, 0); - }; - - const width = sumOfWidthOfChildren() + Math.max(0, children.length - 1) * distanceBetweenNodes; - widths.set(process, width); - } - - return widths; -} - -function processEdgeLineSegments( - indexedProcessTree: IndexedProcessTree, - widths: ProcessWidths, - positions: ProcessPositions -): EdgeLineSegment[] { - const edgeLineSegments: EdgeLineSegment[] = []; - for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { - const edgeLineMetadata: EdgeLineMetadata = { uniqueId: '' }; - /** - * We only handle children, drawing lines back to their parents. The root has no parent, so we skip it - */ - if (metadata.parent === null) { - // eslint-disable-next-line no-continue - continue; - } - const { process, parent, parentWidth } = metadata; - const position = positions.get(process); - const parentPosition = positions.get(parent); - const parentId = event.entityId(parent); - const processEntityId = event.entityId(process); - const edgeLineId = parentId ? parentId + processEntityId : parentId; - - if (position === undefined || parentPosition === undefined) { - /** - * All positions have been precalculated, so if any are missing, it's an error. This will never happen. - */ - throw new Error(); - } - - const parentTime = event.eventTimestamp(parent); - const processTime = event.eventTimestamp(process); - if (parentTime && processTime) { - const elapsedTime = getFriendlyElapsedTime(parentTime, processTime); - if (elapsedTime) edgeLineMetadata.elapsedTime = elapsedTime; - } - edgeLineMetadata.uniqueId = edgeLineId; - - /** - * The point halfway between the parent and child on the y axis, we sometimes have a hard angle here in the edge line - */ - const midwayY = parentPosition[1] + (position[1] - parentPosition[1]) / 2; - - /** - * When drawing edge lines between a parent and children (when there are multiple children) we draw a pitchfork type - * design. The 'midway' line, runs along the x axis and joins all the children with a single descendant line from the parent. - * See the ascii diagram below. The underscore characters would be the midway line. - * - * A - * ____|____ - * | | - * B C - */ - const lineFromProcessToMidwayLine: EdgeLineSegment = { - points: [[position[0], midwayY], position], - metadata: edgeLineMetadata, - }; - - const siblings = indexedProcessTreeChildren(indexedProcessTree, parent); - const isFirstChild = process === siblings[0]; - - if (metadata.isOnlyChild) { - // add a single line segment directly from parent to child. We don't do the 'pitchfork' in this case. - edgeLineSegments.push({ points: [parentPosition, position], metadata: edgeLineMetadata }); - } else if (isFirstChild) { - /** - * If the parent has multiple children, we draw the 'midway' line, and the line from the - * parent to the midway line, while handling the first child. - * - * Consider A the parent, and B the first child. We would draw somemthing like what's in the below diagram. The line from the - * midway line to C would be drawn when we handle C. - * - * A - * ____|____ - * | - * B C - */ - const { firstChildWidth, lastChildWidth } = metadata; - - const lineFromParentToMidwayLine: EdgeLineSegment = { - points: [parentPosition, [parentPosition[0], midwayY]], - metadata: { uniqueId: `parentToMid${edgeLineId}` }, - }; - - const widthOfMidline = parentWidth - firstChildWidth / 2 - lastChildWidth / 2; - - const minX = parentWidth / -2 + firstChildWidth / 2; - const maxX = minX + widthOfMidline; - - const midwayLine: EdgeLineSegment = { - points: [ - [ - // Position line relative to the parent's x component - parentPosition[0] + minX, - midwayY, - ], - [ - // Position line relative to the parent's x component - parentPosition[0] + maxX, - midwayY, - ], - ], - metadata: { uniqueId: `midway${edgeLineId}` }, - }; - - edgeLineSegments.push( - /* line from parent to midway line */ - lineFromParentToMidwayLine, - midwayLine, - lineFromProcessToMidwayLine - ); - } else { - // If this isn't the first child, it must have siblings (the first of which drew the midway line and line - // from the parent to the midway line - edgeLineSegments.push(lineFromProcessToMidwayLine); - } - } - return edgeLineSegments; -} - -function* levelOrderWithWidths( - tree: IndexedProcessTree, - widths: ProcessWidths -): Iterable { - for (const process of levelOrder(tree)) { - const parent = indexedProcessTreeParent(tree, process); - const width = widths.get(process); - - if (width === undefined) { - /** - * All widths have been precalcluated, so this will not happen. - */ - throw new Error(); - } - - /** If the parent is undefined, we are processing the root. */ - if (parent === undefined) { - yield { - process, - width, - parent: null, - parentWidth: null, - isOnlyChild: null, - firstChildWidth: null, - lastChildWidth: null, - }; - } else { - const parentWidth = widths.get(parent); - - if (parentWidth === undefined) { - /** - * All widths have been precalcluated, so this will not happen. - */ - throw new Error(); - } - - const metadata: Partial = { - process, - width, - parent, - parentWidth, - }; - - const siblings = indexedProcessTreeChildren(tree, parent); - if (siblings.length === 1) { - metadata.isOnlyChild = true; - metadata.lastChildWidth = width; - metadata.firstChildWidth = width; - } else { - const firstChildWidth = widths.get(siblings[0]); - const lastChildWidth = widths.get(siblings[siblings.length - 1]); - if (firstChildWidth === undefined || lastChildWidth === undefined) { - /** - * All widths have been precalcluated, so this will not happen. - */ - throw new Error(); - } - metadata.isOnlyChild = false; - metadata.firstChildWidth = firstChildWidth; - metadata.lastChildWidth = lastChildWidth; - } - - yield metadata as ProcessWithWidthMetadata; - } - } -} - -function processPositions( - indexedProcessTree: IndexedProcessTree, - widths: ProcessWidths -): ProcessPositions { - const positions = new Map(); - /** - * This algorithm iterates the tree in level order. It keeps counters that are reset for each parent. - * By keeping track of the last parent node, we can know when we are dealing with a new set of siblings and - * reset the counters. - */ - let lastProcessedParentNode: ResolverEvent | undefined; - /** - * Nodes are positioned relative to their siblings. We walk this in level order, so we handle - * children left -> right. - * - * The width of preceding siblings is used to left align the node. - * The number of preceding siblings is important because each sibling must be 1 unit apart - * on the x axis. - */ - let numberOfPrecedingSiblings = 0; - let runningWidthOfPrecedingSiblings = 0; - - for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { - // Handle root node - if (metadata.parent === null) { - const { process } = metadata; - /** - * Place the root node at (0, 0) for now. - */ - positions.set(process, [0, 0]); - } else { - const { process, parent, isOnlyChild, width, parentWidth } = metadata; - - // Reinit counters when parent changes - if (lastProcessedParentNode !== parent) { - numberOfPrecedingSiblings = 0; - runningWidthOfPrecedingSiblings = 0; - - // keep track of this so we know when to reinitialize - lastProcessedParentNode = parent; - } - - const parentPosition = positions.get(parent); - - if (parentPosition === undefined) { - /** - * Since this algorithm populates the `positions` map in level order, - * the parent node will have been processed already and the parent position - * will always be available. - * - * This will never happen. - */ - throw new Error(); - } - - /** - * The x 'offset' is added to the x value of the parent to determine the position of the node. - * We add `parentWidth / -2` in order to align the left side of this node with the left side of its parent. - * We add `numberOfPrecedingSiblings * distanceBetweenNodes` in order to keep each node 1 apart on the x axis. - * We add `runningWidthOfPrecedingSiblings` so that we don't overlap with our preceding siblings. We stack em up. - * We add `width / 2` so that we center the node horizontally (in case it has non-0 width.) - */ - const xOffset = - parentWidth / -2 + - numberOfPrecedingSiblings * distanceBetweenNodes + - runningWidthOfPrecedingSiblings + - width / 2; - - /** - * The y axis gains `-distanceBetweenNodes` as we move down the screen 1 unit at a time. - */ - let yDistanceBetweenNodes = -distanceBetweenNodes; - - if (!isOnlyChild) { - // Make space on leaves to show elapsed time - yDistanceBetweenNodes *= 2; - } - - const position = vector2Add([xOffset, yDistanceBetweenNodes], parentPosition); - - positions.set(process, position); - - numberOfPrecedingSiblings += 1; - runningWidthOfPrecedingSiblings += width; - } +export const graphableProcesses = createSelector(resolverTree, function (tree?) { + if (tree) { + return resolverTreeModel.lifecycleEvents(tree).filter(isGraphableProcess); + } else { + return []; } +}); - return positions; -} - +/** + * The 'indexed process tree' contains the tree data, indexed in helpful ways. Used for O(1) access to stuff during graph layout. + */ export const indexedProcessTree = createSelector(graphableProcesses, function indexedTree( /* eslint-disable no-shadow */ graphableProcesses @@ -462,22 +105,28 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in /** * This returns a map of entity_ids to stats about the related events and alerts. */ -export function relatedEventsStats(data: DataState) { - return data.relatedEventsStats; -} +export const relatedEventsStats: ( + state: DataState +) => Map | null = createSelector(resolverTree, (tree?: ResolverTree) => { + if (tree) { + return resolverTreeModel.relatedEventsStats(tree); + } else { + return null; + } +}); /** - * returns {Map} a map of entity_ids to related event data. + * returns a map of entity_ids to related event data. */ -export function relatedEventsByEntityId(data: DataState) { +export function relatedEventsByEntityId(data: DataState): Map { return data.relatedEvents; } /** - * returns {Map} a map of entity_ids to booleans indicating if it is waiting on related event + * returns a map of entity_ids to booleans indicating if it is waiting on related event * A value of `undefined` can be interpreted as `not yet requested` */ -export function relatedEventsReady(data: DataState) { +export function relatedEventsReady(data: DataState): Map { return data.relatedEventsReady; } @@ -502,6 +151,39 @@ export const processAdjacencies = createSelector( } ); +/** + * `true` if there were more children than we got in the last request. + */ +export function hasMoreChildren(state: DataState): boolean { + const tree = resolverTree(state); + return tree ? resolverTreeModel.hasMoreChildren(tree) : false; +} + +/** + * `true` if there were more ancestors than we got in the last request. + */ +export function hasMoreAncestors(state: DataState): boolean { + const tree = resolverTree(state); + return tree ? resolverTreeModel.hasMoreAncestors(tree) : false; +} + +/** + * If we need to fetch, this is the ID to fetch. + */ +export function databaseDocumentIDToFetch(state: DataState): string | null { + // If there is an ID, it must match either the last received version, or the pending version. + // Otherwise, we need to fetch it + // NB: this technique will not allow for refreshing of data. + if ( + state.databaseDocumentID !== undefined && + state.databaseDocumentID !== state.pendingRequestDatabaseDocumentID && + state.databaseDocumentID !== state.lastResponse?.databaseDocumentID + ) { + return state.databaseDocumentID; + } else { + return null; + } +} export const processNodePositionsAndEdgeLineSegments = createSelector( indexedProcessTree, function processNodePositionsAndEdgeLineSegments( @@ -509,53 +191,7 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( indexedProcessTree /* eslint-enable no-shadow */ ) { - /** - * Walk the tree in reverse level order, calculating the 'width' of subtrees. - */ - const widths = widthsOfProcessSubtrees(indexedProcessTree); - - /** - * Walk the tree in level order. Using the precalculated widths, calculate the position of nodes. - * Nodes are positioned relative to their parents and preceding siblings. - */ - const positions = processPositions(indexedProcessTree, widths); - - /** - * With the widths and positions precalculated, we calculate edge line segments (arrays of vector2s) - * which connect them in a 'pitchfork' design. - */ - const edgeLineSegments = processEdgeLineSegments(indexedProcessTree, widths, positions); - - /** - * Transform the positions of nodes and edges so they seem like they are on an isometric grid. - */ - const transformedEdgeLineSegments: EdgeLineSegment[] = []; - const transformedPositions = new Map(); - - for (const [processEvent, position] of positions) { - transformedPositions.set(processEvent, applyMatrix3(position, isometricTransformMatrix)); - } - - for (const edgeLineSegment of edgeLineSegments) { - const { - points: [startPoint, endPoint], - } = edgeLineSegment; - - const transformedSegment: EdgeLineSegment = { - ...edgeLineSegment, - points: [ - applyMatrix3(startPoint, isometricTransformMatrix), - applyMatrix3(endPoint, isometricTransformMatrix), - ], - }; - - transformedEdgeLineSegments.push(transformedSegment); - } - - return { - processNodePositions: transformedPositions, - edgeLineSegments: transformedEdgeLineSegments, - }; + return isometricTaxiLayout(indexedProcessTree); } ); @@ -650,13 +286,18 @@ export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( } ); /** - * Returns the `children` and `ancestors` limits for the current graph, if any. - * - * @param state {DataState} the DataState from the reducer + * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. */ -export const limitsReached = (state: DataState): { children: boolean; ancestors: boolean } => { - return { - children: state.lineageLimits.children !== null, - ancestors: state.lineageLimits.ancestors !== null, - }; -}; +export function databaseDocumentIDToAbort(state: DataState): string | null { + /** + * If there is a pending request, and its not for the current databaseDocumentID (even, if the current databaseDocumentID is undefined) then we should abort the request. + */ + if ( + state.pendingRequestDatabaseDocumentID !== undefined && + state.pendingRequestDatabaseDocumentID !== state.databaseDocumentID + ) { + return state.pendingRequestDatabaseDocumentID; + } else { + return null; + } +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts index f10cfe0ba466a..eb2b402a694a5 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts @@ -11,6 +11,7 @@ import { ResolverState } from '../../types'; import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; import { visibleProcessNodePositionsAndEdgeLineSegments } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; +import { mock as mockResolverTree } from '../../models/resolver_tree'; describe('resolver visible entities', () => { let processA: LegacyEndpointEvent; @@ -111,7 +112,7 @@ describe('resolver visible entities', () => { ]; const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { events, stats: new Map(), lineageLimits: { children: '', ancestors: '' } }, + payload: { result: mockResolverTree({ events })!, databaseDocumentID: '' }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] }; store.dispatch(action); @@ -143,7 +144,7 @@ describe('resolver visible entities', () => { ]; const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { events, stats: new Map(), lineageLimits: { children: '', ancestors: '' } }, + payload: { result: mockResolverTree({ events })!, databaseDocumentID: '' }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] }; store.dispatch(action); diff --git a/x-pack/plugins/security_solution/public/resolver/store/index.ts b/x-pack/plugins/security_solution/public/resolver/store/index.ts index 203ecccb1d369..9809e443d2d13 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/index.ts @@ -7,14 +7,15 @@ import { createStore, applyMiddleware, Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; -import { ResolverAction, ResolverState } from '../types'; +import { ResolverState } from '../types'; import { StartServices } from '../../types'; import { resolverReducer } from './reducer'; import { resolverMiddlewareFactory } from './middleware'; +import { ResolverAction } from './actions'; export const storeFactory = ( context?: KibanaReactContextValue -): { store: Store } => { +): Store => { const actionsBlacklist: Array = ['userMovedPointer']; const composeEnhancers = composeWithDevTools({ name: 'Resolver', @@ -22,8 +23,5 @@ export const storeFactory = ( }); const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(context)); - const store = createStore(resolverReducer, composeEnhancers(middlewareEnhancer)); - return { - store, - }; + return createStore(resolverReducer, composeEnhancers(middlewareEnhancer)); }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware.ts deleted file mode 100644 index a1807255b5eaf..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Dispatch, MiddlewareAPI } from 'redux'; -import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; -import { StartServices } from '../../types'; -import { ResolverState, ResolverAction } from '../types'; -import { - ResolverEvent, - ResolverChildren, - ResolverAncestry, - ResolverLifecycleNode, - ResolverNodeStats, - ResolverRelatedEvents, -} from '../../../common/endpoint/types'; -import * as event from '../../../common/endpoint/models/event'; - -type MiddlewareFactory = ( - context?: KibanaReactContextValue -) => ( - api: MiddlewareAPI, S> -) => (next: Dispatch) => (action: ResolverAction) => unknown; - -function getLifecycleEventsAndStats( - nodes: ResolverLifecycleNode[], - stats: Map -): ResolverEvent[] { - return nodes.reduce((flattenedEvents: ResolverEvent[], currentNode: ResolverLifecycleNode) => { - if (currentNode.lifecycle && currentNode.lifecycle.length > 0) { - flattenedEvents.push(...currentNode.lifecycle); - } - - if (currentNode.stats) { - stats.set(currentNode.entityID, currentNode.stats); - } - - return flattenedEvents; - }, []); -} - -export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { - return (api) => (next) => async (action: ResolverAction) => { - next(action); - if (action.type === 'userChangedSelectedEvent') { - /** - * concurrently fetches a process's details, its ancestors, and its related events. - */ - if (context?.services.http && action.payload.selectedEvent) { - api.dispatch({ type: 'appRequestedResolverData' }); - try { - let lifecycle: ResolverEvent[]; - let children: ResolverChildren; - let ancestry: ResolverAncestry; - let entityId: string; - let stats: ResolverNodeStats; - if (event.isLegacyEvent(action.payload.selectedEvent)) { - entityId = action.payload.selectedEvent?.endgame?.unique_pid.toString(); - const legacyEndpointID = action.payload.selectedEvent?.agent?.id; - [{ lifecycle, children, ancestry, stats }] = await Promise.all([ - context.services.http.get(`/api/endpoint/resolver/${entityId}`, { - query: { legacyEndpointID, children: 5, ancestors: 5 }, - }), - ]); - } else { - entityId = action.payload.selectedEvent.process.entity_id; - [{ lifecycle, children, ancestry, stats }] = await Promise.all([ - context.services.http.get(`/api/endpoint/resolver/${entityId}`, { - query: { - children: 5, - ancestors: 5, - }, - }), - ]); - } - const nodeStats: Map = new Map(); - nodeStats.set(entityId, stats); - const lineageLimits = { children: children.nextChild, ancestors: ancestry.nextAncestor }; - - const events = [ - ...lifecycle, - ...getLifecycleEventsAndStats(children.childNodes, nodeStats), - ...getLifecycleEventsAndStats(ancestry.ancestors, nodeStats), - ]; - api.dispatch({ - type: 'serverReturnedResolverData', - payload: { - events, - stats: nodeStats, - lineageLimits, - }, - }); - } catch (error) { - api.dispatch({ - type: 'serverFailedToReturnResolverData', - }); - } - } - } else if ( - (action.type === 'userRequestedRelatedEventData' || - action.type === 'appDetectedMissingEventData') && - context - ) { - const entityIdToFetchFor = action.payload; - let result: ResolverRelatedEvents; - try { - result = await context.services.http.get( - `/api/endpoint/resolver/${entityIdToFetchFor}/events`, - { - query: { events: 100 }, - } - ); - api.dispatch({ - type: 'serverReturnedRelatedEventData', - payload: result, - }); - } catch (e) { - api.dispatch({ - type: 'serverFailedToReturnRelatedEventData', - payload: action.payload, - }); - } - } - }; -}; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts new file mode 100644 index 0000000000000..194b50256c631 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, MiddlewareAPI } from 'redux'; +import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../../types'; +import { ResolverState } from '../../types'; +import { ResolverRelatedEvents } from '../../../../common/endpoint/types'; +import { ResolverTreeFetcher } from './resolver_tree_fetcher'; +import { ResolverAction } from '../actions'; + +type MiddlewareFactory = ( + context?: KibanaReactContextValue +) => ( + api: MiddlewareAPI, S> +) => (next: Dispatch) => (action: ResolverAction) => unknown; + +/** + * The redux middleware that the app uses to trigger side effects. + * All data fetching should be done here. + * For actions that the app triggers directly, use `app` as a prefix for the type. + * For actions that are triggered as a result of server interaction, use `server` as a prefix for the type. + */ +export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { + return (api) => (next) => { + // This cannot work w/o `context`. + if (!context) { + return async (action: ResolverAction) => { + next(action); + }; + } + const resolverTreeFetcher = ResolverTreeFetcher(context, api); + return async (action: ResolverAction) => { + next(action); + + resolverTreeFetcher(); + + if ( + action.type === 'userRequestedRelatedEventData' || + action.type === 'appDetectedMissingEventData' + ) { + const entityIdToFetchFor = action.payload; + let result: ResolverRelatedEvents; + try { + result = await context.services.http.get( + `/api/endpoint/resolver/${entityIdToFetchFor}/events`, + { + query: { events: 100 }, + } + ); + + api.dispatch({ + type: 'serverReturnedRelatedEventData', + payload: result, + }); + } catch (e) { + api.dispatch({ + type: 'serverFailedToReturnRelatedEventData', + payload: action.payload, + }); + } + } + }; + }; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts new file mode 100644 index 0000000000000..59e944d95e04b --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-duplicate-imports */ + +import { Dispatch, MiddlewareAPI } from 'redux'; +import { ResolverTree, ResolverEntityIndex } from '../../../../common/endpoint/types'; + +import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; +import { ResolverState } from '../../types'; +import * as selectors from '../selectors'; +import { StartServices } from '../../../types'; +import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../../common/constants'; +import { ResolverAction } from '../actions'; +/** + * A function that handles syncing ResolverTree data w/ the current entity ID. + * This will make a request anytime the entityID changes (to something other than undefined.) + * If the entity ID changes while a request is in progress, the in-progress request will be cancelled. + * Call the returned function after each state transition. + * This is a factory because it is stateful and keeps that state in closure. + */ +export function ResolverTreeFetcher( + context: KibanaReactContextValue, + api: MiddlewareAPI, ResolverState> +): () => void { + let lastRequestAbortController: AbortController | undefined; + + // Call this after each state change. + // This fetches the ResolverTree for the current entityID + // if the entityID changes while + return async () => { + const state = api.getState(); + const databaseDocumentIDToFetch = selectors.databaseDocumentIDToFetch(state); + + if (selectors.databaseDocumentIDToAbort(state) && lastRequestAbortController) { + lastRequestAbortController.abort(); + // calling abort will cause an action to be fired + } else if (databaseDocumentIDToFetch !== null) { + lastRequestAbortController = new AbortController(); + let result: ResolverTree | undefined; + // Inform the state that we've made the request. Without this, the middleware will try to make the request again + // immediately. + api.dispatch({ + type: 'appRequestedResolverData', + payload: databaseDocumentIDToFetch, + }); + try { + const indices: string[] = context.services.uiSettings.get(defaultIndexKey); + const matchingEntities: ResolverEntityIndex = await context.services.http.get( + '/api/endpoint/resolver/entity', + { + signal: lastRequestAbortController.signal, + query: { + _id: databaseDocumentIDToFetch, + indices, + }, + } + ); + if (matchingEntities.length < 1) { + // If no entity_id could be found for the _id, bail out with a failure. + api.dispatch({ + type: 'serverFailedToReturnResolverData', + payload: databaseDocumentIDToFetch, + }); + return; + } + const entityIDToFetch = matchingEntities[0].entity_id; + result = await context.services.http.get(`/api/endpoint/resolver/${entityIDToFetch}`, { + signal: lastRequestAbortController.signal, + query: { + children: 5, + ancestors: 5, + }, + }); + } catch (error) { + // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError + if (error instanceof DOMException && error.name === 'AbortError') { + api.dispatch({ + type: 'appAbortedResolverDataRequest', + payload: databaseDocumentIDToFetch, + }); + } else { + api.dispatch({ + type: 'serverFailedToReturnResolverData', + payload: databaseDocumentIDToFetch, + }); + } + } + if (result !== undefined) { + api.dispatch({ + type: 'serverReturnedResolverData', + payload: { + result, + databaseDocumentID: databaseDocumentIDToFetch, + }, + }); + } + } + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts index 77dffd79ea094..65e53eb28549f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -8,7 +8,8 @@ import { htmlIdGenerator } from '@elastic/eui'; import { animateProcessIntoView } from './methods'; import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; -import { ResolverState, ResolverAction, ResolverUIState } from '../types'; +import { ResolverAction } from './actions'; +import { ResolverState, ResolverUIState } from '../types'; import { uniquePidForProcess } from '../models/process_event'; /** diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 5599b7e8ab613..55e0072c5227f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -56,6 +56,19 @@ export const processNodePositionsAndEdgeLineSegments = composeSelectors( dataSelectors.processNodePositionsAndEdgeLineSegments ); +/** + * If we need to fetch, this is the entity ID to fetch. + */ +export const databaseDocumentIDToFetch = composeSelectors( + dataStateSelector, + dataSelectors.databaseDocumentIDToFetch +); + +export const databaseDocumentIDToAbort = composeSelectors( + dataStateSelector, + dataSelectors.databaseDocumentIDToAbort +); + export const processAdjacencies = composeSelectors( dataStateSelector, dataSelectors.processAdjacencies @@ -158,15 +171,6 @@ export const graphableProcesses = composeSelectors( dataSelectors.graphableProcesses ); -/** - * Select the `ancestors` and `children` limits that were reached or exceeded - * during the request for the current tree. - */ -export const lineageLimitsReached = composeSelectors( - dataStateSelector, - dataSelectors.limitsReached -); - /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 0742fa2e30560..fe5b2276603a8 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -7,11 +7,11 @@ import { Store } from 'redux'; import { BBox } from 'rbush'; import { ResolverAction } from './store/actions'; -export { ResolverAction } from './store/actions'; import { ResolverEvent, ResolverNodeStats, ResolverRelatedEvents, + ResolverTree, } from '../../common/endpoint/types'; /** @@ -176,15 +176,49 @@ export interface VisibleEntites { * State for `data` reducer which handles receiving Resolver data from the backend. */ export interface DataState { - readonly results: readonly ResolverEvent[]; - readonly relatedEventsStats: Readonly>; + readonly relatedEventsStats: Map; readonly relatedEvents: Map; readonly relatedEventsReady: Map; - readonly lineageLimits: Readonly<{ children: string | null; ancestors: string | null }>; - isLoading: boolean; - hasError: boolean; + /** + * The `_id` for an ES document. Used to select a process that we'll show the graph for. + */ + readonly databaseDocumentID?: string; + /** + * The id used for the pending request, if there is one. + */ + readonly pendingRequestDatabaseDocumentID?: string; + + /** + * The parameters and response from the last successful request. + */ + readonly lastResponse?: { + /** + * The id used in the request. + */ + readonly databaseDocumentID: string; + } & ( + | { + /** + * If a response with a success code was received, this is `true`. + */ + readonly successful: true; + /** + * The ResolverTree parsed from the response. + */ + readonly result: ResolverTree; + } + | { + /** + * If the request threw an exception or the response had a failure code, this will be false. + */ + readonly successful: false; + } + ); } +/** + * Represents an ordered pair. Used for x-y coordinates and the like. + */ export type Vector2 = readonly [number, number]; /** @@ -416,3 +450,17 @@ export type ResolverProcessType = | 'unknownEvent'; export type ResolverStore = Store; + +/** + * Describes the basic Resolver graph layout. + */ +export interface IsometricTaxiLayout { + /** + * A map of events to position. each event represents its own node. + */ + processNodePositions: Map; + /** + * A map of edgline segments, which graphically connect nodes. + */ + edgeLineSegments: EdgeLineSegment[]; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index 67c091627741a..c2a7bbaacbf1d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -11,9 +11,10 @@ import styled from 'styled-components'; import { EuiRange, EuiPanel, EuiIcon } from '@elastic/eui'; import { useSelector, useDispatch } from 'react-redux'; import { SideEffectContext } from './side_effect_context'; -import { ResolverAction, Vector2 } from '../types'; +import { Vector2 } from '../types'; import * as selectors from '../store/selectors'; import { useResolverTheme } from './assets'; +import { ResolverAction } from '../store/actions'; interface StyledGraphControls { graphControlsBackground: string; diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 5c188fdc71156..205180a40d62a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -3,164 +3,44 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ -import React, { useLayoutEffect, useContext } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import styled from 'styled-components'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import * as selectors from '../store/selectors'; -import { EdgeLine } from './edge_line'; -import { Panel } from './panel'; -import { GraphControls } from './graph_controls'; -import { ProcessEventDot } from './process_event_dot'; -import { useCamera } from './use_camera'; -import { SymbolDefinitions, useResolverTheme } from './assets'; -import { entityId } from '../../../common/endpoint/models/event'; -import { ResolverAction } from '../types'; -import { ResolverEvent } from '../../../common/endpoint/types'; -import { SideEffectContext } from './side_effect_context'; +import React, { useMemo } from 'react'; +import { Provider } from 'react-redux'; +import { ResolverMap } from './map'; +import { storeFactory } from '../store'; +import { StartServices } from '../../types'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -interface StyledResolver { - backgroundColor: string; -} - -const StyledResolver = styled.div` - /** - * Take up all availble space - */ - &, - .resolver-graph { - display: flex; - flex-grow: 1; - } - .loading-container { - display: flex; - align-items: center; - justify-content: center; - flex-grow: 1; - } +/** + * The top level, unconnected, Resolver component. + */ +export const Resolver = React.memo(function ({ + className, + databaseDocumentID, +}: { /** - * The placeholder components use absolute positioning. + * Used by `styled-components`. */ - position: relative; + className?: string; /** - * Prevent partially visible components from showing up outside the bounds of Resolver. + * The `_id` value of an event in ES. + * Used as the origin of the Resolver graph. */ - overflow: hidden; - contain: strict; - background-color: ${(props) => props.backgroundColor}; -`; - -const StyledPanel = styled(Panel)` - position: absolute; - left: 0; - top: 0; - bottom: 0; - overflow: auto; - width: 25em; - max-width: 50%; -`; - -const StyledResolverContainer = styled.div` - display: flex; - flex-grow: 1; - contain: layout; -`; - -export const Resolver = React.memo(function Resolver({ - className, - selectedEvent, -}: { - className?: string; - selectedEvent?: ResolverEvent; + databaseDocumentID?: string; }) { - const { timestamp } = useContext(SideEffectContext); - - const { processNodePositions, connectingEdgeLineSegments } = useSelector( - selectors.visibleProcessNodePositionsAndEdgeLineSegments - )(timestamp()); - - const dispatch: (action: ResolverAction) => unknown = useDispatch(); - const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); - const { projectionMatrix, ref, onMouseDown } = useCamera(); - const isLoading = useSelector(selectors.isLoading); - const hasError = useSelector(selectors.hasError); - const relatedEventsStats = useSelector(selectors.relatedEventsStats); - const activeDescendantId = useSelector(selectors.uiActiveDescendantId); - const terminatedProcesses = useSelector(selectors.terminatedProcesses); - const { colorMap } = useResolverTheme(); - - useLayoutEffect(() => { - dispatch({ - type: 'userChangedSelectedEvent', - payload: { selectedEvent }, - }); - }, [dispatch, selectedEvent]); + const context = useKibana(); + const store = useMemo(() => { + return storeFactory(context); + }, [context]); + /** + * Setup the store and use `Provider` here. This allows the ResolverMap component to + * dispatch actions and read from state. + */ return ( - - {isLoading ? ( -
- -
- ) : hasError ? ( -
-
- {' '} - -
-
- ) : ( - - {connectingEdgeLineSegments.map(({ points: [startPosition, endPosition], metadata }) => ( - - ))} - {[...processNodePositions].map(([processEvent, position]) => { - const adjacentNodeMap = processToAdjacencyMap.get(processEvent); - const processEntityId = entityId(processEvent); - if (!adjacentNodeMap) { - // This should never happen - throw new Error('Issue calculating adjacency node map.'); - } - return ( - - ); - })} - - )} - - - -
+ + + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx new file mode 100644 index 0000000000000..9022932c1594f --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-duplicate-imports */ + +/* eslint-disable react/display-name */ + +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as selectors from '../store/selectors'; +import { EdgeLine } from './edge_line'; +import { GraphControls } from './graph_controls'; +import { ProcessEventDot } from './process_event_dot'; +import { useCamera } from './use_camera'; +import { SymbolDefinitions, useResolverTheme } from './assets'; +import { useStateSyncingActions } from './use_state_syncing_actions'; +import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; +import { entityId } from '../../../common/endpoint/models/event'; +import { SideEffectContext } from './side_effect_context'; + +/** + * The highest level connected Resolver component. Needs a `Provider` in its ancestry to work. + */ +export const ResolverMap = React.memo(function ({ + className, + databaseDocumentID, +}: { + /** + * Used by `styled-components`. + */ + className?: string; + /** + * The `_id` value of an event in ES. + * Used as the origin of the Resolver graph. + */ + databaseDocumentID?: string; +}) { + /** + * This is responsible for dispatching actions that include any external data. + * `databaseDocumentID` + */ + useStateSyncingActions({ databaseDocumentID }); + + const { timestamp } = useContext(SideEffectContext); + const { processNodePositions, connectingEdgeLineSegments } = useSelector( + selectors.visibleProcessNodePositionsAndEdgeLineSegments + )(timestamp()); + const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + const relatedEventsStats = useSelector(selectors.relatedEventsStats); + const terminatedProcesses = useSelector(selectors.terminatedProcesses); + const { projectionMatrix, ref, onMouseDown } = useCamera(); + const isLoading = useSelector(selectors.isLoading); + const hasError = useSelector(selectors.hasError); + const activeDescendantId = useSelector(selectors.uiActiveDescendantId); + const { colorMap } = useResolverTheme(); + + return ( + + {isLoading ? ( +
+ +
+ ) : hasError ? ( +
+
+ {' '} + +
+
+ ) : ( + + {connectingEdgeLineSegments.map(({ points: [startPosition, endPosition], metadata }) => ( + + ))} + {[...processNodePositions].map(([processEvent, position]) => { + const adjacentNodeMap = processToAdjacencyMap.get(processEvent); + const processEntityId = entityId(processEvent); + if (!adjacentNodeMap) { + // This should never happen + throw new Error('Issue calculating adjacency node map.'); + } + return ( + + ); + })} + + )} + + + +
+ ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index c8f6512077a6f..2a2e7e87394a9 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -22,7 +22,7 @@ import { displayNameRecord } from './process_event_dot'; import * as selectors from '../store/selectors'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as event from '../../../common/endpoint/models/event'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; import { SideEffectContext } from './side_effect_context'; import { ProcessEventListNarrowedByType } from './panels/panel_content_related_list'; import { EventCountsForProcess } from './panels/panel_content_related_counts'; @@ -141,15 +141,10 @@ const PanelContent = memo(function PanelContent() { [history, urlSearch] ); - // GO JONNY GO const relatedEventStats = useSelector(selectors.relatedEventsStats); const { crumbId, crumbEvent } = queryParams; - const relatedStatsForIdFromParams = useMemo(() => { - if (idFromParams) { - return relatedEventStats.get(idFromParams); - } - return undefined; - }, [relatedEventStats, idFromParams]); + const relatedStatsForIdFromParams: ResolverNodeStats | undefined = + idFromParams && relatedEventStats ? relatedEventStats.get(idFromParams) : undefined; /** * Determine which set of breadcrumbs to display based on the query parameters diff --git a/x-pack/plugins/security_solution/public/resolver/view/styles.tsx b/x-pack/plugins/security_solution/public/resolver/view/styles.tsx new file mode 100644 index 0000000000000..2a1e67f4a9fdc --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/styles.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import styled from 'styled-components'; +import { Panel } from './panel'; + +/** + * The top level DOM element for Resolver + * NB: `styled-components` may be used to wrap this. + */ +export const StyledMapContainer = styled.div<{ backgroundColor: string }>` + /** + * Take up all availble space + */ + &, + .resolver-graph { + display: flex; + flex-grow: 1; + } + .loading-container { + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + } + /** + * The placeholder components use absolute positioning. + */ + position: relative; + /** + * Prevent partially visible components from showing up outside the bounds of Resolver. + */ + overflow: hidden; + contain: strict; + background-color: ${(props) => props.backgroundColor}; +`; + +/** + * The Panel, styled for use in `ResolverMap`. + */ +export const StyledPanel = styled(Panel)` + position: absolute; + left: 0; + top: 0; + bottom: 0; + overflow: auto; + width: 25em; + max-width: 50%; +`; + +/** + * Used by ResolverMap to contain the lines and nodes. + */ +export const GraphContainer = styled.div` + display: flex; + flex-grow: 1; + contain: layout; +`; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index dc7cb9a2ab199..f772c20f8cf16 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -11,12 +11,14 @@ import { useCamera, useAutoUpdatingClientRect } from './use_camera'; import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; import { storeFactory } from '../store'; -import { Matrix3, ResolverAction, ResolverStore, SideEffectSimulator } from '../types'; +import { Matrix3, ResolverStore, SideEffectSimulator } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../lib/vector2'; import { sideEffectSimulator } from './side_effect_simulator'; import { mockProcessEvent } from '../models/process_event_test_helpers'; +import { mock as mockResolverTree } from '../models/resolver_tree'; +import { ResolverAction } from '../store/actions'; describe('useCamera on an unpainted element', () => { let element: HTMLElement; @@ -27,7 +29,7 @@ describe('useCamera on an unpainted element', () => { let simulator: SideEffectSimulator; beforeEach(async () => { - ({ store } = storeFactory()); + store = storeFactory(); const Test = function Test() { const camera = useCamera(); @@ -159,7 +161,7 @@ describe('useCamera on an unpainted element', () => { let process: ResolverEvent; beforeEach(() => { const events: ResolverEvent[] = []; - const numberOfEvents: number = Math.floor(Math.random() * 10 + 1); + const numberOfEvents: number = 10; for (let index = 0; index < numberOfEvents; index++) { const uniquePpid = index === 0 ? undefined : index - 1; @@ -174,23 +176,27 @@ describe('useCamera on an unpainted element', () => { }) ); } - const serverResponseAction: ResolverAction = { - type: 'serverReturnedResolverData', - payload: { - events, - stats: new Map(), - lineageLimits: { children: null, ancestors: null }, - }, - }; - act(() => { - store.dispatch(serverResponseAction); - }); + const tree = mockResolverTree({ events }); + if (tree !== null) { + const serverResponseAction: ResolverAction = { + type: 'serverReturnedResolverData', + payload: { result: tree, databaseDocumentID: '' }, + }; + act(() => { + store.dispatch(serverResponseAction); + }); + } else { + throw new Error('failed to create tree'); + } const processes: ResolverEvent[] = [ ...selectors .processNodePositionsAndEdgeLineSegments(store.getState()) .processNodePositions.keys(), ]; process = processes[processes.length - 1]; + if (!process) { + throw new Error('missing the process to bring into view'); + } simulator.controls.time = 0; const cameraAction: ResolverAction = { type: 'userBroughtProcessIntoView', diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_dispatch.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_dispatch.ts index a993a4ed595e1..90c3dadc56ba5 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_dispatch.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_dispatch.ts @@ -5,7 +5,7 @@ */ import { useDispatch } from 'react-redux'; -import { ResolverAction } from '../types'; +import { ResolverAction } from '../store/actions'; /** * Call `useDispatch`, but only accept `ResolverAction` actions. diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts new file mode 100644 index 0000000000000..b8ea2049f5c49 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useLayoutEffect } from 'react'; +import { useResolverDispatch } from './use_resolver_dispatch'; + +/** + * This is a hook that is meant to be used once at the top level of Resolver. + * It dispatches actions that keep the store in sync with external properties. + */ +export function useStateSyncingActions({ + databaseDocumentID, +}: { + /** + * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. + */ + databaseDocumentID?: string; +}) { + const dispatch = useResolverDispatch(); + useLayoutEffect(() => { + dispatch({ + type: 'appReceivedNewExternalProperties', + payload: { databaseDocumentID }, + }); + }, [dispatch, databaseDocumentID]); +} diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index fe38dd79176a5..1c414246929ab 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -4,13 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiTitle, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useCallback, useState } from 'react'; import { connect, ConnectedProps, useDispatch, useSelector } from 'react-redux'; @@ -31,6 +25,7 @@ import { setInsertTimeline, updateTimelineGraphEventId, } from '../../../timelines/store/timeline/actions'; +import { Resolver } from '../../../resolver/view'; import * as i18n from './translations'; @@ -39,6 +34,10 @@ const OverlayContainer = styled.div<{ bodyHeight?: number }>` width: 100%; `; +const StyledResolver = styled(Resolver)` + height: 100%; +`; + interface OwnProps { bodyHeight?: number; graphEventId?: string; @@ -117,9 +116,7 @@ const GraphOverlayComponent = ({
- - <>{`Resolver graph for event _id ${graphEventId}`} - + ({ overflow: auto; scrollbar-width: thin; flex: 1; - visibility: ${({ visible }) => (visible ? 'visible' : 'hidden')}; + display: ${({ visible }) => (visible ? 'block' : 'none')}; &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts index 9b45a1a6c5354..5c92b23d594de 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts @@ -12,12 +12,14 @@ import { validateChildren, validateAncestry, validateAlerts, + validateEntities, } from '../../../common/endpoint/schema/resolver'; import { handleEvents } from './resolver/events'; import { handleChildren } from './resolver/children'; import { handleAncestry } from './resolver/ancestry'; import { handleTree } from './resolver/tree'; import { handleAlerts } from './resolver/alerts'; +import { handleEntities } from './resolver/entity'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); @@ -66,4 +68,16 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp }, handleTree(log, endpointAppContext) ); + + /** + * Used to get details about an entity, aka process. + */ + router.get( + { + path: '/api/endpoint/resolver/entity', + validate: validateEntities, + options: { authRequired: true }, + }, + handleEntities() + ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts new file mode 100644 index 0000000000000..69b3780ec1683 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { validateEntities } from '../../../../common/endpoint/schema/resolver'; +import { ResolverEntityIndex } from '../../../../common/endpoint/types'; + +/** + * This is used to get an 'entity_id' which is an internal-to-Resolver concept, from an `_id`, which + * is the artificial ID generated by ES for each document. + */ +export function handleEntities(): RequestHandler> { + return async (context, request, response) => { + const { + query: { _id, indices }, + } = request; + + /** + * A safe type for the response based on the semantics of the query. + * We specify _source, asking for `process.entity_id` and we only + * accept documents that have it. + * Also, we only request 1 document. + */ + interface ExpectedQueryResponse { + hits: { + hits: + | [] + | [ + { + _source: { + process: { + entity_id: string; + }; + }; + } + ]; + }; + } + + const queryResponse: ExpectedQueryResponse = await context.core.elasticsearch.legacy.client.callAsCurrentUser( + 'search', + { + index: indices, + body: { + // only return process.entity_id + _source: 'process.entity_id', + // only return 1 match at most + size: 1, + query: { + bool: { + filter: [ + { + // only return documents with the matching _id + ids: { + values: _id, + }, + }, + { + exists: { + // only return documents that have process.entity_id + field: 'process.entity_id', + }, + }, + ], + }, + }, + }, + } + ); + + const responseBody: ResolverEntityIndex = []; + for (const { + _source: { + process: { entity_id }, + }, + } of queryResponse.hits.hits) { + responseBody.push({ + entity_id, + }); + } + return response.ok({ body: responseBody }); + }; +} diff --git a/x-pack/test/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js new file mode 100644 index 0000000000000..46848b1db0ef4 --- /dev/null +++ b/x-pack/test/functional/apps/discover/async_scripted_fields.js @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Tests for scripted field in default distribution where async search is used +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const kibanaServer = getService('kibanaServer'); + // const log = getService('log'); + const retry = getService('retry'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const testSubjects = getService('testSubjects'); + + const PageObjects = getPageObjects(['common', 'settings', 'discover', 'timePicker']); + const queryBar = getService('queryBar'); + + describe('async search with scripted fields', function () { + this.tags(['skipFirefox']); + + before(async function () { + await esArchiver.load('kibana_scripted_fields_on_logstash'); + await esArchiver.loadIfNeeded('logstash_functional'); + // changing the timepicker default here saves us from having to set it in Discover (~8s) + await kibanaServer.uiSettings.update({ + 'timepicker:timeDefaults': + '{ "from": "Sep 18, 2015 @ 19:37:13.000", "to": "Sep 23, 2015 @ 02:30:09.000"}', + }); + }); + + after(async function afterAll() { + await kibanaServer.uiSettings.replace({}); + await kibanaServer.uiSettings.update({}); + await esArchiver.unload('logstash_functional'); + await esArchiver.load('empty_kibana'); + }); + + it('query should show failed shards pop up', async function () { + if (false) { + /* If you had to modify the scripted fields, you could un-comment all this, run it, use es_archiver to update 'kibana_scripted_fields_on_logstash' + */ + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.createIndexPattern('logsta'); + await PageObjects.settings.clickScriptedFieldsTab(); + await log.debug('add scripted field'); + await PageObjects.settings.addScriptedField( + 'sharedFail', + 'painless', + 'string', + null, + '1', + // Scripted field below with multiple string checking actually should cause share failure message + // bcause it's not checking if all the fields it uses exist in each doc (and they don't) + "if (doc['response.raw'].value == '200') { return 'good ' + doc['url.raw'].value } else { return 'bad ' + doc['machine.os.raw'].value } " + ); + } + + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.selectIndexPattern('logsta*'); + + await retry.tryForTime(20000, async function () { + // wait for shards failed message + const shardMessage = await testSubjects.getVisibleText('euiToastHeader'); + log.debug(shardMessage); + expect(shardMessage).to.be('1 of 3 shards failed'); + }); + }); + + it('query return results with valid scripted field', async function () { + if (false) { + /* the commented-out steps below were used to create the scripted fields in the logstash-* index pattern + which are now saved in the esArchive. + */ + + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.clickIndexPatternLogstash(); + const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); + await PageObjects.settings.clickScriptedFieldsTab(); + await log.debug('add scripted field'); + await PageObjects.settings.addScriptedField( + 'goodScript', + 'painless', + 'string', + null, + '1', + // Scripted field below with should work + "if (doc['response.raw'].value == '200') { if (doc['url.raw'].size() > 0) { return 'good ' + doc['url.raw'].value } else { return 'good' } } else { if (doc['machine.os.raw'].size() > 0) { return 'bad ' + doc['machine.os.raw'].value } else { return 'bad' } }" + ); + await retry.try(async function () { + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + startingCount + 1 + ); + }); + + await PageObjects.settings.addScriptedField( + 'goodScript2', + 'painless', + 'string', + null, + '1', + // Scripted field below which should work + "if (doc['url.raw'].size() > 0) { String tempString = \"\"; for ( int i = (doc['url.raw'].value.length() - 1); i >= 0 ; i--) { tempString = tempString + (doc['url.raw'].value).charAt(i); } return tempString; } else { return \"emptyUrl\"; }" + ); + await retry.try(async function () { + expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( + startingCount + 2 + ); + }); + } + + await PageObjects.discover.selectIndexPattern('logstash-*'); + await queryBar.setQuery('php* OR *jpg OR *css*'); + await testSubjects.click('querySubmitButton'); + await retry.tryForTime(30000, async function () { + expect(await PageObjects.discover.getHitCount()).to.be('13,301'); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/discover/index.ts b/x-pack/test/functional/apps/discover/index.ts index b7937928a8632..323b728e16454 100644 --- a/x-pack/test/functional/apps/discover/index.ts +++ b/x-pack/test/functional/apps/discover/index.ts @@ -11,6 +11,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./preserve_url')); + loadTestFile(require.resolve('./async_scripted_fields')); loadTestFile(require.resolve('./reporting')); }); } diff --git a/x-pack/test/functional/es_archives/kibana_scripted_fields_on_logstash/data.json.gz b/x-pack/test/functional/es_archives/kibana_scripted_fields_on_logstash/data.json.gz new file mode 100644 index 0000000000000..1e57c64f2d7df Binary files /dev/null and b/x-pack/test/functional/es_archives/kibana_scripted_fields_on_logstash/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/kibana_scripted_fields_on_logstash/mappings.json b/x-pack/test/functional/es_archives/kibana_scripted_fields_on_logstash/mappings.json new file mode 100644 index 0000000000000..1816ebfc11891 --- /dev/null +++ b/x-pack/test/functional/es_archives/kibana_scripted_fields_on_logstash/mappings.json @@ -0,0 +1,2763 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "7b44fba6773e37c806ce290ea9b7024e", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2", + "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", + "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "cases": "08b8b110dbca273d37e8aef131ecab61", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "ae24d22d5986d04124cc6568f771066f", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "inventory-view": "5299b67717e96502c77babf1c16fd4d3", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "21c3ea0763beb1ecb0162529706b88c5", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "maps-telemetry": "268da3a48066123fc5baf35abaa55014", + "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "server": "ec97f1c5da1a19609a60874e5af1100c", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "f2d929253ecd06ffbac78b4047f45a86", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "296a89039fc4260292be36b1b005d8f2", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "a6f3af21b612339cbe6eecc1e5a60871", + "url": "b675c3be8d76ecf029294d51dc7ec65d", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "params": { + "enabled": false, + "type": "object" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + } + } + }, + "cardinality": { + "properties": { + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "has_any_services": { + "type": "boolean" + }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services_per_agent": { + "properties": { + "dotnet": { + "null_value": 0, + "type": "long" + }, + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } + } + } + }, + "application_usage_totals": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + } + } + }, + "application_usage_transactional": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + }, + "timestamp": { + "type": "date" + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "timepicker:quickRanges": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + }, + "type": "nested" + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "inventory-view": { + "properties": { + "accountId": { + "type": "keyword" + }, + "autoBounds": { + "type": "boolean" + }, + "autoReload": { + "type": "boolean" + }, + "boundsOverride": { + "properties": { + "max": { + "type": "integer" + }, + "min": { + "type": "integer" + } + } + }, + "customMetrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "customOptions": { + "properties": { + "field": { + "type": "keyword" + }, + "text": { + "type": "keyword" + } + }, + "type": "nested" + }, + "filterQuery": { + "properties": { + "expression": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + } + } + }, + "groupBy": { + "properties": { + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + }, + "metric": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "nodeType": { + "type": "keyword" + }, + "region": { + "type": "keyword" + }, + "time": { + "type": "long" + }, + "view": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "dynamic": "true", + "type": "object" + }, + "layerTypesCount": { + "dynamic": "true", + "type": "object" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "indexPatternsWithGeoFieldCount": { + "type": "long" + }, + "mapsTotalCount": { + "type": "long" + }, + "settings": { + "properties": { + "showMapVisualizationTypes": { + "type": "boolean" + } + } + }, + "timeCaptured": { + "type": "date" + } + } + }, + "metrics-explorer-view": { + "properties": { + "chartOptions": { + "properties": { + "stack": { + "type": "boolean" + }, + "type": { + "type": "keyword" + }, + "yAxisMode": { + "type": "keyword" + } + } + }, + "currentTimerange": { + "properties": { + "from": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "to": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "options": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "filterQuery": { + "type": "keyword" + }, + "groupBy": { + "type": "keyword" + }, + "limit": { + "type": "integer" + }, + "metrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eventType": { + "type": "keyword" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "templateTimelineId": { + "type": "text" + }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "type": "keyword" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "integer" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "type": "keyword" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "type": "keyword" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "properties": { + "certThresholds": { + "properties": { + "age": { + "type": "long" + }, + "expiration": { + "type": "long" + } + } + }, + "heartbeatIndices": { + "type": "keyword" + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 60e53a0973f0c..d40a3abfa8939 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25682,30 +25682,10 @@ react-intl@^2.8.0: intl-relativeformat "^2.1.0" invariant "^2.1.1" -react-is@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" - integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== - -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: - version "16.8.6" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" - integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== - -react-is@^16.8.3: - version "16.9.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb" - integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw== - -react-is@^16.8.4: - version "16.8.5" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.5.tgz#c54ac229dd66b5afe0de5acbe47647c3da692ff8" - integrity sha512-sudt2uq5P/2TznPV4Wtdi+Lnq3yaYW8LfvPKLM9BKD8jJNBkxMVyB0C9/GmVhLw7Jbdmndk/73n7XQGeN9A3QQ== - -react-is@^16.9.0: - version "16.11.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.11.0.tgz#b85dfecd48ad1ce469ff558a882ca8e8313928fa" - integrity sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw== +react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.3, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react-is@~16.3.0: version "16.3.2"