diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7ee84b6bc9e8d..d116b1d3a41fc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,9 @@ /dev_docs @elastic/kibana-tech-leads /packages/kbn-docs-utils/ @elastic/kibana-tech-leads @elastic/kibana-operations +# Virtual teams +/x-pack/plugins/rule_registry/ @elastic/rac + # App /x-pack/plugins/discover_enhanced/ @elastic/kibana-app /x-pack/plugins/lens/ @elastic/kibana-app @@ -31,6 +34,7 @@ /src/plugins/vis_type_pie/ @elastic/kibana-app /src/plugins/visualize/ @elastic/kibana-app /src/plugins/visualizations/ @elastic/kibana-app +/src/plugins/url_forwarding/ @elastic/kibana-app /packages/kbn-tinymath/ @elastic/kibana-app # Application Services @@ -369,6 +373,7 @@ /x-pack/test/plugin_functional/plugins/resolver_test/ @elastic/security-solution /x-pack/test/plugin_functional/test_suites/resolver/ @elastic/security-solution /x-pack/plugins/security_solution/ @elastic/security-solution +/x-pack/plugins/metrics_entities/ @elastic/security-solution /x-pack/test/detection_engine_api_integration @elastic/security-solution /x-pack/test/lists_api_integration @elastic/security-solution /x-pack/test/api_integration/apis/security_solution @elastic/security-solution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11c595a1ad983..14dfaa84cebb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,5 @@ # Contributing to Kibana -We understand that you may not have days at a time to work on Kibana. We ask that you read our [developer guide](https://www.elastic.co/guide/en/kibana/master/development.html) carefully so that you spend less time, overall, struggling to push your PR through our code review processes. +If you are an employee at Elastic, please check out our Developer Guide [here](https://docs.elastic.dev/kibana-dev-docs/welcome). -Our developer guide is written in asciidoc and located under [./docs/developer](./docs/developer) if you want to make edits or access it in raw form. +If you are an external developer, we have a legacy developer guide [here](https://www.elastic.co/guide/en/kibana/master/development.html), or you can view the raw docs from our new, internal Developer Guide [here](./dev_docs/getting_started/dev_welcome.mdx). Eventually, our internal Developer Guide will be opened for public consumption. diff --git a/dev_docs/assets/kibana_template_no_data_config.png b/dev_docs/assets/kibana_template_no_data_config.png index 5e54bfdce1938..a3d12fc018503 100644 Binary files a/dev_docs/assets/kibana_template_no_data_config.png and b/dev_docs/assets/kibana_template_no_data_config.png differ diff --git a/dev_docs/tutorials/kibana_page_template.mdx b/dev_docs/tutorials/kibana_page_template.mdx index eab5b2eb3ce8e..bc0abc99d8921 100644 --- a/dev_docs/tutorials/kibana_page_template.mdx +++ b/dev_docs/tutorials/kibana_page_template.mdx @@ -126,7 +126,7 @@ This is a built-in configuration that displays a very specific UI and requires v The `noDataConfig` is of type [`NoDataPagProps`](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx): -1. `solution: string`: Single name for the current solution, used to auto-generate the title, logo, description, and button label *(required)* +1. `solution: string`: Single name for the current solution, used to auto-generate the title, logo, and description *(required)* 2. `docsLink: string`: Required to set the docs link for the whole solution *(required)* 3. `logo?: string`: Optionally replace the auto-generated logo 4. `pageTitle?: string`: Optionally replace the auto-generated page title (h1) @@ -136,7 +136,7 @@ The `noDataConfig` is of type [`NoDataPagProps`](https://github.com/elastic/kiba There are two main actions for adding data that we promote throughout Kibana, Elastic Agent and Beats. They are added to the cards that are displayed by using the keys `elasticAgent` and `beats` respectively. For consistent messaging, these two cards are pre-configured but require specific `href`s and/or `onClick` handlers for directing the user to the right location for that solution. -It also accepts a `recommended` prop as a boolean to promote one or more of the cards through visuals added to the UI. It will also place the `recommended` ones first in the list. By default, the configuration will recommend `elasticAgent`. Optionally you can also replace the `button` label by passing a string, or the whole component by passing a `ReactNode`. +It also accepts a `recommended` prop as a boolean to promote one or more of the cards through visuals added to the UI. It will also place the `recommended` ones first in the list. Optionally you can also replace the `button` label by passing a string, or the whole component by passing a `ReactNode`. ```tsx @@ -145,18 +145,20 @@ const hasData = checkForData(); // No data configuration const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = { - solution: 'Observability', + solution: 'Analytics', + logo: 'logoKibana', docsLink: '#', actions: { - elasticAgent: { + beats: { href: '#', }, - beats: { + elasticAgent: { href: '#', }, }, }; +// Conditionally apply the configuration if there is no data Signature: ```typescript -static createGenericNotFoundEsUnavailableError(type: string, id: string): DecoratedError; +static createGenericNotFoundEsUnavailableError(type?: string | null, id?: string | null): DecoratedError; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| type | string | | -| id | string | | +| type | string | null | | +| id | string | null | | Returns: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.id.md new file mode 100644 index 0000000000000..88c3a7d3654be --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternListItem](./kibana-plugin-plugins-data-public.indexpatternlistitem.md) > [id](./kibana-plugin-plugins-data-public.indexpatternlistitem.id.md) + +## IndexPatternListItem.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.md new file mode 100644 index 0000000000000..609a5e0d9ef2c --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternListItem](./kibana-plugin-plugins-data-public.indexpatternlistitem.md) + +## IndexPatternListItem interface + +Signature: + +```typescript +export interface IndexPatternListItem +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-plugins-data-public.indexpatternlistitem.id.md) | string | | +| [title](./kibana-plugin-plugins-data-public.indexpatternlistitem.title.md) | string | | +| [type](./kibana-plugin-plugins-data-public.indexpatternlistitem.type.md) | string | | +| [typeMeta](./kibana-plugin-plugins-data-public.indexpatternlistitem.typemeta.md) | TypeMeta | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.title.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.title.md new file mode 100644 index 0000000000000..26f292bf0d17b --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.title.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternListItem](./kibana-plugin-plugins-data-public.indexpatternlistitem.md) > [title](./kibana-plugin-plugins-data-public.indexpatternlistitem.title.md) + +## IndexPatternListItem.title property + +Signature: + +```typescript +title: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.type.md new file mode 100644 index 0000000000000..467e8bb81b159 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternListItem](./kibana-plugin-plugins-data-public.indexpatternlistitem.md) > [type](./kibana-plugin-plugins-data-public.indexpatternlistitem.type.md) + +## IndexPatternListItem.type property + +Signature: + +```typescript +type?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.typemeta.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.typemeta.md new file mode 100644 index 0000000000000..3b93c5111f8dd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternlistitem.typemeta.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternListItem](./kibana-plugin-plugins-data-public.indexpatternlistitem.md) > [typeMeta](./kibana-plugin-plugins-data-public.indexpatternlistitem.typemeta.md) + +## IndexPatternListItem.typeMeta property + +Signature: + +```typescript +typeMeta?: TypeMeta; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getcache.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getcache.md index ad2a167bd8c74..1f0148df596af 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getcache.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getcache.md @@ -7,5 +7,5 @@ Signature: ```typescript -getCache: () => Promise[] | null | undefined>; +getCache: () => Promise>[] | null | undefined>; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getidswithtitle.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getidswithtitle.md index 7d29ced66afa8..b2dcddce0457c 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getidswithtitle.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.getidswithtitle.md @@ -9,8 +9,5 @@ Get list of index pattern ids with titles Signature: ```typescript -getIdsWithTitle: (refresh?: boolean) => Promise>; +getIdsWithTitle: (refresh?: boolean) => Promise; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.md index 26b393a5fb5b6..572a122066868 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.md @@ -25,13 +25,13 @@ export declare class IndexPatternsService | [fieldArrayToMap](./kibana-plugin-plugins-data-public.indexpatternsservice.fieldarraytomap.md) | | (fields: FieldSpec[], fieldAttrs?: FieldAttrs | undefined) => Record<string, FieldSpec> | Converts field array to map | | [find](./kibana-plugin-plugins-data-public.indexpatternsservice.find.md) | | (search: string, size?: number) => Promise<IndexPattern[]> | Find and load index patterns by title | | [get](./kibana-plugin-plugins-data-public.indexpatternsservice.get.md) | | (id: string) => Promise<IndexPattern> | Get an index pattern by id. Cache optimized | -| [getCache](./kibana-plugin-plugins-data-public.indexpatternsservice.getcache.md) | | () => Promise<SavedObject<IndexPatternSavedObjectAttrs>[] | null | undefined> | | +| [getCache](./kibana-plugin-plugins-data-public.indexpatternsservice.getcache.md) | | () => Promise<SavedObject<Pick<IndexPatternAttributes, "type" | "title" | "typeMeta">>[] | null | undefined> | | | [getDefault](./kibana-plugin-plugins-data-public.indexpatternsservice.getdefault.md) | | () => Promise<IndexPattern | null> | Get default index pattern | | [getDefaultId](./kibana-plugin-plugins-data-public.indexpatternsservice.getdefaultid.md) | | () => Promise<string | null> | Get default index pattern id | | [getFieldsForIndexPattern](./kibana-plugin-plugins-data-public.indexpatternsservice.getfieldsforindexpattern.md) | | (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise<any> | Get field list by providing an index patttern (or spec) | | [getFieldsForWildcard](./kibana-plugin-plugins-data-public.indexpatternsservice.getfieldsforwildcard.md) | | (options: GetFieldsOptions) => Promise<any> | Get field list by providing { pattern } | | [getIds](./kibana-plugin-plugins-data-public.indexpatternsservice.getids.md) | | (refresh?: boolean) => Promise<string[]> | Get list of index pattern ids | -| [getIdsWithTitle](./kibana-plugin-plugins-data-public.indexpatternsservice.getidswithtitle.md) | | (refresh?: boolean) => Promise<Array<{
id: string;
title: string;
}>> | Get list of index pattern ids with titles | +| [getIdsWithTitle](./kibana-plugin-plugins-data-public.indexpatternsservice.getidswithtitle.md) | | (refresh?: boolean) => Promise<IndexPatternListItem[]> | Get list of index pattern ids with titles | | [getTitles](./kibana-plugin-plugins-data-public.indexpatternsservice.gettitles.md) | | (refresh?: boolean) => Promise<string[]> | Get list of index pattern titles | | [refreshFields](./kibana-plugin-plugins-data-public.indexpatternsservice.refreshfields.md) | | (indexPattern: IndexPattern) => Promise<void> | Refresh field list for a given index pattern | | [savedObjectToSpec](./kibana-plugin-plugins-data-public.indexpatternsservice.savedobjecttospec.md) | | (savedObject: SavedObject<IndexPatternAttributes>) => IndexPatternSpec | Converts index pattern saved object to index pattern spec | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iserrorresponse.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iserrorresponse.md index e4ac35f19e959..93dfdeb056f15 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iserrorresponse.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iserrorresponse.md @@ -7,5 +7,5 @@ Signature: ```typescript -isErrorResponse: (response?: IKibanaSearchResponse | undefined) => boolean | undefined +isErrorResponse: (response?: IKibanaSearchResponse | undefined) => boolean ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 760f6d8651428..185dd771c4ace 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -66,6 +66,7 @@ | [IKibanaSearchRequest](./kibana-plugin-plugins-data-public.ikibanasearchrequest.md) | | | [IKibanaSearchResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.md) | | | [IndexPatternAttributes](./kibana-plugin-plugins-data-public.indexpatternattributes.md) | Interface for an index pattern saved object | +| [IndexPatternListItem](./kibana-plugin-plugins-data-public.indexpatternlistitem.md) | | | [IndexPatternSpec](./kibana-plugin-plugins-data-public.indexpatternspec.md) | Static index pattern format Serialized data object, representing index pattern attributes and state | | [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) | | | [ISearchSetup](./kibana-plugin-plugins-data-public.isearchsetup.md) | The setup contract exposed by the Search plugin exposes the search strategy extension point. | @@ -84,6 +85,7 @@ | [SavedQueryService](./kibana-plugin-plugins-data-public.savedqueryservice.md) | | | [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchsessioninfoprovider.md) | Provide info about current search session to be stored in the Search Session saved object | | [SearchSourceFields](./kibana-plugin-plugins-data-public.searchsourcefields.md) | search source fields | +| [TypeMeta](./kibana-plugin-plugins-data-public.typemeta.md) | | | [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) | Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) | ## Variables diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md index 3c99ae4c86c63..c54ffedf61034 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md @@ -47,7 +47,9 @@ search: { intervalLabel: string; })[]; getNumberHistogramIntervalByDatatableColumn: (column: import("../../expressions").DatatableColumn) => number | undefined; - getDateHistogramMetaDataByDatatableColumn: (column: import("../../expressions").DatatableColumn) => { + getDateHistogramMetaDataByDatatableColumn: (column: import("../../expressions").DatatableColumn, defaults?: Partial<{ + timeZone: string; + }>) => { interval: string | undefined; timeZone: string | undefined; timeRange: import("../common").TimeRange | undefined; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index 5fffc5436e9c6..cd9bd61736225 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "isClearable" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "isClearable" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "displayStyle">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.aggs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.aggs.md new file mode 100644 index 0000000000000..d2ab7ef72a4a5 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.aggs.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TypeMeta](./kibana-plugin-plugins-data-public.typemeta.md) > [aggs](./kibana-plugin-plugins-data-public.typemeta.aggs.md) + +## TypeMeta.aggs property + +Signature: + +```typescript +aggs?: Record; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.md new file mode 100644 index 0000000000000..dcc6500d54c5e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TypeMeta](./kibana-plugin-plugins-data-public.typemeta.md) + +## TypeMeta interface + +Signature: + +```typescript +export interface TypeMeta +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [aggs](./kibana-plugin-plugins-data-public.typemeta.aggs.md) | Record<string, AggregationRestrictions> | | +| [params](./kibana-plugin-plugins-data-public.typemeta.params.md) | {
rollup_index: string;
} | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.params.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.params.md new file mode 100644 index 0000000000000..6646f3c63ecc1 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.typemeta.params.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TypeMeta](./kibana-plugin-plugins-data-public.typemeta.md) > [params](./kibana-plugin-plugins-data-public.typemeta.params.md) + +## TypeMeta.params property + +Signature: + +```typescript +params?: { + rollup_index: string; + }; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getcache.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getcache.md index 821c06984e55e..db765cf54d048 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getcache.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getcache.md @@ -7,5 +7,5 @@ Signature: ```typescript -getCache: () => Promise[] | null | undefined>; +getCache: () => Promise>[] | null | undefined>; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getidswithtitle.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getidswithtitle.md index 6433c78483545..a047b056e0ed5 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getidswithtitle.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.getidswithtitle.md @@ -9,8 +9,5 @@ Get list of index pattern ids with titles Signature: ```typescript -getIdsWithTitle: (refresh?: boolean) => Promise>; +getIdsWithTitle: (refresh?: boolean) => Promise; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.md index f5e845ced3cd1..64c46fe4abbd8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsservice.md @@ -25,13 +25,13 @@ export declare class IndexPatternsService | [fieldArrayToMap](./kibana-plugin-plugins-data-server.indexpatternsservice.fieldarraytomap.md) | | (fields: FieldSpec[], fieldAttrs?: FieldAttrs | undefined) => Record<string, FieldSpec> | Converts field array to map | | [find](./kibana-plugin-plugins-data-server.indexpatternsservice.find.md) | | (search: string, size?: number) => Promise<IndexPattern[]> | Find and load index patterns by title | | [get](./kibana-plugin-plugins-data-server.indexpatternsservice.get.md) | | (id: string) => Promise<IndexPattern> | Get an index pattern by id. Cache optimized | -| [getCache](./kibana-plugin-plugins-data-server.indexpatternsservice.getcache.md) | | () => Promise<SavedObject<IndexPatternSavedObjectAttrs>[] | null | undefined> | | +| [getCache](./kibana-plugin-plugins-data-server.indexpatternsservice.getcache.md) | | () => Promise<SavedObject<Pick<IndexPatternAttributes, "type" | "title" | "typeMeta">>[] | null | undefined> | | | [getDefault](./kibana-plugin-plugins-data-server.indexpatternsservice.getdefault.md) | | () => Promise<IndexPattern | null> | Get default index pattern | | [getDefaultId](./kibana-plugin-plugins-data-server.indexpatternsservice.getdefaultid.md) | | () => Promise<string | null> | Get default index pattern id | | [getFieldsForIndexPattern](./kibana-plugin-plugins-data-server.indexpatternsservice.getfieldsforindexpattern.md) | | (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise<any> | Get field list by providing an index patttern (or spec) | | [getFieldsForWildcard](./kibana-plugin-plugins-data-server.indexpatternsservice.getfieldsforwildcard.md) | | (options: GetFieldsOptions) => Promise<any> | Get field list by providing { pattern } | | [getIds](./kibana-plugin-plugins-data-server.indexpatternsservice.getids.md) | | (refresh?: boolean) => Promise<string[]> | Get list of index pattern ids | -| [getIdsWithTitle](./kibana-plugin-plugins-data-server.indexpatternsservice.getidswithtitle.md) | | (refresh?: boolean) => Promise<Array<{
id: string;
title: string;
}>> | Get list of index pattern ids with titles | +| [getIdsWithTitle](./kibana-plugin-plugins-data-server.indexpatternsservice.getidswithtitle.md) | | (refresh?: boolean) => Promise<IndexPatternListItem[]> | Get list of index pattern ids with titles | | [getTitles](./kibana-plugin-plugins-data-server.indexpatternsservice.gettitles.md) | | (refresh?: boolean) => Promise<string[]> | Get list of index pattern titles | | [refreshFields](./kibana-plugin-plugins-data-server.indexpatternsservice.refreshfields.md) | | (indexPattern: IndexPattern) => Promise<void> | Refresh field list for a given index pattern | | [savedObjectToSpec](./kibana-plugin-plugins-data-server.indexpatternsservice.savedobjecttospec.md) | | (savedObject: SavedObject<IndexPatternAttributes>) => IndexPatternSpec | Converts index pattern saved object to index pattern spec | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md index b944c9dcc02a2..07ae46f8bbf12 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md @@ -18,6 +18,7 @@ export interface EmbeddableEditorState | --- | --- | --- | | [embeddableId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.embeddableid.md) | string | | | [originatingApp](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingapp.md) | string | | +| [originatingPath](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md) | string | | | [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md) | string | Pass current search session id when navigating to an editor, Editors could use it continue previous search session | | [valueInput](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.valueinput.md) | EmbeddableInput | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md new file mode 100644 index 0000000000000..e255f11f8a059 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableEditorState](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) > [originatingPath](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md) + +## EmbeddableEditorState.originatingPath property + +Signature: + +```typescript +originatingPath?: string; +``` diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 8655b126ae5db..3a4134cbf982e 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -18,6 +18,8 @@ URL:: ServiceNow instance URL. Username:: Username for HTTP Basic authentication. Password:: Password for HTTP Basic authentication. +The ServiceNow user requires at minimum read, create, and update access to the Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + [float] [[servicenow-connector-networking-configuration]] ==== Connector networking configuration diff --git a/examples/bfetch_explorer/kibana.json b/examples/bfetch_explorer/kibana.json index 4bd4492611812..0eda11670034c 100644 --- a/examples/bfetch_explorer/kibana.json +++ b/examples/bfetch_explorer/kibana.json @@ -4,6 +4,10 @@ "version": "0.0.1", "server": true, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "requiredPlugins": ["bfetch", "developerExamples"], "optionalPlugins": [], "requiredBundles": ["kibanaReact"] diff --git a/examples/developer_examples/kibana.json b/examples/developer_examples/kibana.json index 9e6b54c7af67c..a744b53137dc7 100644 --- a/examples/developer_examples/kibana.json +++ b/examples/developer_examples/kibana.json @@ -1,5 +1,9 @@ { "id": "developerExamples", + "owner": { + "name": "Kibana Core", + "githubTeam": "kibana-core" + }, "kibanaVersion": "kibana", "version": "0.0.1", "ui": true diff --git a/examples/expressions_explorer/kibana.json b/examples/expressions_explorer/kibana.json index 7e2062ff0a588..770ce91143d99 100644 --- a/examples/expressions_explorer/kibana.json +++ b/examples/expressions_explorer/kibana.json @@ -4,6 +4,10 @@ "version": "0.0.1", "server": false, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "requiredPlugins": ["expressions", "inspector", "uiActions", "developerExamples"], "optionalPlugins": [], "requiredBundles": [] diff --git a/package.json b/package.json index 4fdb713e37a12..63cbe52b55030 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@elastic/apm-rum-react": "^1.2.11", "@elastic/charts": "33.2.2", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", - "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.14", + "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.17", "@elastic/ems-client": "7.15.0", "@elastic/eui": "37.1.1", "@elastic/filesaver": "1.1.2", @@ -379,6 +379,7 @@ "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", "redux-thunks": "^1.0.0", + "remark-stringify": "^9.0.0", "regenerator-runtime": "^0.13.3", "request": "^2.88.0", "require-in-the-middle": "^5.0.2", @@ -679,7 +680,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^91.0.1", + "chromedriver": "^92.0.1", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", diff --git a/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.test.ts b/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.test.ts index cfa496fb5a2a0..45e6474b22986 100644 --- a/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.test.ts +++ b/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.test.ts @@ -108,6 +108,7 @@ describe('kuery functions', () => { const node = nodeTypes.function.buildNode('geoBoundingBox', 'geo', params); const result = geoBoundingBox.toElasticsearchQuery(node, indexPattern); + // @ts-expect-error @elastic/elasticsearch doesn't support ignore_unmapped in QueryDslGeoBoundingBoxQuery expect(result.geo_bounding_box!.ignore_unmapped).toBe(true); }); diff --git a/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.ts b/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.ts index 6a44eed1d7ec9..1dae0b40ff08e 100644 --- a/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.ts +++ b/packages/kbn-es-query/src/kuery/functions/geo_bounding_box.ts @@ -53,6 +53,7 @@ export function toElasticsearchQuery( } return { + // @ts-expect-error @elastic/elasticsearch doesn't support ignore_unmapped in QueryDslGeoBoundingBoxQuery geo_bounding_box: { [fieldName]: queryParams, ignore_unmapped: true, diff --git a/packages/kbn-es-query/src/kuery/functions/geo_polygon.ts b/packages/kbn-es-query/src/kuery/functions/geo_polygon.ts index 713124e1c4e93..cf0bcdafa04c7 100644 --- a/packages/kbn-es-query/src/kuery/functions/geo_polygon.ts +++ b/packages/kbn-es-query/src/kuery/functions/geo_polygon.ts @@ -49,6 +49,7 @@ export function toElasticsearchQuery( } return { + // @ts-expect-error @elastic/elasticsearch doesn't support ignore_unmapped in QueryDslGeoPolygonQuery geo_polygon: { [fieldName]: queryParams, ignore_unmapped: true, diff --git a/packages/kbn-legacy-logging/.babelrc b/packages/kbn-legacy-logging/.babelrc new file mode 100644 index 0000000000000..7da72d1779128 --- /dev/null +++ b/packages/kbn-legacy-logging/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@kbn/babel-preset/node_preset"] +} diff --git a/packages/kbn-legacy-logging/BUILD.bazel b/packages/kbn-legacy-logging/BUILD.bazel index 7a9b472ca9553..1148cf1d38b65 100644 --- a/packages/kbn-legacy-logging/BUILD.bazel +++ b/packages/kbn-legacy-logging/BUILD.bazel @@ -1,5 +1,6 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("//src/dev/bazel:index.bzl", "jsts_transpiler") PKG_BASE_NAME = "kbn-legacy-logging" PKG_REQUIRE_NAME = "@kbn/legacy-logging" @@ -23,7 +24,7 @@ NPM_MODULE_EXTRA_FILES = [ "README.md" ] -SRC_DEPS = [ +RUNTIME_DEPS = [ "//packages/kbn-config-schema", "//packages/kbn-utils", "@npm//@elastic/numeral", @@ -37,6 +38,13 @@ SRC_DEPS = [ ] TYPES_DEPS = [ + "//packages/kbn-config-schema", + "//packages/kbn-utils", + "@npm//@elastic/numeral", + "@npm//chokidar", + "@npm//query-string", + "@npm//rxjs", + "@npm//tslib", "@npm//@types/hapi__hapi", "@npm//@types/hapi__podium", "@npm//@types/jest", @@ -45,7 +53,11 @@ TYPES_DEPS = [ "@npm//@types/node", ] -DEPS = SRC_DEPS + TYPES_DEPS +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) ts_config( name = "tsconfig", @@ -57,13 +69,14 @@ ts_config( ) ts_project( - name = "tsc", + name = "tsc_types", args = ['--pretty'], srcs = SRCS, - deps = DEPS, + deps = TYPES_DEPS, declaration = True, declaration_map = True, - out_dir = "target", + emit_declaration_only = True, + out_dir = "target_types", source_map = True, root_dir = "src", tsconfig = ":tsconfig", @@ -72,7 +85,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":tsc"], + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json index 77fcbb9904919..6e846ffc5bfaf 100644 --- a/packages/kbn-legacy-logging/package.json +++ b/packages/kbn-legacy-logging/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "./target/index.js", - "types": "./target/index.d.ts" + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts" } diff --git a/packages/kbn-legacy-logging/tsconfig.json b/packages/kbn-legacy-logging/tsconfig.json index 30a2e56602b6f..55047dbcadc91 100644 --- a/packages/kbn-legacy-logging/tsconfig.json +++ b/packages/kbn-legacy-logging/tsconfig.json @@ -1,13 +1,14 @@ { "extends": "../../tsconfig.bazel.json", "compilerOptions": { - "outDir": "target", - "stripInternal": false, "declaration": true, "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "target_types", "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-legacy-logging/src", + "stripInternal": false, "types": ["jest", "node"] }, "include": ["src/**/*"] diff --git a/packages/kbn-rule-data-utils/src/alerts_as_data_severity.ts b/packages/kbn-rule-data-utils/src/alerts_as_data_severity.ts new file mode 100644 index 0000000000000..c23af291fbefc --- /dev/null +++ b/packages/kbn-rule-data-utils/src/alerts_as_data_severity.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const ALERT_SEVERITY_WARNING = 'warning'; +export const ALERT_SEVERITY_CRITICAL = 'critical'; +export type AlertSeverity = typeof ALERT_SEVERITY_WARNING | typeof ALERT_SEVERITY_CRITICAL; diff --git a/packages/kbn-rule-data-utils/src/index.ts b/packages/kbn-rule-data-utils/src/index.ts index f60ad31286c9c..ef06d5777b5ab 100644 --- a/packages/kbn-rule-data-utils/src/index.ts +++ b/packages/kbn-rule-data-utils/src/index.ts @@ -8,3 +8,4 @@ export * from './technical_field_names'; export * from './alerts_as_data_rbac'; +export * from './alerts_as_data_severity'; diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index ed23bf02d23ae..f954b121320fe 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -58,6 +58,7 @@ const createApiResponse = ({ headers, warnings, meta: { + body, request: { params: params!, options: requestOptions, @@ -367,7 +368,7 @@ describe('configureClient', () => { it('logs default error info when the error response body is empty', () => { const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); - let response = createApiResponse({ + let response: RequestEvent = createApiResponse({ statusCode: 400, headers: {}, params: { @@ -384,7 +385,7 @@ describe('configureClient', () => { Array [ Array [ "400 - GET /_path [undefined]: Response Error", + GET /_path [undefined]: {\\"error\\":{}}", undefined, ], ] @@ -399,7 +400,7 @@ describe('configureClient', () => { method: 'GET', path: '/_path', }, - body: {} as any, + body: undefined, }); client.emit('response', new errors.ResponseError(response), response); diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index 848d9c204bfbf..26a68df81f24e 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -11,6 +11,7 @@ import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { ElasticsearchClient } from './types'; import { ICustomClusterClient } from './cluster_client'; +import { PRODUCT_RESPONSE_HEADER } from '../supported_server_response_check'; const createInternalClientMock = ( res?: MockedTransportRequestPromise @@ -142,7 +143,7 @@ export type MockedTransportRequestPromise = TransportRequestPromise & { const createSuccessTransportRequestPromise = ( body: T, { statusCode = 200 }: { statusCode?: number } = {}, - headers?: Record + headers: Record = { [PRODUCT_RESPONSE_HEADER]: 'Elasticsearch' } ): MockedTransportRequestPromise> => { const response = createApiResponse({ body, statusCode, headers }); const promise = Promise.resolve(response); @@ -163,7 +164,7 @@ function createApiResponse>( return { body: {} as any, statusCode: 200, - headers: {}, + headers: { [PRODUCT_RESPONSE_HEADER]: 'Elasticsearch' }, warnings: [], meta: {} as any, ...opts, diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 62bb30452bb98..f50e3a0f72860 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -38,4 +38,8 @@ export type { DeleteDocumentResponse, } from './client'; export { getRequestDebugMeta, getErrorMessage } from './client'; -export { isSupportedEsServer } from './supported_server_response_check'; +export { + isSupportedEsServer, + isNotFoundFromUnsupportedServer, + PRODUCT_RESPONSE_HEADER, +} from './supported_server_response_check'; diff --git a/src/core/server/elasticsearch/supported_server_response_check.test.ts b/src/core/server/elasticsearch/supported_server_response_check.test.ts new file mode 100644 index 0000000000000..589e947142fc3 --- /dev/null +++ b/src/core/server/elasticsearch/supported_server_response_check.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isNotFoundFromUnsupportedServer } from './supported_server_response_check'; + +describe('#isNotFoundFromUnsupportedServer', () => { + it('returns true with not found response from unsupported server', () => { + const rawResponse = { + statusCode: 404, + headers: {}, + }; + + const result = isNotFoundFromUnsupportedServer(rawResponse); + expect(result).toBe(true); + }); + + it('returns false with not found response from supported server', async () => { + const rawResponse = { + statusCode: 404, + headers: { 'x-elastic-product': 'Elasticsearch' }, + }; + + const result = isNotFoundFromUnsupportedServer(rawResponse); + expect(result).toBe(false); + }); + + it('returns false when not a 404', async () => { + const rawResponse = { + statusCode: 200, + headers: { 'x-elastic-product': 'Elasticsearch' }, + }; + + const result = isNotFoundFromUnsupportedServer(rawResponse); + expect(result).toBe(false); + }); +}); diff --git a/src/core/server/elasticsearch/supported_server_response_check.ts b/src/core/server/elasticsearch/supported_server_response_check.ts index 6fe812bc58518..85235d04caf5c 100644 --- a/src/core/server/elasticsearch/supported_server_response_check.ts +++ b/src/core/server/elasticsearch/supported_server_response_check.ts @@ -12,6 +12,22 @@ export const PRODUCT_RESPONSE_HEADER = 'x-elastic-product'; * @returns boolean */ // This check belongs to the elasticsearch service as a dedicated helper method. -export const isSupportedEsServer = (headers: Record | null) => { +export const isSupportedEsServer = (headers: Record | null) => { return !!headers && headers[PRODUCT_RESPONSE_HEADER] === 'Elasticsearch'; }; + +/** + * Check to ensure that a 404 response does not come from Elasticsearch + * + * WARNING: This is a hack to work around for 404 responses returned from a proxy. + * We're aiming to minimise the risk of data loss when consumers act on Not Found errors + * + * @param response response from elasticsearch client call + * @returns boolean 'true' if the status code is 404 and the Elasticsearch product header is missing/unexpected value + */ +export const isNotFoundFromUnsupportedServer = (args: { + statusCode: number | null; + headers: Record | null; +}): boolean => { + return args.statusCode === 404 && !isSupportedEsServer(args.headers); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 65be45e49190d..0ae6464f2623d 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -421,17 +421,13 @@ describe('migration actions', () => { timeout: '0s', })(); - await cloneIndexPromise.then((res) => { - expect(res).toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "error": [ResponseError: Response Error], - "message": "Response Error", - "type": "retryable_es_client_error", - }, - } - `); + await expect(cloneIndexPromise).resolves.toMatchObject({ + _tag: 'Left', + left: { + error: expect.any(ResponseError), + message: expect.stringMatching(/\"timed_out\":true/), + type: 'retryable_es_client_error', + }, }); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts index 9895efb5fd9f9..e259b375736d5 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts @@ -64,6 +64,7 @@ describe('Elasticsearch Errors', () => { { ignore: [403] } ); + // @ts-expect-error @elastic/elasticsearch doesn't declare error on IndexResponse expect(isWriteBlockException(res.body.error!)).toEqual(true); }); @@ -79,6 +80,7 @@ describe('Elasticsearch Errors', () => { { ignore: [403] } ); + // @ts-expect-error @elastic/elasticsearch doesn't declare error on IndexResponse expect(isWriteBlockException(res.body.error!)).toEqual(true); }); diff --git a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts index 45794e25d00a6..fe97208a6168d 100644 --- a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts +++ b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts @@ -25,6 +25,7 @@ import { savedObjectsPointInTimeFinderMock } from './point_in_time_finder.mock'; import { savedObjectsRepositoryMock } from './repository.mock'; import { PointInTimeFinder } from './point_in_time_finder'; import { ISavedObjectsRepository } from './repository'; +import { SavedObjectsErrorHelpers } from './errors'; const SPACES = ['default', 'another-space']; const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; @@ -318,6 +319,23 @@ describe('collectMultiNamespaceReferences', () => { // obj3 is excluded from the results ]); }); + it(`handles 404 responses that don't come from Elasticsearch`, async () => { + const createEsUnavailableNotFoundError = () => { + return SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + }; + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const params = setup([obj1]); + client.mget.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { docs: [] }, + { statusCode: 404 }, + {} + ) + ); + await expect(() => collectMultiNamespaceReferences(params)).rejects.toThrowError( + createEsUnavailableNotFoundError() + ); + }); describe('legacy URL aliases', () => { it('uses the PointInTimeFinder to search for legacy URL aliases', async () => { diff --git a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts index b82dfec9467c3..7acbaaea1f5d7 100644 --- a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts +++ b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts @@ -7,11 +7,12 @@ */ import * as esKuery from '@kbn/es-query'; - +import { isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import type { SavedObjectsSerializer } from '../../serialization'; import type { SavedObject, SavedObjectsBaseOptions } from '../../types'; +import { SavedObjectsErrorHelpers } from './errors'; import { getRootFields } from './included_fields'; import { getSavedObjectFromSource, rawDocExistsInNamespace } from './internal_utils'; import type { @@ -198,6 +199,15 @@ async function getObjectsAndReferences({ { body: { docs: makeBulkGetDocs(bulkGetObjects) } }, { ignore: [404] } ); + // exit early if we can't verify a 404 response is from Elasticsearch + if ( + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } const newObjectsToGet = new Set(); for (let i = 0; i < bulkGetObjects.length; i++) { // For every element in bulkGetObjects, there should be a matching element in bulkGetResponse.body.docs diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index 32f12193306e7..9dd8959d49293 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -124,7 +124,7 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(false); const genericError = decorateEsError(error); expect(genericError.message).toEqual( - `Saved object index alias [.kibana_8.0.0] not found: Response Error` + `Saved object index alias [.kibana_8.0.0] not found: {\"error\":{\"reason\":\"no such index [.kibana_8.0.0] and [require_alias] request flag is [true] and [.kibana_8.0.0] is not an alias\"}}` ); expect(genericError.output.statusCode).toBe(500); expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(true); diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index c1e1e9589b9ae..7412e744f19e7 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -203,7 +203,11 @@ export class SavedObjectsErrorHelpers { return isSavedObjectsClientError(error) && error[code] === CODE_GENERAL_ERROR; } - public static createGenericNotFoundEsUnavailableError(type: string, id: string) { + public static createGenericNotFoundEsUnavailableError( + // type and id not available in all operations (e.g. mget) + type: string | null = null, + id: string | null = null + ) { const notFoundError = this.createGenericNotFoundError(type, id); return this.decorateEsUnavailableError( new Error(`${notFoundError.message}`), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index eead42db1ec58..efae5bd737020 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -652,6 +652,29 @@ describe('SavedObjectsRepository', () => { }); }; + const unsupportedProductBulkCreateMgetError = async (objects, options) => { + const multiNamespaceObjects = objects.filter( + ({ type, id }) => registry.isMultiNamespace(type) && id + ); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...response }, + { statusCode: 404 }, + {} + ) + ); + } + const response = getMockBulkCreateResponse(objects, options?.namespace); + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + await expect(savedObjectsRepository.bulkCreate(objects, options)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError() + ); + }; + it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) @@ -759,6 +782,13 @@ describe('SavedObjectsRepository', () => { const expectedErrorResult = { type: obj3.type, id: obj3.id, error: 'Oh no, a bulk error!' }; await bulkCreateError(obj3, true, expectedErrorResult); }); + + it(`throws when ES mget action returns 404 with missing Elasticsearch header`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; + await unsupportedProductBulkCreateMgetError(objects); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(0); + }); }); describe('migration', () => { @@ -1011,6 +1041,21 @@ describe('SavedObjectsRepository', () => { }); }; + const unsupportedProductBulkGetMgetError = async (objects, options) => { + const response = getMockMgetResponse(objects, options?.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...response }, + { statusCode: 404 }, + {} + ) + ); + await expect(bulkGet(objects, options)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError() + ); + expect(client.mget).toHaveBeenCalledTimes(1); + }; + it(`throws when options.namespace is '*'`, async () => { const obj = { type: 'dashboard', id: 'three' }; await expect( @@ -1046,6 +1091,12 @@ describe('SavedObjectsRepository', () => { response.docs[1].namespaces = ['bar-namespace']; await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); }); + + it(`throws when ES mget action responds with a 404 and a missing Elasticsearch product header`, async () => { + const getId = (type, id) => `${type}:${id}`; + await unsupportedProductBulkGetMgetError([obj1, obj2]); // returns 404 without required product header + _expectClientCallArgs([obj1, obj2], { getId }); + }); }); describe('returns', () => { @@ -1450,6 +1501,34 @@ describe('SavedObjectsRepository', () => { saved_objects: [expectSuccess(obj1), expectErrorNotFound(_obj), expectSuccess(obj2)], }); }; + const unsupportedProductBulkUpdateMgetError = async (objects, options, includeOriginId) => { + const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...response }, + { statusCode: 404 }, + {} + ) + ); + } + const response = getMockBulkUpdateResponse(objects, options?.namespace, includeOriginId); + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + + await expect(savedObjectsRepository.bulkUpdate(objects, options)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError() + ); + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + }; + + it(`throws when ES mget action responds with a 404 and a missing Elasticsearch product header`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; + await unsupportedProductBulkUpdateMgetError(objects); + expect(client.mget).toHaveBeenCalledTimes(1); + }); it(`throws when options.namespace is '*'`, async () => { await expect( @@ -1651,6 +1730,24 @@ describe('SavedObjectsRepository', () => { savedObjectsRepository.checkConflicts([obj1], { namespace: ALL_NAMESPACES_STRING }) ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); + + it(`throws when not found responses aren't from Elasticsearch`, async () => { + const checkConflictsMgetError = async (objects, options) => { + const response = getMockMgetResponse(objects, options?.namespace); + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...response }, + { statusCode: 404 }, + {} + ) + ); + await expect(checkConflicts(objects, options)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError() + ); + expect(client.mget).toHaveBeenCalledTimes(1); + }; + await checkConflictsMgetError([obj1, obj2], { namespace: 'default' }); + }); }); describe('returns', () => { @@ -2228,11 +2325,7 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - { found: false }, - undefined, - { 'x-elastic-product': 'Elasticsearch' } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }, undefined) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -2240,11 +2333,7 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - {}, - { statusCode: 404 }, - { 'x-elastic-product': 'Elasticsearch' } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -2252,14 +2341,18 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get with missing Elasticsearch header`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + { statusCode: 404 }, + {} + ) ); await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); }); it(`throws when ES is unable to find the index during get with missing Elasticsearch header`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }, {}) ); await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); }); @@ -2305,7 +2398,11 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during delete`, async () => { client.delete.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { result: 'not_found' }, + {}, + {} + ) ); await expectNotFoundEsUnavailableError(type, id); expect(client.delete).toHaveBeenCalledTimes(1); @@ -2313,9 +2410,13 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during delete`, async () => { client.delete.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - error: { type: 'index_not_found_exception' }, - }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { + error: { type: 'index_not_found_exception' }, + }, + {}, + {} + ) ); await expectNotFoundEsUnavailableError(type, id); expect(client.delete).toHaveBeenCalledTimes(1); @@ -2572,6 +2673,22 @@ describe('SavedObjectsRepository', () => { savedObjectsRepository.removeReferencesTo(type, id, defaultOptions) ).rejects.toThrowError(createConflictError(type, id)); }); + + it(`throws on 404 with missing Elasticsearch header`, async () => { + client.updateByQuery.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { + updated: updatedCount, + }, + { statusCode: 404 }, + {} + ) + ); + await expect( + savedObjectsRepository.removeReferencesTo(type, id, defaultOptions) + ).rejects.toThrowError(createGenericNotFoundEsUnavailableError(type, id)); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + }); }); }); @@ -2748,6 +2865,21 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { + const findNotSupportedServerError = async (options, namespace) => { + const expectedSearchResults = generateSearchResults(namespace); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...expectedSearchResults }, + { statusCode: 404 }, + {} + ) + ); + await expect(savedObjectsRepository.find(options)).rejects.toThrowError( + createGenericNotFoundEsUnavailableError() + ); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledTimes(1); + }; it(`throws when type is not defined`, async () => { await expect(savedObjectsRepository.find({})).rejects.toThrowError( 'options.type must be a string or an array of strings' @@ -2828,6 +2960,11 @@ describe('SavedObjectsRepository', () => { expect(getSearchDslNS.getSearchDsl).not.toHaveBeenCalled(); expect(client.search).not.toHaveBeenCalled(); }); + + it(`throws when ES is unable to find with missing Elasticsearch`, async () => { + await findNotSupportedServerError({ type }); + expect(client.search).toHaveBeenCalledTimes(1); + }); }); describe('returns', () => { @@ -3204,6 +3341,7 @@ describe('SavedObjectsRepository', () => { createGenericNotFoundEsUnavailableError(type, id) ); }; + it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.get(type, id, { namespace: ALL_NAMESPACES_STRING }) @@ -3222,11 +3360,7 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - { found: false }, - undefined, - { 'x-elastic-product': 'Elasticsearch' } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }, undefined) ); await expectNotFoundError(type, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -3234,11 +3368,7 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - {}, - { statusCode: 404 }, - { 'x-elastic-product': 'Elasticsearch' } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); await expectNotFoundError(type, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -3257,7 +3387,11 @@ describe('SavedObjectsRepository', () => { it(`throws when ES does not return the correct header when finding the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + undefined, + {} + ) ); await expectNotFoundEsUnavailableError(type, id); @@ -3367,8 +3501,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( { found: false }, - undefined, - { 'x-elastic-product': 'Elasticsearch' } + undefined ) // for actual target ); @@ -3421,10 +3554,16 @@ describe('SavedObjectsRepository', () => { describe('because alias is not used', () => { const expectExactMatchResult = async (aliasResult) => { const options = { namespace }; - client.update.mockResolvedValueOnce(aliasResult); // for alias object + if (!aliasResult.body) { + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { ...aliasResult }) + ); + } else { + client.update.mockResolvedValueOnce(aliasResult); // for alias object + } const response = getMockGetResponse({ type, id }, options.namespace); client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...response }) // for actual target ); const result = await savedObjectsRepository.resolve(type, id, options); @@ -3909,8 +4048,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( { ...mockGetResponse }, - { statusCode: 200 }, - { 'x-elastic-product': 'Elasticsearch' } + { statusCode: 200 } ) ); } @@ -3932,8 +4070,7 @@ describe('SavedObjectsRepository', () => { }, }, }, - { statusCode: 200 }, - { 'x-elastic-product': 'Elasticsearch' } + { statusCode: 200 } ) ); const result = await savedObjectsRepository.update(type, id, attributes, options); @@ -4144,11 +4281,7 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - { found: false }, - undefined, - { 'x-elastic-product': 'Elasticsearch' } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }, undefined) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -4156,11 +4289,7 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the index during get`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - {}, - { statusCode: 404 }, - { 'x-elastic-product': 'Elasticsearch' } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -4168,15 +4297,19 @@ describe('SavedObjectsRepository', () => { it(`throws when ES is unable to find the document during get with missing Elasticsearch header`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + { found: false }, + { statusCode: 404 }, + {} + ) ); await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); - it(`throws when ES is unable to find the index during get with missing Elasticsearch`, async () => { + it(`throws when ES is unable to find the index during get with missing Elasticsearch header`, async () => { client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }, {}) ); await expectNotFoundEsUnavailableError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); @@ -4303,6 +4436,21 @@ describe('SavedObjectsRepository', () => { ); }; + const unsupportedProductExpectNotFoundError = async (type, options) => { + const results = generateResults(); + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { ...results }, + { statusCode: 404 }, + {} + ) + ); + await expect( + savedObjectsRepository.openPointInTimeForType(type, options) + ).rejects.toThrowError(createGenericNotFoundEsUnavailableError()); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }; + it(`throws when ES is unable to find the index`, async () => { client.openPointInTime.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) @@ -4321,6 +4469,11 @@ describe('SavedObjectsRepository', () => { await test(HIDDEN_TYPE); await test(['unknownType', HIDDEN_TYPE]); }); + + it(`throws on 404 with missing Elasticsearch product header`, async () => { + await unsupportedProductExpectNotFoundError(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); }); describe('returns', () => { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 17b0f10ef67c8..365fc6a3734e4 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -14,7 +14,7 @@ import { REPOSITORY_RESOLVE_OUTCOME_STATS, } from '../../../core_usage_data'; import type { ElasticsearchClient } from '../../../elasticsearch/'; -import { isSupportedEsServer } from '../../../elasticsearch'; +import { isSupportedEsServer, isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; import type { Logger } from '../../../logging'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { @@ -335,11 +335,15 @@ export class SavedObjectsRepository { require_alias: true, }; - const { body } = + const { body, statusCode, headers } = id && overwrite ? await this.client.index(requestParams) : await this.client.create(requestParams); + // throw if we can't verify a 404 response is from Elasticsearch + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(id, type); + } return this._rawToSavedObject({ ...raw, ...body, @@ -419,7 +423,16 @@ export class SavedObjectsRepository { { ignore: [404] } ) : undefined; - + // throw if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } let bulkRequestIndexCounter = 0; const bulkCreateParams: object[] = []; const expectedBulkResults: Either[] = expectedResults.map((expectedBulkGetResult) => { @@ -588,7 +601,16 @@ export class SavedObjectsRepository { { ignore: [404] } ) : undefined; - + // throw if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } const errors: SavedObjectsCheckConflictsResponse['errors'] = []; expectedBulkGetResults.forEach((expectedResult) => { if (isLeft(expectedResult)) { @@ -665,6 +687,7 @@ export class SavedObjectsRepository { } const deleteDocNotFound = body.result === 'not_found'; + // @ts-expect-error @elastic/elasticsearch doesn't declare error on DeleteResponse const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; const esServerSupported = isSupportedEsServer(headers); if (deleteDocNotFound || deleteIndexNotFound) { @@ -703,7 +726,7 @@ export class SavedObjectsRepository { const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); const typesToUpdate = allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)); - const { body } = await this.client.updateByQuery( + const { body, statusCode, headers } = await this.client.updateByQuery( { index: this.getIndicesForTypes(typesToUpdate), refresh: options.refresh, @@ -731,6 +754,10 @@ export class SavedObjectsRepository { }, { ignore: [404] } ); + // throw if we can't verify a 404 response is from Elasticsearch + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } return body; } @@ -875,10 +902,16 @@ export class SavedObjectsRepository { }, }; - const { body, statusCode } = await this.client.search(esOptions, { - ignore: [404], - }); + const { body, statusCode, headers } = await this.client.search( + esOptions, + { + ignore: [404], + } + ); if (statusCode === 404) { + if (!isSupportedEsServer(headers)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } // 404 is only possible here if the index is missing, which // we don't want to leak, see "404s from missing index" above return { @@ -974,7 +1007,16 @@ export class SavedObjectsRepository { { ignore: [404] } ) : undefined; - + // fail fast if we can't verify a 404 is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } return { saved_objects: expectedBulkGetResults.map((expectedResult) => { if (isLeft(expectedResult)) { @@ -1098,7 +1140,18 @@ export class SavedObjectsRepository { }, { ignore: [404] } ); - + if ( + isNotFoundFromUnsupportedServer({ + statusCode: aliasResponse.statusCode, + headers: aliasResponse.headers, + }) + ) { + // throw if we cannot verify the response is from Elasticsearch + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError( + LEGACY_URL_ALIAS_TYPE, + rawAliasId + ); + } if ( aliasResponse.statusCode === 404 || aliasResponse.body.get?.found === false || @@ -1129,7 +1182,15 @@ export class SavedObjectsRepository { }, { ignore: [404] } ); - + // exit early if a 404 isn't from elasticsearch + if ( + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } const exactMatchDoc = bulkGetResponse?.body.docs[0]; const aliasMatchDoc = bulkGetResponse?.body.docs[1]; const foundExactMatch = @@ -1438,7 +1499,16 @@ export class SavedObjectsRepository { } ) : undefined; - + // fail fast if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } let bulkUpdateRequestIndexCounter = 0; const bulkUpdateParams: object[] = []; const expectedBulkUpdateResults: Either[] = expectedBulkGetResults.map( @@ -1579,7 +1649,7 @@ export class SavedObjectsRepository { // we need to target all SO indices as all types of objects may have references to the given SO. const targetIndices = this.getIndicesForTypes(allTypes); - const { body } = await this.client.updateByQuery( + const { body, statusCode, headers } = await this.client.updateByQuery( { index: targetIndices, refresh, @@ -1612,7 +1682,10 @@ export class SavedObjectsRepository { }, { ignore: [404] } ); - + // fail fast if we can't verify a 404 is from Elasticsearch + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } if (body.failures?.length) { throw SavedObjectsErrorHelpers.createConflictError( type, @@ -1877,11 +1950,15 @@ export class SavedObjectsRepository { ...(preference ? { preference } : {}), }; - const { body, statusCode } = await this.client.openPointInTime(esOptions, { + const { body, statusCode, headers } = await this.client.openPointInTime(esOptions, { ignore: [404], }); if (statusCode === 404) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + if (!isSupportedEsServer(headers)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } else { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } } return { @@ -2060,7 +2137,7 @@ export class SavedObjectsRepository { throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); } - const { body, statusCode } = await this.client.get( + const { body, statusCode, headers } = await this.client.get( { id: this._serializer.generateRawId(undefined, type, id), index: this.getIndexForType(type), @@ -2076,6 +2153,9 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createConflictError(type, id); } return getSavedObjectNamespaces(namespace, body); + } else if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + // checking if the 404 is from Elasticsearch + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); } return getSavedObjectNamespaces(namespace); } @@ -2107,9 +2187,7 @@ export class SavedObjectsRepository { const indexFound = statusCode !== 404; // check if we have the elasticsearch header when index is not found and if we do, ensure it is Elasticsearch - const esServerSupported = isSupportedEsServer(headers); - - if (!isFoundGetResponse(body) && !esServerSupported) { + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); } @@ -2134,6 +2212,7 @@ export class SavedObjectsRepository { return { saved_object: object, outcome: 'exactMatch' }; } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // 404 responses already confirmed to be valid Elasticsearch responses await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); } throw err; diff --git a/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts b/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts index 11dbe6149878c..ba15fbabfba6b 100644 --- a/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts +++ b/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts @@ -23,6 +23,7 @@ import type { UpdateObjectsSpacesParams, } from './update_objects_spaces'; import { updateObjectsSpaces } from './update_objects_spaces'; +import { SavedObjectsErrorHelpers } from './errors'; type SetupParams = Partial< Pick @@ -105,6 +106,32 @@ describe('#updateObjectsSpaces', () => { }) ); } + /** Mocks the saved objects client so as to test unsupported server responding with 404 */ + function mockMgetResultsNotFound(...results: Array<{ found: boolean }>) { + client.mget.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { + docs: results.map((x) => + x.found + ? { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + _source: { namespaces: [EXISTING_SPACE] }, + ...VERSION_PROPS, + found: true, + } + : { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + found: false, + } + ), + }, + { statusCode: 404 }, + {} + ) + ); + } /** Asserts that mget is called for the given objects */ function expectMgetArgs(...objects: SavedObjectsUpdateObjectsSpacesObject[]) { @@ -240,6 +267,17 @@ describe('#updateObjectsSpaces', () => { { ...obj7, spaces: [EXISTING_SPACE, 'foo-space'] }, ]); }); + + it('throws when mget not found response is missing the Elasticsearch header', async () => { + const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; + const spacesToAdd = ['foo-space']; + const params = setup({ objects, spacesToAdd }); + mockMgetResultsNotFound({ found: true }); + + await expect(() => updateObjectsSpaces(params)).rejects.toThrowError( + SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError() + ); + }); }); // Note: these test cases do not include requested objects that will result in errors (those are covered above) diff --git a/src/core/server/saved_objects/service/lib/update_objects_spaces.ts b/src/core/server/saved_objects/service/lib/update_objects_spaces.ts index 3131d0240f96b..666b7b98b42e5 100644 --- a/src/core/server/saved_objects/service/lib/update_objects_spaces.ts +++ b/src/core/server/saved_objects/service/lib/update_objects_spaces.ts @@ -25,6 +25,7 @@ import { } from './internal_utils'; import { DEFAULT_REFRESH_SETTING } from './repository'; import type { RepositoryEsClient } from './repository_es_client'; +import { isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; /** * An object that should have its spaces updated. @@ -190,6 +191,16 @@ export async function updateObjectsSpaces({ ) : undefined; + // fail fast if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } const time = new Date().toISOString(); let bulkOperationRequestIndexCounter = 0; const bulkOperationParams: estypes.BulkOperationContainer[] = []; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index ac5fe9a5d8dbb..adbc06c92d45c 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2528,7 +2528,7 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) - static createGenericNotFoundEsUnavailableError(type: string, id: string): DecoratedError; + static createGenericNotFoundEsUnavailableError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) static createIndexAliasNotFoundError(alias: string): DecoratedError; // (undocumented) diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index a6579069acbc0..b7c0733de728e 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -247,7 +247,6 @@ describe('PluginStatusService', () => { subscription.unsubscribe(); expect(statusUpdates).toEqual([ - { a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } }, { a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } }, { a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } }, { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, @@ -274,7 +273,6 @@ describe('PluginStatusService', () => { subscription.unsubscribe(); expect(statusUpdates).toEqual([ - { a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } }, { a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } }, { a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } }, { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, @@ -357,6 +355,35 @@ describe('PluginStatusService', () => { }).toThrowError(); }); + it('debounces plugins custom status registration', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + const available: ServiceStatus = { + level: ServiceStatusLevels.available, + summary: 'a available', + }; + + const statusUpdates: Array> = []; + const subscription = service + .getDependenciesStatus$('b') + .subscribe((status) => statusUpdates.push(status)); + + const pluginA$ = new BehaviorSubject(available); + service.set('a', pluginA$); + + expect(statusUpdates).toStrictEqual([]); + + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // Waiting for the debounce timeout should cut a new update + await delay(25); + subscription.unsubscribe(); + + expect(statusUpdates).toStrictEqual([{ a: available }]); + }); + it('debounces events in quick succession', async () => { const service = new PluginsStatusService({ core$: coreAllAvailable$, diff --git a/src/core/server/status/plugins_status.ts b/src/core/server/status/plugins_status.ts index 6a8ef1081e165..7ef3ddb31d978 100644 --- a/src/core/server/status/plugins_status.ts +++ b/src/core/server/status/plugins_status.ts @@ -76,6 +76,7 @@ export class PluginsStatusService { public getDerivedStatus$(plugin: PluginName): Observable { return this.update$.pipe( + debounceTime(25), // Avoid calling the plugin's custom status logic for every plugin that depends on it. switchMap(() => { // Only go up the dependency tree if any of this plugin's dependencies have a custom status // Helps eliminate memory overhead of creating thousands of Observables unnecessarily. diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 65edd6b61720e..1e9c0b443eb8b 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -20,7 +20,7 @@ export const CopySource: Task = { 'src/**', '!src/**/*.{test,test.mocks,mock}.{js,ts,tsx}', '!src/**/mocks.ts', // special file who imports .mock files - '!src/**/{target,__tests__,__snapshots__,__mocks__,integration_tests}/**', + '!src/**/{target,__tests__,__snapshots__,__mocks__,integration_tests,__fixtures__}/**', '!src/core/server/core_app/assets/favicons/favicon.distribution.png', '!src/core/server/core_app/assets/favicons/favicon.distribution.svg', '!src/test_utils/**', @@ -29,10 +29,11 @@ export const CopySource: Task = { '!src/cli/dev.js', '!src/functional_test_runner/**', '!src/dev/**', + '!**/jest.config.js', '!src/plugins/telemetry/schema/**', // Skip telemetry schemas // this is the dev-only entry '!src/setup_node_env/index.js', - '!**/public/**/*.{js,ts,tsx,json}', + '!**/public/**/*.{js,ts,tsx,json,scss}', 'typings/**', 'config/kibana.yml', 'config/node.options', diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index e524d78a53e80..cf00241ee2766 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -5,7 +5,7 @@ "ui": true, "requiredPlugins": ["management"], "optionalPlugins": ["home", "usageCollection"], - "requiredBundles": ["kibanaReact", "kibanaUtils", "home"], + "requiredBundles": ["kibanaReact", "kibanaUtils", "home", "esUiShared"], "owner": { "name": "Kibana App", "githubTeam": "kibana-app" diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index 759e1f992808f..745452a31ff9c 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -8,7 +8,6 @@ import React, { PureComponent, Fragment } from 'react'; import classNames from 'classnames'; - import 'brace/theme/textmate'; import 'brace/mode/markdown'; import 'brace/mode/json'; @@ -19,7 +18,6 @@ import { EuiCodeBlock, EuiColorPicker, EuiScreenReaderOnly, - EuiCodeEditor, EuiDescribedFormGroup, EuiFieldNumber, EuiFieldText, @@ -40,6 +38,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { FieldSetting, FieldState } from '../../types'; import { isDefaultValue } from '../../lib'; import { UiSettingsType, DocLinksStart, ToastsStart } from '../../../../../../core/public'; +import { EuiCodeEditor } from '../../../../../es_ui_shared/public'; interface FieldProps { setting: FieldSetting; diff --git a/src/plugins/advanced_settings/tsconfig.json b/src/plugins/advanced_settings/tsconfig.json index b207f600cbd4e..5bf4ce3d6248b 100644 --- a/src/plugins/advanced_settings/tsconfig.json +++ b/src/plugins/advanced_settings/tsconfig.json @@ -16,5 +16,6 @@ { "path": "../home/tsconfig.json" }, { "path": "../usage_collection/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, + { "path": "../es_ui_shared/tsconfig.json" }, ] } diff --git a/src/plugins/console/kibana.json b/src/plugins/console/kibana.json index ca43e4f258add..9452f43647a19 100644 --- a/src/plugins/console/kibana.json +++ b/src/plugins/console/kibana.json @@ -3,6 +3,10 @@ "version": "kibana", "server": true, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": ["devTools"], "optionalPlugins": ["usageCollection", "home"], "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils", "home"] diff --git a/src/plugins/data/common/index_patterns/index.ts b/src/plugins/data/common/index_patterns/index.ts index 340162e8bda70..f493b417b47ef 100644 --- a/src/plugins/data/common/index_patterns/index.ts +++ b/src/plugins/data/common/index_patterns/index.ts @@ -10,6 +10,6 @@ export * from './constants'; export * from './fields'; export * from './types'; export { IndexPatternsService, IndexPatternsContract } from './index_patterns'; -export type { IndexPattern } from './index_patterns'; +export type { IndexPattern, IndexPatternListItem } from './index_patterns'; export * from './errors'; export * from './expressions'; diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts index a80e97b4e2cab..c6715fac5d9af 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts @@ -137,11 +137,11 @@ describe('IndexPatterns', () => { expect((await indexPatterns.get(id)).fields.length).toBe(1); }); - test('savedObjectCache pre-fetches only title', async () => { + test('savedObjectCache pre-fetches title, type, typeMeta', async () => { expect(await indexPatterns.getIds()).toEqual(['id']); expect(savedObjectsClient.find).toHaveBeenCalledWith({ type: 'index-pattern', - fields: ['title'], + fields: ['title', 'type', 'typeMeta'], perPage: 10000, }); }); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 64628f7165f27..d20cfc98ba059 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -28,6 +28,7 @@ import { FieldAttrs, FieldSpec, IndexPatternFieldMap, + TypeMeta, } from '../types'; import { FieldFormatsStartCommon, FORMATS_UI_SETTINGS } from '../../../../field_formats/common/'; import { UI_SETTINGS, SavedObject } from '../../../common'; @@ -39,8 +40,21 @@ import { castEsToKbnFieldTypeName } from '../../kbn_field_types'; const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3; -export interface IndexPatternSavedObjectAttrs { +export type IndexPatternSavedObjectAttrs = Pick< + IndexPatternAttributes, + 'title' | 'type' | 'typeMeta' +>; + +export type IndexPatternListSavedObjectAttrs = Pick< + IndexPatternAttributes, + 'title' | 'type' | 'typeMeta' +>; + +export interface IndexPatternListItem { + id: string; title: string; + type?: string; + typeMeta?: TypeMeta; } interface IndexPatternsServiceDeps { @@ -94,7 +108,7 @@ export class IndexPatternsService { private async refreshSavedObjectsCache() { const so = await this.savedObjectsClient.find({ type: INDEX_PATTERN_SAVED_OBJECT_TYPE, - fields: ['title'], + fields: ['title', 'type', 'typeMeta'], perPage: 10000, }); this.savedObjectsCache = so; @@ -152,9 +166,7 @@ export class IndexPatternsService { * Get list of index pattern ids with titles * @param refresh Force refresh of index pattern list */ - getIdsWithTitle = async ( - refresh: boolean = false - ): Promise> => { + getIdsWithTitle = async (refresh: boolean = false): Promise => { if (!this.savedObjectsCache || refresh) { await this.refreshSavedObjectsCache(); } @@ -164,6 +176,8 @@ export class IndexPatternsService { return this.savedObjectsCache.map((obj) => ({ id: obj?.id, title: obj?.attributes?.title, + type: obj?.attributes?.type, + typeMeta: obj?.attributes?.typeMeta && JSON.parse(obj?.attributes?.typeMeta), })); }; @@ -559,7 +573,7 @@ export class IndexPatternsService { const createdIndexPattern = await this.initFromSavedObject(response); this.indexPatternCache.set(createdIndexPattern.id!, Promise.resolve(createdIndexPattern)); if (this.savedObjectsCache) { - this.savedObjectsCache.push(response as SavedObject); + this.savedObjectsCache.push(response as SavedObject); } return createdIndexPattern; } diff --git a/src/plugins/data/common/search/aggs/utils/get_date_histogram_meta.ts b/src/plugins/data/common/search/aggs/utils/get_date_histogram_meta.ts index 0a3aab6286d89..87086edd56e72 100644 --- a/src/plugins/data/common/search/aggs/utils/get_date_histogram_meta.ts +++ b/src/plugins/data/common/search/aggs/utils/get_date_histogram_meta.ts @@ -17,7 +17,12 @@ import { BUCKET_TYPES } from '../buckets/bucket_agg_types'; * If the column is not a column created by a date_histogram aggregation of the esaggs data source, * this function will return undefined. */ -export const getDateHistogramMetaDataByDatatableColumn = (column: DatatableColumn) => { +export const getDateHistogramMetaDataByDatatableColumn = ( + column: DatatableColumn, + defaults: Partial<{ + timeZone: string; + }> = {} +) => { if (column.meta.source !== 'esaggs') return; if (column.meta.sourceParams?.type !== BUCKET_TYPES.DATE_HISTOGRAM) return; const params = (column.meta.sourceParams.params as unknown) as AggParamsDateHistogram; @@ -29,7 +34,7 @@ export const getDateHistogramMetaDataByDatatableColumn = (column: DatatableColum return { interval, - timeZone: params.used_time_zone, + timeZone: params.used_time_zone || defaults.timeZone, timeRange: column.meta.sourceParams.appliedTimeRange as TimeRange | undefined, }; }; diff --git a/src/plugins/data/common/search/utils.ts b/src/plugins/data/common/search/utils.ts index e11957c6fa9fc..ea5ac28852d6a 100644 --- a/src/plugins/data/common/search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -12,7 +12,7 @@ import type { IKibanaSearchResponse } from './types'; * @returns true if response had an error while executing in ES */ export const isErrorResponse = (response?: IKibanaSearchResponse) => { - return !response || !response.rawResponse || (!response.isRunning && response.isPartial); + return !response || !response.rawResponse || (!response.isRunning && !!response.isPartial); }; /** diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 99c89ac69b795..0602f51889a6c 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -62,7 +62,7 @@ export const indexPatterns = { flattenHitWrapper, }; -export { IndexPatternsContract, IndexPattern, IndexPatternField } from './index_patterns'; +export { IndexPatternsContract, IndexPattern, IndexPatternField, TypeMeta } from './index_patterns'; export { IIndexPattern, @@ -79,6 +79,7 @@ export { INDEX_PATTERN_SAVED_OBJECT_TYPE, AggregationRestrictions, IndexPatternType, + IndexPatternListItem, } from '../common'; export { DuplicateIndexPatternError } from '../common/index_patterns/errors'; diff --git a/src/plugins/data/public/index_patterns/index.ts b/src/plugins/data/public/index_patterns/index.ts index 1bdd17af2a78d..e23fc789656af 100644 --- a/src/plugins/data/public/index_patterns/index.ts +++ b/src/plugins/data/public/index_patterns/index.ts @@ -16,7 +16,7 @@ export { } from '../../common/index_patterns/lib'; export { flattenHitWrapper, formatHitProvider, onRedirectNoIndexPattern } from './index_patterns'; -export { IndexPatternField, IIndexPatternFieldList } from '../../common/index_patterns'; +export { IndexPatternField, IIndexPatternFieldList, TypeMeta } from '../../common/index_patterns'; export { IndexPatternsService, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 09e6cb329c24a..c6f8100c48413 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1269,7 +1269,6 @@ export class IndexPattern implements IIndexPattern { title: string; toSpec(): IndexPatternSpec; type: string | undefined; - // Warning: (ae-forgotten-export) The symbol "TypeMeta" needs to be exported by the entry point index.d.ts typeMeta?: TypeMeta; version: string | undefined; } @@ -1369,6 +1368,20 @@ export class IndexPatternField implements IFieldType { get visualizable(): boolean; } +// Warning: (ae-missing-release-tag) "IndexPatternListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface IndexPatternListItem { + // (undocumented) + id: string; + // (undocumented) + title: string; + // (undocumented) + type?: string; + // (undocumented) + typeMeta?: TypeMeta; +} + // Warning: (ae-forgotten-export) The symbol "name" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Input" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Arguments" needs to be exported by the entry point index.d.ts @@ -1457,19 +1470,14 @@ export class IndexPatternsService { fieldArrayToMap: (fields: FieldSpec[], fieldAttrs?: FieldAttrs | undefined) => Record; find: (search: string, size?: number) => Promise; get: (id: string) => Promise; - // Warning: (ae-forgotten-export) The symbol "IndexPatternSavedObjectAttrs" needs to be exported by the entry point index.d.ts - // // (undocumented) - getCache: () => Promise[] | null | undefined>; + getCache: () => Promise>[] | null | undefined>; getDefault: () => Promise; getDefaultId: () => Promise; getFieldsForIndexPattern: (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise; getFieldsForWildcard: (options: GetFieldsOptions) => Promise; getIds: (refresh?: boolean) => Promise; - getIdsWithTitle: (refresh?: boolean) => Promise>; + getIdsWithTitle: (refresh?: boolean) => Promise; getTitles: (refresh?: boolean) => Promise; refreshFields: (indexPattern: IndexPattern) => Promise; savedObjectToSpec: (savedObject: SavedObject) => IndexPatternSpec; @@ -1560,7 +1568,7 @@ export interface ISearchStartSearchSource { // Warning: (ae-missing-release-tag) "isErrorResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const isErrorResponse: (response?: IKibanaSearchResponse | undefined) => boolean | undefined; +export const isErrorResponse: (response?: IKibanaSearchResponse | undefined) => boolean; // Warning: (ae-missing-release-tag) "isEsError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -2036,7 +2044,9 @@ export const search: { intervalLabel: string; })[]; getNumberHistogramIntervalByDatatableColumn: (column: import("../../expressions").DatatableColumn) => number | undefined; - getDateHistogramMetaDataByDatatableColumn: (column: import("../../expressions").DatatableColumn) => { + getDateHistogramMetaDataByDatatableColumn: (column: import("../../expressions").DatatableColumn, defaults?: Partial<{ + timeZone: string; + }>) => { interval: string | undefined; timeZone: string | undefined; timeRange: import("../common").TimeRange | undefined; @@ -2055,8 +2065,8 @@ export const SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions"; // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "isClearable" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "isClearable" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "displayStyle">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts @@ -2273,6 +2283,18 @@ export type TimeRange = { mode?: 'absolute' | 'relative'; }; +// Warning: (ae-missing-release-tag) "TypeMeta" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface TypeMeta { + // (undocumented) + aggs?: Record; + // (undocumented) + params?: { + rollup_index: string; + }; +} + // Warning: (ae-missing-release-tag) "UI_SETTINGS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2332,20 +2354,20 @@ export interface WaitUntilNextSessionCompletesOptions { // src/plugins/data/public/index.ts:53:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:53:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:53:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:210:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:210:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:210:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:212:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:213:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:222:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:223:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:224:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:225:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:229:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:230:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:237:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:211:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:211:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:211:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:213:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:214:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:223:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:224:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:225:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:226:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:230:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:231:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:234:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:235:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:62:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss b/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss index d45f7040e5739..24f3ca05a5685 100644 --- a/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss +++ b/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss @@ -11,6 +11,10 @@ padding-bottom: $euiSizeS; } +.globalQueryBar--inPage { + padding: 0; +} + .globalFilterGroup__filterBar { margin-top: $euiSizeXS; } diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index a03e7b33d2b65..db0bebf97578b 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -75,6 +75,8 @@ export interface SearchBarOwnProps { iconType?: EuiIconProps['type']; nonKqlMode?: 'lucene' | 'text'; nonKqlModeHelpText?: string; + // defines padding; use 'inPage' to avoid extra padding; use 'detached' if the searchBar appears at the very top of the view, without any wrapper + displayStyle?: 'inPage' | 'detached'; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -410,8 +412,12 @@ class SearchBarUI extends Component { ); } + const globalQueryBarClasses = classNames('globalQueryBar', { + 'globalQueryBar--inPage': this.props.displayStyle === 'inPage', + }); + return ( -
+
{queryBar} {filterBar} diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 71863ecb61341..f684586917fe7 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -517,20 +517,16 @@ class IndexPatternsService { fieldArrayToMap: (fields: FieldSpec[], fieldAttrs?: FieldAttrs | undefined) => Record; find: (search: string, size?: number) => Promise; get: (id: string) => Promise; - // Warning: (ae-forgotten-export) The symbol "IndexPatternSavedObjectAttrs" needs to be exported by the entry point index.d.ts - // // (undocumented) - getCache: () => Promise[] | null | undefined>; + getCache: () => Promise>[] | null | undefined>; getDefault: () => Promise; getDefaultId: () => Promise; getFieldsForIndexPattern: (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise; // Warning: (ae-forgotten-export) The symbol "GetFieldsOptions" needs to be exported by the entry point index.d.ts getFieldsForWildcard: (options: GetFieldsOptions) => Promise; getIds: (refresh?: boolean) => Promise; - getIdsWithTitle: (refresh?: boolean) => Promise>; + // Warning: (ae-forgotten-export) The symbol "IndexPatternListItem" needs to be exported by the entry point index.d.ts + getIdsWithTitle: (refresh?: boolean) => Promise; getTitles: (refresh?: boolean) => Promise; refreshFields: (indexPattern: IndexPattern) => Promise; savedObjectToSpec: (savedObject: SavedObject_2) => IndexPatternSpec; diff --git a/src/plugins/dev_tools/kibana.json b/src/plugins/dev_tools/kibana.json index f1c6c9ecf87e6..75a1e82f1d910 100644 --- a/src/plugins/dev_tools/kibana.json +++ b/src/plugins/dev_tools/kibana.json @@ -3,5 +3,9 @@ "version": "kibana", "server": false, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": ["urlForwarding"] } diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/actions/columns.ts b/src/plugins/discover/public/application/apps/main/components/doc_table/actions/columns.ts index 130b43539d9b5..9b69a98ca7996 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/actions/columns.ts +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/actions/columns.ts @@ -78,9 +78,7 @@ export function getStateColumnActions({ state: DiscoverState | ContextState; }) { function onAddColumn(columnName: string) { - if (capabilities.discover.save) { - popularizeField(indexPattern, columnName, indexPatterns); - } + popularizeField(indexPattern, columnName, indexPatterns, capabilities); const columns = addColumn(state.columns || [], columnName, useNewFieldsApi); const defaultOrder = config.get(SORT_DEFAULT_ORDER_SETTING); const sort = @@ -89,9 +87,7 @@ export function getStateColumnActions({ } function onRemoveColumn(columnName: string) { - if (capabilities.discover.save) { - popularizeField(indexPattern, columnName, indexPatterns); - } + popularizeField(indexPattern, columnName, indexPatterns, capabilities); const columns = removeColumn(state.columns || [], columnName, useNewFieldsApi); // The state's sort property is an array of [sortByColumn,sortDirection] const sort = diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 94e28c3f1d54c..6d241468bdf74 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -122,7 +122,7 @@ export function DiscoverLayout({ const onAddFilter = useCallback( (field: IndexPatternField | string, values: string, operation: '+' | '-') => { const fieldName = typeof field === 'string' ? field : field.name; - popularizeField(indexPattern, fieldName, indexPatterns); + popularizeField(indexPattern, fieldName, indexPatterns, capabilities); const newFilters = esFilters.generateFilters( filterManager, field, @@ -135,7 +135,7 @@ export function DiscoverLayout({ } return filterManager.addFilters(newFilters); }, - [filterManager, indexPattern, indexPatterns, trackUiMetric] + [filterManager, indexPattern, indexPatterns, trackUiMetric, capabilities] ); const onEditRuntimeField = useCallback(() => { diff --git a/src/plugins/discover/public/application/components/context_app/context_app.test.tsx b/src/plugins/discover/public/application/components/context_app/context_app.test.tsx index 7ac6a9d0e8de3..a21b035c335df 100644 --- a/src/plugins/discover/public/application/components/context_app/context_app.test.tsx +++ b/src/plugins/discover/public/application/components/context_app/context_app.test.tsx @@ -54,6 +54,9 @@ describe('ContextApp test', () => { discover: { save: true, }, + indexPatterns: { + save: true, + }, }, indexPatterns: indexPatternsMock, toastNotifications: { addDanger: () => {} }, diff --git a/src/plugins/discover/public/application/components/context_app/context_app.tsx b/src/plugins/discover/public/application/components/context_app/context_app.tsx index 37963eb2dfa93..25590f331839e 100644 --- a/src/plugins/discover/public/application/components/context_app/context_app.tsx +++ b/src/plugins/discover/public/application/components/context_app/context_app.tsx @@ -109,10 +109,10 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp filterManager.addFilters(newFilters); if (indexPatterns) { const fieldName = typeof field === 'string' ? field : field.name; - await popularizeField(indexPattern, fieldName, indexPatterns); + await popularizeField(indexPattern, fieldName, indexPatterns, capabilities); } }, - [filterManager, indexPatternId, indexPatterns, indexPattern] + [filterManager, indexPatternId, indexPatterns, indexPattern, capabilities] ); const TopNavMenu = navigation.ui.TopNavMenu; diff --git a/src/plugins/discover/public/application/helpers/popularize_field.test.ts b/src/plugins/discover/public/application/helpers/popularize_field.test.ts index 8be23c4270438..7ae3994abd21a 100644 --- a/src/plugins/discover/public/application/helpers/popularize_field.test.ts +++ b/src/plugins/discover/public/application/helpers/popularize_field.test.ts @@ -6,15 +6,27 @@ * Side Public License, v 1. */ +import { Capabilities } from 'kibana/public'; import { IndexPattern, IndexPatternsService } from '../../../../data/public'; import { popularizeField } from './popularize_field'; +const capabilities = ({ + indexPatterns: { + save: true, + }, +} as unknown) as Capabilities; + describe('Popularize field', () => { test('returns undefined if index pattern lacks id', async () => { const indexPattern = ({} as unknown) as IndexPattern; const fieldName = '@timestamp'; const indexPatternsService = ({} as unknown) as IndexPatternsService; - const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + const result = await popularizeField( + indexPattern, + fieldName, + indexPatternsService, + capabilities + ); expect(result).toBeUndefined(); }); @@ -26,7 +38,12 @@ describe('Popularize field', () => { } as unknown) as IndexPattern; const fieldName = '@timestamp'; const indexPatternsService = ({} as unknown) as IndexPatternsService; - const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + const result = await popularizeField( + indexPattern, + fieldName, + indexPatternsService, + capabilities + ); expect(result).toBeUndefined(); }); @@ -44,7 +61,12 @@ describe('Popularize field', () => { const indexPatternsService = ({ updateSavedObject: async () => {}, } as unknown) as IndexPatternsService; - const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + const result = await popularizeField( + indexPattern, + fieldName, + indexPatternsService, + capabilities + ); expect(result).toBeUndefined(); expect(field.count).toEqual(1); }); @@ -65,7 +87,34 @@ describe('Popularize field', () => { throw new Error('unknown error'); }, } as unknown) as IndexPatternsService; - const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + const result = await popularizeField( + indexPattern, + fieldName, + indexPatternsService, + capabilities + ); + expect(result).toBeUndefined(); + }); + + test('should not try to update index pattern without permissions', async () => { + const field = { + count: 0, + }; + const indexPattern = ({ + id: 'id', + fields: { + getByName: () => field, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({ + updateSavedObject: jest.fn(), + } as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService, ({ + indexPatterns: { save: false }, + } as unknown) as Capabilities); expect(result).toBeUndefined(); + expect(indexPatternsService.updateSavedObject).not.toHaveBeenCalled(); + expect(field.count).toEqual(0); }); }); diff --git a/src/plugins/discover/public/application/helpers/popularize_field.ts b/src/plugins/discover/public/application/helpers/popularize_field.ts index 4ade7d1768419..90968dd7c3d58 100644 --- a/src/plugins/discover/public/application/helpers/popularize_field.ts +++ b/src/plugins/discover/public/application/helpers/popularize_field.ts @@ -6,14 +6,16 @@ * Side Public License, v 1. */ +import type { Capabilities } from 'kibana/public'; import { IndexPattern, IndexPatternsContract } from '../../../../data/public'; async function popularizeField( indexPattern: IndexPattern, fieldName: string, - indexPatternsService: IndexPatternsContract + indexPatternsService: IndexPatternsContract, + capabilities: Capabilities ) { - if (!indexPattern.id) return; + if (!indexPattern.id || !capabilities?.indexPatterns?.save) return; const field = indexPattern.fields.getByName(fieldName); if (!field) { return; diff --git a/src/plugins/embeddable/kibana.json b/src/plugins/embeddable/kibana.json index 1ecf76dbbd5c2..42dc716fe64e9 100644 --- a/src/plugins/embeddable/kibana.json +++ b/src/plugins/embeddable/kibana.json @@ -3,16 +3,11 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": [ - "inspector", - "uiActions" - ], - "extraPublicDirs": [ - "public/lib/test_samples" - ], - "requiredBundles": [ - "savedObjects", - "kibanaReact", - "kibanaUtils" - ] + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, + "requiredPlugins": ["inspector", "uiActions"], + "extraPublicDirs": ["public/lib/test_samples"], + "requiredBundles": ["savedObjects", "kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index 98cf6e70284cd..74ee31ba71104 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -17,6 +17,7 @@ export const EMBEDDABLE_EDITOR_STATE_KEY = 'embeddable_editor_state'; */ export interface EmbeddableEditorState { originatingApp: string; + originatingPath?: string; embeddableId?: string; valueInput?: EmbeddableInput; diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 2e46cb82dc592..3dfe10445fb85 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -369,6 +369,8 @@ export interface EmbeddableEditorState { embeddableId?: string; // (undocumented) originatingApp: string; + // (undocumented) + originatingPath?: string; searchSessionId?: string; // (undocumented) valueInput?: EmbeddableInput; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts index cff179f64ea08..240eeb59f9a45 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts @@ -59,7 +59,7 @@ describe('handleEsError', () => { expect(payload).toEqual({ attributes: { causes: undefined, error: undefined }, - message: 'Response Error', + message: '{}', }); expect(status).toBe(400); diff --git a/src/plugins/es_ui_shared/kibana.json b/src/plugins/es_ui_shared/kibana.json index d442bfb93d5af..2735b153f738c 100644 --- a/src/plugins/es_ui_shared/kibana.json +++ b/src/plugins/es_ui_shared/kibana.json @@ -3,6 +3,10 @@ "version": "kibana", "ui": true, "server": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "extraPublicDirs": [ "static/validators/string", "static/forms/hook_form_lib", @@ -10,7 +14,5 @@ "static/forms/components", "static/forms/helpers/field_validators/types" ], - "requiredBundles": [ - "data" - ] + "requiredBundles": ["data"] } diff --git a/src/plugins/expressions/kibana.json b/src/plugins/expressions/kibana.json index 23c7fe722fdb3..46e6ef8b4ea75 100644 --- a/src/plugins/expressions/kibana.json +++ b/src/plugins/expressions/kibana.json @@ -3,9 +3,10 @@ "version": "kibana", "server": true, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "extraPublicDirs": ["common", "common/fonts"], - "requiredBundles": [ - "kibanaUtils", - "inspector" - ] + "requiredBundles": ["kibanaUtils", "inspector"] } diff --git a/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap index 4e66fd9e14c81..348f618805858 100644 --- a/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap @@ -4,6 +4,7 @@ exports[`should render a Welcome screen with no telemetry disclaimer 1`] = `
{ defaultProps.localStorage.getItem = sinon.spy(() => 'true'); const component = await renderHome({ - find: () => Promise.resolve({ total: 0 }), + http: { + get: () => Promise.resolve({ isNewInstance: true }), + }, }); sinon.assert.calledOnce(defaultProps.localStorage.getItem); diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index cb02d62f9164f..da8eac6c78a8d 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -33,6 +33,7 @@ export function HomeApp({ directories, solutions }) { addBasePath, environmentService, telemetry, + http, } = getServices(); const environment = environmentService.getEnvironment(); const isCloudEnabled = environment.cloud; @@ -71,10 +72,10 @@ export function HomeApp({ directories, solutions }) { addBasePath={addBasePath} directories={directories} solutions={solutions} - find={savedObjectsClient.find} localStorage={localStorage} urlBasePath={getBasePath()} telemetry={telemetry} + http={http} /> diff --git a/src/plugins/home/public/application/components/welcome.tsx b/src/plugins/home/public/application/components/welcome.tsx index 55b733e413f6a..ca7e6874c75c2 100644 --- a/src/plugins/home/public/application/components/welcome.tsx +++ b/src/plugins/home/public/application/components/welcome.tsx @@ -119,7 +119,7 @@ export class Welcome extends React.Component { const { urlBasePath, telemetry } = this.props; return ( -
+
diff --git a/src/plugins/home/server/routes/fetch_new_instance_status.ts b/src/plugins/home/server/routes/fetch_new_instance_status.ts new file mode 100644 index 0000000000000..12d94feb3b8a1 --- /dev/null +++ b/src/plugins/home/server/routes/fetch_new_instance_status.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IRouter } from 'src/core/server'; +import { isNewInstance } from '../services/new_instance_status'; + +export const registerNewInstanceStatusRoute = (router: IRouter) => { + router.get( + { + path: '/internal/home/new_instance_status', + validate: false, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { client: soClient } = context.core.savedObjects; + const { client: esClient } = context.core.elasticsearch; + + try { + return res.ok({ + body: { + isNewInstance: await isNewInstance({ esClient, soClient }), + }, + }); + } catch (e) { + return res.customError({ + statusCode: 500, + }); + } + }) + ); +}; diff --git a/src/plugins/home/server/routes/index.ts b/src/plugins/home/server/routes/index.ts index 905304e059660..6013dbf130831 100644 --- a/src/plugins/home/server/routes/index.ts +++ b/src/plugins/home/server/routes/index.ts @@ -8,7 +8,9 @@ import { IRouter } from 'src/core/server'; import { registerHitsStatusRoute } from './fetch_es_hits_status'; +import { registerNewInstanceStatusRoute } from './fetch_new_instance_status'; export const registerRoutes = (router: IRouter) => { registerHitsStatusRoute(router); + registerNewInstanceStatusRoute(router); }; diff --git a/src/plugins/home/server/services/new_instance_status.test.ts b/src/plugins/home/server/services/new_instance_status.test.ts new file mode 100644 index 0000000000000..9ce8f8571f5a1 --- /dev/null +++ b/src/plugins/home/server/services/new_instance_status.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isNewInstance } from './new_instance_status'; +import { elasticsearchServiceMock, savedObjectsClientMock } from '../../../../core/server/mocks'; + +describe('isNewInstance', () => { + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + const soClient = savedObjectsClientMock.create(); + + beforeEach(() => jest.resetAllMocks()); + + it('returns true when there are no index patterns', async () => { + soClient.find.mockResolvedValue({ + page: 1, + per_page: 100, + total: 0, + saved_objects: [], + }); + expect(await isNewInstance({ esClient, soClient })).toEqual(true); + }); + + it('returns false when there are any index patterns other than metrics-* or logs-*', async () => { + soClient.find.mockResolvedValue({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [ + { + id: '1', + references: [], + type: 'index-pattern', + score: 99, + attributes: { title: 'my-pattern-*' }, + }, + ], + }); + expect(await isNewInstance({ esClient, soClient })).toEqual(false); + }); + + describe('when only metrics-* and logs-* index patterns exist', () => { + beforeEach(() => { + soClient.find.mockResolvedValue({ + page: 1, + per_page: 100, + total: 2, + saved_objects: [ + { + id: '1', + references: [], + type: 'index-pattern', + score: 99, + attributes: { title: 'metrics-*' }, + }, + { + id: '2', + references: [], + type: 'index-pattern', + score: 99, + attributes: { title: 'logs-*' }, + }, + ], + }); + }); + + it('calls /_cat/indices for the index patterns', async () => { + await isNewInstance({ esClient, soClient }); + expect(esClient.asCurrentUser.cat.indices).toHaveBeenCalledWith({ + index: 'logs-*,metrics-*', + format: 'json', + }); + }); + + it('returns true if no logs or metrics indices exist', async () => { + esClient.asCurrentUser.cat.indices.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise([]) + ); + expect(await isNewInstance({ esClient, soClient })).toEqual(true); + }); + + it('returns true if no logs or metrics indices contain data', async () => { + esClient.asCurrentUser.cat.indices.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise([ + { index: '.ds-metrics-foo', 'docs.count': '0' }, + ]) + ); + expect(await isNewInstance({ esClient, soClient })).toEqual(true); + }); + + it('returns true if only metrics-elastic_agent index contains data', async () => { + esClient.asCurrentUser.cat.indices.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise([ + { index: '.ds-metrics-elastic_agent', 'docs.count': '100' }, + ]) + ); + expect(await isNewInstance({ esClient, soClient })).toEqual(true); + }); + + it('returns true if only logs-elastic_agent index contains data', async () => { + esClient.asCurrentUser.cat.indices.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise([ + { index: '.ds-logs-elastic_agent', 'docs.count': '100' }, + ]) + ); + expect(await isNewInstance({ esClient, soClient })).toEqual(true); + }); + + it('returns false if any other logs or metrics indices contain data', async () => { + esClient.asCurrentUser.cat.indices.mockReturnValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise([ + { index: '.ds-metrics-foo', 'docs.count': '100' }, + ]) + ); + expect(await isNewInstance({ esClient, soClient })).toEqual(false); + }); + + it('returns false if an authentication error is thrown', async () => { + esClient.asCurrentUser.cat.indices.mockReturnValue( + elasticsearchServiceMock.createErrorTransportRequestPromise({}) + ); + expect(await isNewInstance({ esClient, soClient })).toEqual(false); + }); + }); +}); diff --git a/src/plugins/home/server/services/new_instance_status.ts b/src/plugins/home/server/services/new_instance_status.ts new file mode 100644 index 0000000000000..00223589a8d41 --- /dev/null +++ b/src/plugins/home/server/services/new_instance_status.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IScopedClusterClient, SavedObjectsClientContract } from '../../../../core/server'; +import type { IndexPatternSavedObjectAttrs } from '../../../data/common/index_patterns/index_patterns'; + +const LOGS_INDEX_PATTERN = 'logs-*'; +const METRICS_INDEX_PATTERN = 'metrics-*'; + +const INDEX_PREFIXES_TO_IGNORE = [ + '.ds-metrics-elastic_agent', // ignore index created by Fleet server itself + '.ds-logs-elastic_agent', // ignore index created by Fleet server itself +]; + +interface Deps { + esClient: IScopedClusterClient; + soClient: SavedObjectsClientContract; +} + +export const isNewInstance = async ({ esClient, soClient }: Deps): Promise => { + const indexPatterns = await soClient.find({ + type: 'index-pattern', + fields: ['title'], + search: `*`, + searchFields: ['title'], + perPage: 100, + }); + + // If there are no index patterns, assume this is a new instance + if (indexPatterns.total === 0) { + return true; + } + + // If there are any index patterns that are not the default metrics-* and logs-* ones created by Fleet, assume this + // is not a new instance + if ( + indexPatterns.saved_objects.some( + (ip) => + ip.attributes.title !== LOGS_INDEX_PATTERN && ip.attributes.title !== METRICS_INDEX_PATTERN + ) + ) { + return false; + } + + try { + const logsAndMetricsIndices = await esClient.asCurrentUser.cat.indices({ + index: `${LOGS_INDEX_PATTERN},${METRICS_INDEX_PATTERN}`, + format: 'json', + }); + + const anyIndicesContainerUserData = logsAndMetricsIndices.body + // Ignore some data that is shipped by default + .filter(({ index }) => !INDEX_PREFIXES_TO_IGNORE.some((prefix) => index?.startsWith(prefix))) + // If any other logs and metrics indices have data, return false + .some((catResult) => (catResult['docs.count'] ?? '0') !== '0'); + + return !anyIndicesContainerUserData; + } catch (e) { + // If any errors are encountered return false to be safe + return false; + } +}; diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index 6eb5c9fe38bad..8227e48501aa2 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -212,7 +212,7 @@ export const getSavedObjects = (): SavedObject[] => [ fieldFormatMap: '{"hour_of_day":{"id":"number","params":{"pattern":"00"}},"AvgTicketPrice":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', runtimeFieldMap: - '{"hour_of_day":{"type":"long","script":{"source":"emit(doc[\'timestamp\'].value.hourOfDay);"}}}', + '{"hour_of_day":{"type":"long","script":{"source":"emit(doc[\'timestamp\'].value.getHour());"}}}', }, references: [], }, diff --git a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_prompts.tsx index 3b06fa1cff298..80224dbfb673f 100644 --- a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_prompts.tsx @@ -36,7 +36,7 @@ export const EmptyPrompts: FC = ({ loadSources, }) => { const { - services: { docLinks, application, http }, + services: { docLinks, application, http, searchClient }, } = useKibana(); const [remoteClustersExist, setRemoteClustersExist] = useState(false); @@ -47,7 +47,13 @@ export const EmptyPrompts: FC = ({ useCallback(() => { let isMounted = true; if (!hasDataIndices) - getIndices(http, () => false, '*:*', false).then((dataSources) => { + getIndices({ + http, + isRollupIndex: () => false, + pattern: '*:*', + showAllIndices: false, + searchClient, + }).then((dataSources) => { if (isMounted) { setRemoteClustersExist(!!dataSources.filter(removeAliases).length); } @@ -55,7 +61,7 @@ export const EmptyPrompts: FC = ({ return () => { isMounted = false; }; - }, [http, hasDataIndices]); + }, [http, hasDataIndices, searchClient]); if (!hasExistingIndexPatterns && !goToForm) { if (!hasDataIndices && !remoteClustersExist) { diff --git a/src/plugins/index_pattern_editor/public/components/index_pattern_editor_flyout_content.tsx b/src/plugins/index_pattern_editor/public/components/index_pattern_editor_flyout_content.tsx index cabff9bfb009b..4f6f7708d90c0 100644 --- a/src/plugins/index_pattern_editor/public/components/index_pattern_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_editor/public/components/index_pattern_editor_flyout_content.tsx @@ -70,7 +70,7 @@ const IndexPatternEditorFlyoutContentComponent = ({ }: Props) => { const isMounted = useRef(false); const { - services: { http, indexPatternService, uiSettings }, + services: { http, indexPatternService, uiSettings, searchClient }, } = useKibana(); const { form } = useForm({ @@ -128,13 +128,19 @@ const IndexPatternEditorFlyoutContentComponent = ({ // load all data sources and set initial matchedIndices const loadSources = useCallback(() => { - getIndices(http, () => false, '*', allowHidden).then((dataSources) => { + getIndices({ + http, + isRollupIndex: () => false, + pattern: '*', + showAllIndices: allowHidden, + searchClient, + }).then((dataSources) => { setAllSources(dataSources); const matchedSet = getMatchedIndices(dataSources, [], [], allowHidden); setMatchedIndices(matchedSet); setIsLoadingSources(false); }); - }, [http, allowHidden]); + }, [http, allowHidden, searchClient]); // loading list of index patterns useEffect(() => { @@ -223,13 +229,31 @@ const IndexPatternEditorFlyoutContentComponent = ({ const indexRequests = []; if (query?.endsWith('*')) { - const exactMatchedQuery = getIndices(http, isRollupIndex, query, allowHidden); + const exactMatchedQuery = getIndices({ + http, + isRollupIndex, + pattern: query, + showAllIndices: allowHidden, + searchClient, + }); indexRequests.push(exactMatchedQuery); // provide default value when not making a request for the partialMatchQuery indexRequests.push(Promise.resolve([])); } else { - const exactMatchQuery = getIndices(http, isRollupIndex, query, allowHidden); - const partialMatchQuery = getIndices(http, isRollupIndex, `${query}*`, allowHidden); + const exactMatchQuery = getIndices({ + http, + isRollupIndex, + pattern: query, + showAllIndices: allowHidden, + searchClient, + }); + const partialMatchQuery = getIndices({ + http, + isRollupIndex, + pattern: `${query}*`, + showAllIndices: allowHidden, + searchClient, + }); indexRequests.push(exactMatchQuery); indexRequests.push(partialMatchQuery); @@ -264,7 +288,7 @@ const IndexPatternEditorFlyoutContentComponent = ({ return fetchIndices(newTitle); }, - [http, allowHidden, allSources, type, rollupIndicesCapabilities] + [http, allowHidden, allSources, type, rollupIndicesCapabilities, searchClient] ); useEffect(() => { diff --git a/src/plugins/index_pattern_editor/public/constants.ts b/src/plugins/index_pattern_editor/public/constants.ts index ff74e0827fa50..8d325184353df 100644 --- a/src/plugins/index_pattern_editor/public/constants.ts +++ b/src/plugins/index_pattern_editor/public/constants.ts @@ -9,3 +9,10 @@ export const pluginName = 'index_pattern_editor'; export const MAX_NUMBER_OF_MATCHING_INDICES = 100; export const CONFIG_ROLLUPS = 'rollups:enableIndexPatterns'; + +// This isn't ideal. We want to avoid searching for 20 indices +// then filtering out the majority of them because they are system indices. +// We'd like to filter system indices out in the query +// so if we can accomplish that in the future, this logic can go away +export const ESTIMATED_NUMBER_OF_SYSTEM_INDICES = 100; +export const MAX_SEARCH_SIZE = MAX_NUMBER_OF_MATCHING_INDICES + ESTIMATED_NUMBER_OF_SYSTEM_INDICES; diff --git a/src/plugins/index_pattern_editor/public/lib/get_indices.test.ts b/src/plugins/index_pattern_editor/public/lib/get_indices.test.ts index fc96482f0379f..d65cd27e090bb 100644 --- a/src/plugins/index_pattern_editor/public/lib/get_indices.test.ts +++ b/src/plugins/index_pattern_editor/public/lib/get_indices.test.ts @@ -6,11 +6,17 @@ * Side Public License, v 1. */ -import { getIndices, responseToItemArray } from './get_indices'; +import { + getIndices, + getIndicesViaSearch, + responseToItemArray, + dedupeMatchedItems, +} from './get_indices'; import { httpServiceMock } from '../../../../core/public/mocks'; -import { ResolveIndexResponseItemIndexAttrs } from '../types'; +import { ResolveIndexResponseItemIndexAttrs, MatchedItem } from '../types'; +import { Observable } from 'rxjs'; -export const successfulResponse = { +export const successfulResolveResponse = { indices: [ { name: 'remoteCluster1:bar-01', @@ -32,28 +38,99 @@ export const successfulResponse = { ], }; -const mockGetTags = () => []; -const mockIsRollupIndex = () => false; +const successfulSearchResponse = { + isPartial: false, + isRunning: false, + rawResponse: { + aggregations: { + indices: { + buckets: [{ key: 'kibana_sample_data_ecommerce' }, { key: '.kibana_1' }], + }, + }, + }, +}; + +const partialSearchResponse = { + isPartial: true, + isRunning: true, + rawResponse: { + hits: { + total: 2, + hits: [], + }, + }, +}; + +const errorSearchResponse = { + isPartial: true, + isRunning: false, +}; + +const isRollupIndex = () => false; +const getTags = () => []; +const searchClient = () => + new Observable((observer) => { + observer.next(successfulSearchResponse); + observer.complete(); + }) as any; const http = httpServiceMock.createStartContract(); -http.get.mockResolvedValue(successfulResponse); +http.get.mockResolvedValue(successfulResolveResponse); describe('getIndices', () => { it('should work in a basic case', async () => { - const result = await getIndices(http, mockIsRollupIndex, 'kibana', false); + const uncalledSearchClient = jest.fn(); + const result = await getIndices({ + http, + pattern: 'kibana', + searchClient: uncalledSearchClient, + isRollupIndex, + }); + expect(http.get).toHaveBeenCalled(); + expect(uncalledSearchClient).not.toHaveBeenCalled(); expect(result.length).toBe(3); expect(result[0].name).toBe('f-alias'); expect(result[1].name).toBe('foo'); }); + it('should make two calls in cross cluser case', async () => { + http.get.mockResolvedValue(successfulResolveResponse); + const result = await getIndices({ http, pattern: '*:kibana', searchClient, isRollupIndex }); + + expect(http.get).toHaveBeenCalled(); + expect(result.length).toBe(4); + expect(result[0].name).toBe('f-alias'); + expect(result[1].name).toBe('foo'); + expect(result[2].name).toBe('kibana_sample_data_ecommerce'); + expect(result[3].name).toBe('remoteCluster1:bar-01'); + }); + it('should ignore ccs query-all', async () => { - expect((await getIndices(http, mockIsRollupIndex, '*:', false)).length).toBe(0); + expect((await getIndices({ http, pattern: '*:', searchClient, isRollupIndex })).length).toBe(0); }); it('should ignore a single comma', async () => { - expect((await getIndices(http, mockIsRollupIndex, ',', false)).length).toBe(0); - expect((await getIndices(http, mockIsRollupIndex, ',*', false)).length).toBe(0); - expect((await getIndices(http, mockIsRollupIndex, ',foobar', false)).length).toBe(0); + expect((await getIndices({ http, pattern: ',', searchClient, isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: ',*', searchClient, isRollupIndex })).length).toBe(0); + expect( + (await getIndices({ http, pattern: ',foobar', searchClient, isRollupIndex })).length + ).toBe(0); + }); + + it('should work with partial responses', async () => { + const searchClientPartialResponse = () => + new Observable((observer) => { + observer.next(partialSearchResponse); + observer.next(successfulSearchResponse); + observer.complete(); + }) as any; + const result = await getIndices({ + http, + pattern: '*:kibana', + searchClient: searchClientPartialResponse, + isRollupIndex, + }); + expect(result.length).toBe(4); }); it('response object to item array', () => { @@ -81,16 +158,37 @@ describe('getIndices', () => { }, ], }; - expect(responseToItemArray(result, mockGetTags)).toMatchSnapshot(); - expect(responseToItemArray({}, mockGetTags)).toEqual([]); + expect(responseToItemArray(result, getTags)).toMatchSnapshot(); + expect(responseToItemArray({}, getTags)).toEqual([]); + }); + + it('matched items are deduped', () => { + const setA = [{ name: 'a' }, { name: 'b' }] as MatchedItem[]; + const setB = [{ name: 'b' }, { name: 'c' }] as MatchedItem[]; + expect(dedupeMatchedItems(setA, setB)).toHaveLength(3); }); describe('errors', () => { - it('should handle errors gracefully', async () => { + it('should handle thrown errors gracefully', async () => { http.get.mockImplementationOnce(() => { throw new Error('Test error'); }); - const result = await getIndices(http, mockIsRollupIndex, 'kibana', false); + const result = await getIndices({ http, pattern: 'kibana', searchClient, isRollupIndex }); + expect(result.length).toBe(0); + }); + + it('getIndicesViaSearch should handle error responses gracefully', async () => { + const searchClientErrorResponse = () => + new Observable((observer) => { + observer.next(errorSearchResponse); + observer.complete(); + }) as any; + const result = await getIndicesViaSearch({ + pattern: '*:kibana', + searchClient: searchClientErrorResponse, + showAllIndices: false, + isRollupIndex, + }); expect(result.length).toBe(0); }); }); diff --git a/src/plugins/index_pattern_editor/public/lib/get_indices.ts b/src/plugins/index_pattern_editor/public/lib/get_indices.ts index 625e99ecbcdc5..8d642174232ac 100644 --- a/src/plugins/index_pattern_editor/public/lib/get_indices.ts +++ b/src/plugins/index_pattern_editor/public/lib/get_indices.ts @@ -8,10 +8,18 @@ import { sortBy } from 'lodash'; import { HttpStart } from 'kibana/public'; +import { map, filter } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { Tag, INDEX_PATTERN_TYPE } from '../types'; -// todo move into this plugin, consider removing all ipm references import { MatchedItem, ResolveIndexResponse, ResolveIndexResponseItemIndexAttrs } from '../types'; +import { MAX_SEARCH_SIZE } from '../constants'; + +import { + DataPublicPluginStart, + IEsSearchResponse, + isErrorResponse, + isCompleteResponse, +} from '../../../data/public'; const aliasLabel = i18n.translate('indexPatternEditor.aliasLabel', { defaultMessage: 'Alias' }); const dataStreamLabel = i18n.translate('indexPatternEditor.dataStreamLabel', { @@ -41,13 +49,137 @@ const getIndexTags = (isRollupIndex: (indexName: string) => boolean) => (indexNa ] : []; -export async function getIndices( - http: HttpStart, - isRollupIndex: (indexName: string) => boolean, - rawPattern: string, +export const searchResponseToArray = ( + getTags: (indexName: string) => Tag[], showAllIndices: boolean -): Promise { +) => (response: IEsSearchResponse) => { + const { rawResponse } = response; + if (!rawResponse.aggregations) { + return []; + } else { + // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response + return rawResponse.aggregations.indices.buckets + .map((bucket: { key: string }) => { + return bucket.key; + }) + .filter((indexName: string) => { + if (showAllIndices) { + return true; + } else { + return !indexName.startsWith('.'); + } + }) + .map((indexName: string) => { + return { + name: indexName, + tags: getTags(indexName), + item: {}, + }; + }); + } +}; + +export const getIndicesViaSearch = async ({ + pattern, + searchClient, + showAllIndices, + isRollupIndex, +}: { + pattern: string; + searchClient: DataPublicPluginStart['search']['search']; + showAllIndices: boolean; + isRollupIndex: (indexName: string) => boolean; +}): Promise => + searchClient({ + params: { + ignoreUnavailable: true, + expand_wildcards: showAllIndices ? 'all' : 'open', + index: pattern, + body: { + size: 0, // no hits + aggs: { + indices: { + terms: { + field: '_index', + size: MAX_SEARCH_SIZE, + }, + }, + }, + }, + }, + }) + .pipe( + filter((resp) => isCompleteResponse(resp) || isErrorResponse(resp)), + map(searchResponseToArray(getIndexTags(isRollupIndex), showAllIndices)) + ) + .toPromise() + .catch(() => []); + +export const getIndicesViaResolve = async ({ + http, + pattern, + showAllIndices, + isRollupIndex, +}: { + http: HttpStart; + pattern: string; + showAllIndices: boolean; + isRollupIndex: (indexName: string) => boolean; +}) => + http + .get(`/internal/index-pattern-management/resolve_index/${pattern}`, { + query: showAllIndices ? { expand_wildcards: 'all' } : undefined, + }) + .then((response) => { + if (!response) { + return []; + } else { + return responseToItemArray(response, getIndexTags(isRollupIndex)); + } + }); + +/** + * Takes two MatchedItem[]s and returns a merged set, with the second set prrioritized over the first based on name + * + * @param matchedA + * @param matchedB + */ + +export const dedupeMatchedItems = (matchedA: MatchedItem[], matchedB: MatchedItem[]) => { + const mergedMatchedItems = matchedA.reduce((col, item) => { + col[item.name] = item; + return col; + }, {} as Record); + + matchedB.reduce((col, item) => { + col[item.name] = item; + return col; + }, mergedMatchedItems); + + return Object.values(mergedMatchedItems).sort((a, b) => { + if (a.name > b.name) return 1; + if (b.name > a.name) return -1; + + return 0; + }); +}; + +export async function getIndices({ + http, + pattern: rawPattern = '', + showAllIndices = false, + searchClient, + isRollupIndex, +}: { + http: HttpStart; + pattern: string; + showAllIndices?: boolean; + searchClient: DataPublicPluginStart['search']['search']; + isRollupIndex: (indexName: string) => boolean; +}): Promise { const pattern = rawPattern.trim(); + const isCCS = pattern.indexOf(':') !== -1; + const requests: Array> = []; // Searching for `*:` fails for CCS environments. The search request // is worthless anyways as the we should only send a request @@ -67,20 +199,32 @@ export async function getIndices( return []; } - const query = showAllIndices ? { expand_wildcards: 'all' } : undefined; + const promiseResolve = getIndicesViaResolve({ + http, + pattern, + showAllIndices, + isRollupIndex, + }).catch(() => []); + requests.push(promiseResolve); - try { - const response = await http.get( - `/internal/index-pattern-management/resolve_index/${pattern}`, - { query } - ); - if (!response) { - return []; - } + if (isCCS) { + // CCS supports ±1 major version. We won't be able to expect resolve endpoint to exist until v9 + const promiseSearch = getIndicesViaSearch({ + pattern, + searchClient, + showAllIndices, + isRollupIndex, + }).catch(() => []); + requests.push(promiseSearch); + } - return responseToItemArray(response, getIndexTags(isRollupIndex)); - } catch { - return []; + const responses = await Promise.all(requests); + + if (responses.length === 2) { + const [resolveResponse, searchResponse] = responses; + return dedupeMatchedItems(searchResponse, resolveResponse); + } else { + return responses[0]; } } diff --git a/src/plugins/index_pattern_editor/public/open_editor.tsx b/src/plugins/index_pattern_editor/public/open_editor.tsx index ec62a1d6ec7c6..afeaff11f7403 100644 --- a/src/plugins/index_pattern_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_editor/public/open_editor.tsx @@ -23,9 +23,10 @@ import { IndexPatternEditorLazy } from './components/index_pattern_editor_lazy'; interface Dependencies { core: CoreStart; indexPatternService: DataPublicPluginStart['indexPatterns']; + searchClient: DataPublicPluginStart['search']['search']; } -export const getEditorOpener = ({ core, indexPatternService }: Dependencies) => ( +export const getEditorOpener = ({ core, indexPatternService, searchClient }: Dependencies) => ( options: IndexPatternEditorProps ): CloseEditor => { const { uiSettings, overlays, docLinks, notifications, http, application } = core; @@ -38,6 +39,7 @@ export const getEditorOpener = ({ core, indexPatternService }: Dependencies) => notifications, application, indexPatternService, + searchClient, }); let overlayRef: OverlayRef | null = null; diff --git a/src/plugins/index_pattern_editor/public/plugin.tsx b/src/plugins/index_pattern_editor/public/plugin.tsx index ca72249496e77..246386c5800e4 100644 --- a/src/plugins/index_pattern_editor/public/plugin.tsx +++ b/src/plugins/index_pattern_editor/public/plugin.tsx @@ -38,6 +38,7 @@ export class IndexPatternEditorPlugin openEditor: getEditorOpener({ core, indexPatternService: data.indexPatterns, + searchClient: data.search.search, }), /** * Index pattern editor flyout via react component @@ -53,6 +54,7 @@ export class IndexPatternEditorPlugin notifications, application, indexPatternService: data.indexPatterns, + searchClient: data.search.search, }} {...props} /> diff --git a/src/plugins/index_pattern_editor/public/types.ts b/src/plugins/index_pattern_editor/public/types.ts index 2a2abe249b330..8cc1779a804ba 100644 --- a/src/plugins/index_pattern_editor/public/types.ts +++ b/src/plugins/index_pattern_editor/public/types.ts @@ -27,6 +27,7 @@ export interface IndexPatternEditorContext { notifications: NotificationsStart; application: ApplicationStart; indexPatternService: DataPublicPluginStart['indexPatterns']; + searchClient: DataPublicPluginStart['search']['search']; } /** @public */ diff --git a/src/plugins/index_pattern_management/public/components/utils.ts b/src/plugins/index_pattern_management/public/components/utils.ts index 6520de95028c6..1273a1073fbbf 100644 --- a/src/plugins/index_pattern_management/public/components/utils.ts +++ b/src/plugins/index_pattern_management/public/components/utils.ts @@ -7,7 +7,7 @@ */ import { IndexPatternsContract } from 'src/plugins/data/public'; -import { IndexPattern, IFieldType } from 'src/plugins/data/public'; +import { IFieldType, IndexPattern, IndexPatternListItem } from 'src/plugins/data/public'; import { i18n } from '@kbn/i18n'; const defaultIndexPatternListName = i18n.translate( @@ -24,8 +24,8 @@ const rollupIndexPatternListName = i18n.translate( } ); -const isRollup = (indexPattern: IndexPattern) => { - return indexPattern.type === 'rollup'; +const isRollup = (indexPatternType: string = '') => { + return indexPatternType === 'rollup'; }; export async function getIndexPatterns( @@ -33,24 +33,22 @@ export async function getIndexPatterns( indexPatternsService: IndexPatternsContract ) { const existingIndexPatterns = await indexPatternsService.getIdsWithTitle(true); - const indexPatternsListItems = await Promise.all( - existingIndexPatterns.map(async ({ id, title }) => { - const isDefault = defaultIndex === id; - const pattern = await indexPatternsService.get(id); - const tags = getTags(pattern, isDefault); + const indexPatternsListItems = existingIndexPatterns.map((idxPattern) => { + const { id, title } = idxPattern; + const isDefault = defaultIndex === id; + const tags = getTags(idxPattern, isDefault); - return { - id, - title, - default: isDefault, - tags, - // the prepending of 0 at the default pattern takes care of prioritization - // so the sorting will but the default index on top - // or on bottom of a the table - sort: `${isDefault ? '0' : '1'}${title}`, - }; - }) - ); + return { + id, + title, + default: isDefault, + tags, + // the prepending of 0 at the default pattern takes care of prioritization + // so the sorting will but the default index on top + // or on bottom of a the table + sort: `${isDefault ? '0' : '1'}${title}`, + }; + }); return ( indexPatternsListItems.sort((a, b) => { @@ -65,7 +63,7 @@ export async function getIndexPatterns( ); } -export const getTags = (indexPattern: IndexPattern, isDefault: boolean) => { +export const getTags = (indexPattern: IndexPatternListItem | IndexPattern, isDefault: boolean) => { const tags = []; if (isDefault) { tags.push({ @@ -73,7 +71,7 @@ export const getTags = (indexPattern: IndexPattern, isDefault: boolean) => { name: defaultIndexPatternListName, }); } - if (isRollup(indexPattern)) { + if (isRollup(indexPattern.type)) { tags.push({ key: 'rollup', name: rollupIndexPatternListName, @@ -82,17 +80,21 @@ export const getTags = (indexPattern: IndexPattern, isDefault: boolean) => { return tags; }; -export const areScriptedFieldsEnabled = (indexPattern: IndexPattern) => { - return !isRollup(indexPattern); +export const areScriptedFieldsEnabled = (indexPattern: IndexPatternListItem | IndexPattern) => { + return !isRollup(indexPattern.type); }; -export const getFieldInfo = (indexPattern: IndexPattern, field: IFieldType) => { - if (!isRollup(indexPattern)) { +export const getFieldInfo = ( + indexPattern: IndexPatternListItem | IndexPattern, + field: IFieldType +) => { + if (!isRollup(indexPattern.type)) { return []; } - const allAggs = indexPattern.typeMeta && indexPattern.typeMeta.aggs; - const fieldAggs = allAggs && Object.keys(allAggs).filter((agg) => allAggs[agg][field.name]); + const allAggs = indexPattern.typeMeta?.aggs; + const fieldAggs: string[] | undefined = + allAggs && Object.keys(allAggs).filter((agg) => allAggs[agg][field.name]); if (!fieldAggs || !fieldAggs.length) { return []; diff --git a/src/plugins/inspector/kibana.json b/src/plugins/inspector/kibana.json index 90e5d60250728..66c6617924a7e 100644 --- a/src/plugins/inspector/kibana.json +++ b/src/plugins/inspector/kibana.json @@ -3,6 +3,10 @@ "version": "kibana", "server": false, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "extraPublicDirs": ["common", "common/adapters/request"], "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/kibana_overview/public/components/overview/overview.tsx b/src/plugins/kibana_overview/public/components/overview/overview.tsx index 400eca0ce418b..b6d486a656860 100644 --- a/src/plugins/kibana_overview/public/components/overview/overview.tsx +++ b/src/plugins/kibana_overview/public/components/overview/overview.tsx @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; import { RedirectAppLinks, useKibana, @@ -73,7 +74,9 @@ export const Overview: FC = ({ newsFetchResult, solutions, features }) => const manageDataFeatures = getFeaturesByCategory(FeatureCatalogueCategory.ADMIN); const devTools = findFeatureById('console'); const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = { - solution: 'Analytics', + solution: i18n.translate('kibanaOverview.noDataConfig.solutionName', { + defaultMessage: `Analytics`, + }), logo: 'logoKibana', actions: { beats: { diff --git a/src/plugins/kibana_react/kibana.json b/src/plugins/kibana_react/kibana.json index 6bf7ff1d82070..210b15897cfad 100644 --- a/src/plugins/kibana_react/kibana.json +++ b/src/plugins/kibana_react/kibana.json @@ -3,5 +3,9 @@ "version": "kibana", "ui": true, "server": false, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "extraPublicDirs": ["common"] } diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap index c8fda1d036439..3f72ae5597a98 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -2,33 +2,27 @@ exports[`ElasticAgentCard props button 1`] = ` Button } - href="app/integrations/browse" + href="/app/integrations/browse" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add a Solution integration" + title="Add Elastic Agent" /> `; exports[`ElasticAgentCard props href 1`] = ` Button @@ -36,46 +30,41 @@ exports[`ElasticAgentCard props href 1`] = ` href="#" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add a Solution integration" + title="Add Elastic Agent" /> `; exports[`ElasticAgentCard props recommended 1`] = ` - Find an integration for Solution + Add Elastic Agent } - href="app/integrations/browse" + href="/app/integrations/browse" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add a Solution integration" + title="Add Elastic Agent" /> `; exports[`ElasticAgentCard renders 1`] = ` - Find an integration for Solution + Add Elastic Agent } - href="app/integrations/browse" + href="/app/integrations/browse" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add a Solution integration" + title="Add Elastic Agent" /> `; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap index 1146e4f676eb6..af26f9e93ebac 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap @@ -2,31 +2,27 @@ exports[`ElasticBeatsCard props button 1`] = ` Button } - href="app/home#/tutorial" + href="/app/home#/tutorial_directory" image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" paddingSize="l" - title="Add data with Beats" + title="Add data" /> `; exports[`ElasticBeatsCard props href 1`] = ` Button @@ -34,45 +30,41 @@ exports[`ElasticBeatsCard props href 1`] = ` href="#" image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" paddingSize="l" - title="Add data with Beats" + title="Add data" /> `; exports[`ElasticBeatsCard props recommended 1`] = ` - Install Beats for Solution + Add data } - href="app/home#/tutorial" + href="/app/home#/tutorial_directory" image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" paddingSize="l" - title="Add data with Beats" + title="Add data" /> `; exports[`ElasticBeatsCard renders 1`] = ` - Install Beats for Solution + Add data } - href="app/home#/tutorial" + href="/app/home#/tutorial_directory" image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" paddingSize="l" - title="Add data with Beats" + title="Add data" /> `; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap index a8232c209ed73..fccbbe3a9e8ee 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap @@ -75,10 +75,9 @@ exports[`NoDataCard props href 1`] = `
`; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index f0ee2fc2739d9..ad3fb0e2e6396 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -/* eslint-disable @elastic/eui/href-or-on-click */ - import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'kibana/public'; @@ -24,8 +22,9 @@ export type ElasticAgentCardProps = NoDataPageActions & { */ export const ElasticAgentCard: FunctionComponent = ({ solution, - recommended = true, - href = 'app/integrations/browse', + recommended, + title, + href, button, ...cardRest }) => { @@ -35,30 +34,26 @@ export const ElasticAgentCard: FunctionComponent = ({ const addBasePath = http.basePath.prepend; const basePathUrl = '/plugins/kibanaReact/assets/'; + const defaultCTAtitle = i18n.translate('kibana-react.noDataPage.elasticAgentCard.title', { + defaultMessage: 'Add Elastic Agent', + }); + const footer = typeof button !== 'string' && typeof button !== 'undefined' ? ( button ) : ( - - {button || - i18n.translate('kibana-react.noDataPage.elasticAgentCard.buttonLabel', { - defaultMessage: 'Find an integration for {solution}', - values: { solution }, - })} - + // The href and/or onClick are attached to the whole Card, so the button is just for show. + // Do not add the behavior here too or else it will propogate through + {button || title || defaultCTAtitle} ); return ( = ({ recommended, - href = 'app/home#/tutorial', + title, button, - solution, + href, + solution, // unused for now ...cardRest }) => { const { @@ -33,29 +32,26 @@ export const ElasticBeatsCard: FunctionComponent = ({ const basePathUrl = '/plugins/kibanaReact/assets/'; const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + const defaultCTAtitle = i18n.translate('kibana-react.noDataPage.elasticBeatsCard.title', { + defaultMessage: 'Add data', + }); + const footer = typeof button !== 'string' && typeof button !== 'undefined' ? ( button ) : ( - - {button || - i18n.translate('kibana-react.noDataPage.elasticBeatsCard.buttonLabel', { - defaultMessage: 'Install Beats for {solution}', - values: { solution }, - })} - + // The href and/or onClick are attached to the whole Card, so the button is just for show. + // Do not add the behavior here too or else it will propogate through + {button || title || defaultCTAtitle} ); return ( = ({ recommended, + title, button, ...cardRest }) => { const footer = - typeof button !== 'string' ? ( - button - ) : ( - - {button} - - ); + typeof button !== 'string' ? button : {button || title}; return ( { - this.ace = ace; + handleOnLoad = (editor) => { + this.editor = editor; }; - handleVarClick(snippet) { - return () => { - if (this.ace) this.ace.insert(snippet); - }; - } + handleVarClick = (snippet) => () => { + if (this.editor) { + const range = this.editor.getSelection(); + + this.editor.executeEdits('', [{ range, text: snippet }]); + } + }; render() { const { visData, model, getConfig } = this.props; diff --git a/src/plugins/vis_type_vega/kibana.json b/src/plugins/vis_type_vega/kibana.json index 407e20fe0688a..a579e85c0caf2 100644 --- a/src/plugins/vis_type_vega/kibana.json +++ b/src/plugins/vis_type_vega/kibana.json @@ -5,7 +5,7 @@ "ui": true, "requiredPlugins": ["data", "visualizations", "mapsEms", "expressions", "inspector"], "optionalPlugins": ["home","usageCollection"], - "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor", "esUiShared"], "owner": { "name": "Kibana App", "githubTeam": "kibana-app" diff --git a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx index 148c630ad94e5..9150b31343799 100644 --- a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx @@ -7,13 +7,13 @@ */ import React, { useCallback } from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; import compactStringify from 'json-stringify-pretty-compact'; import hjson from 'hjson'; import 'brace/mode/hjson'; import { i18n } from '@kbn/i18n'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; +import { EuiCodeEditor } from '../../../es_ui_shared/public'; import { getNotifications } from '../services'; import { VisParams } from '../vega_fn'; import { VegaHelpMenu } from './vega_help_menu'; diff --git a/src/plugins/vis_type_vega/tsconfig.json b/src/plugins/vis_type_vega/tsconfig.json index e1b8b5d9d4bac..62bdd0262b4a5 100644 --- a/src/plugins/vis_type_vega/tsconfig.json +++ b/src/plugins/vis_type_vega/tsconfig.json @@ -26,5 +26,6 @@ { "path": "../kibana_utils/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, + { "path": "../es_ui_shared/tsconfig.json" }, ] } diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts index f23d9e4ada336..d5e1360ced74c 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts @@ -76,7 +76,7 @@ export const getAggs = () => { title: 'kibana_sample_data_flights', timeFieldName: 'timestamp', fields: - '[{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + '[{"count":0,"script":"doc[\'timestamp\'].value.getHour()","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', fieldFormatMap: '{"hour_of_day":{"id":"number","params":{"pattern":"00"}},"AvgTicketPrice":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', runtimeFieldMap: '{}', @@ -241,7 +241,7 @@ export const getVis = (bucketType: string) => { title: 'kibana_sample_data_flights', timeFieldName: 'timestamp', fields: - '[{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + '[{"count":0,"script":"doc[\'timestamp\'].value.getHour()","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', fieldFormatMap: '{"hour_of_day":{"id":"number","params":{"pattern":"00"}},"AvgTicketPrice":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', runtimeFieldMap: '{}', diff --git a/test/common/config.js b/test/common/config.js index 5b5d01cfeb1e4..eb110fad55ea8 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -41,7 +41,6 @@ export default function () { )}`, `--elasticsearch.username=${kibanaServerTestUser.username}`, `--elasticsearch.password=${kibanaServerTestUser.password}`, - `--home.disableWelcomeScreen=true`, // Needed for async search functional tests to introduce a delay `--data.search.aggs.shardDelay.enabled=true`, `--security.showInsecureClusterWarning=false`, diff --git a/test/functional/apps/home/_welcome.ts b/test/functional/apps/home/_welcome.ts new file mode 100644 index 0000000000000..ec7e9759558df --- /dev/null +++ b/test/functional/apps/home/_welcome.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'home']); + + describe('Welcome interstitial', () => { + before(async () => { + // Need to navigate to page first to clear storage before test can be run + await PageObjects.common.navigateToUrl('home', undefined); + await browser.clearLocalStorage(); + await esArchiver.emptyKibanaIndex(); + }); + + it('is displayed on a fresh on-prem install', async () => { + await PageObjects.common.navigateToUrl('home', undefined, { disableWelcomePrompt: false }); + expect(await PageObjects.home.isWelcomeInterstitialDisplayed()).to.be(true); + }); + }); +} diff --git a/test/functional/apps/home/index.js b/test/functional/apps/home/index.js index ff6e522e41639..257ee724f6c8b 100644 --- a/test/functional/apps/home/index.js +++ b/test/functional/apps/home/index.js @@ -21,5 +21,6 @@ export default function ({ getService, loadTestFile }) { loadTestFile(require.resolve('./_newsfeed')); loadTestFile(require.resolve('./_add_data')); loadTestFile(require.resolve('./_sample_data')); + loadTestFile(require.resolve('./_welcome')); }); } diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index 89db60bc7645c..b8b74d5cd7bf3 100644 --- a/test/functional/apps/visualize/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/_tsvb_markdown.ts @@ -11,10 +11,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const { visualBuilder, timePicker, visualize } = getPageObjects([ + const { visualBuilder, timePicker, visualize, visChart } = getPageObjects([ 'visualBuilder', 'timePicker', 'visualize', + 'visChart', ]); const retry = getService('retry'); @@ -76,6 +77,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(markdownText).to.be(html); }); + it('markdown variables should be clickable', async () => { + await visualBuilder.clearMarkdown(); + const [firstVariable] = await visualBuilder.getMarkdownTableVariables(); + await firstVariable.selector.click(); + await visChart.waitForVisualizationRenderingStabilized(); + const markdownText = await visualBuilder.getMarkdownText(); + expect(markdownText).to.be('46'); + }); + it('should render mustache list', async () => { const list = '{{#each _all}}\n{{ data.formatted.[0] }} {{ data.raw.[0] }}\n{{/each}}'; const expectedRenderer = 'Sep 22, 2015 @ 06:00:00.000,6 1442901600000,6'; diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 49d56d6f43784..70589b9d9505e 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -19,6 +19,7 @@ interface NavigateProps { shouldLoginIfPrompted: boolean; useActualUrl: boolean; insertTimestamp: boolean; + disableWelcomePrompt: boolean; } export class CommonPageObject extends FtrService { private readonly log = this.ctx.getService('log'); @@ -37,11 +38,17 @@ export class CommonPageObject extends FtrService { * Logins to Kibana as default user and navigates to provided app * @param appUrl Kibana URL */ - private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) { + private async loginIfPrompted( + appUrl: string, + insertTimestamp: boolean, + disableWelcomePrompt: boolean + ) { // Disable the welcome screen. This is relevant for environments // which don't allow to use the yml setting, e.g. cloud production. // It is done here so it applies to logins but also to a login re-use. - await this.browser.setLocalStorageItem('home:welcome:show', 'false'); + if (disableWelcomePrompt) { + await this.browser.setLocalStorageItem('home:welcome:show', 'false'); + } let currentUrl = await this.browser.getCurrentUrl(); this.log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`); @@ -76,6 +83,7 @@ export class CommonPageObject extends FtrService { appConfig, ensureCurrentUrl, shouldLoginIfPrompted, + disableWelcomePrompt, useActualUrl, insertTimestamp, } = navigateProps; @@ -95,7 +103,7 @@ export class CommonPageObject extends FtrService { await alert?.accept(); const currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl, insertTimestamp) + ? await this.loginIfPrompted(appUrl, insertTimestamp, disableWelcomePrompt) : await this.browser.getCurrentUrl(); if (ensureCurrentUrl && !currentUrl.includes(appUrl)) { @@ -117,6 +125,7 @@ export class CommonPageObject extends FtrService { basePath = '', ensureCurrentUrl = true, shouldLoginIfPrompted = true, + disableWelcomePrompt = true, useActualUrl = false, insertTimestamp = true, shouldUseHashForSubUrl = true, @@ -136,6 +145,7 @@ export class CommonPageObject extends FtrService { appConfig, ensureCurrentUrl, shouldLoginIfPrompted, + disableWelcomePrompt, useActualUrl, insertTimestamp, }); @@ -156,6 +166,7 @@ export class CommonPageObject extends FtrService { basePath = '', ensureCurrentUrl = true, shouldLoginIfPrompted = true, + disableWelcomePrompt = true, useActualUrl = true, insertTimestamp = true, } = {} @@ -170,6 +181,7 @@ export class CommonPageObject extends FtrService { appConfig, ensureCurrentUrl, shouldLoginIfPrompted, + disableWelcomePrompt, useActualUrl, insertTimestamp, }); @@ -202,7 +214,13 @@ export class CommonPageObject extends FtrService { async navigateToApp( appName: string, - { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {} + { + basePath = '', + shouldLoginIfPrompted = true, + disableWelcomePrompt = true, + hash = '', + insertTimestamp = true, + } = {} ) { let appUrl: string; if (this.config.has(['apps', appName])) { @@ -233,7 +251,7 @@ export class CommonPageObject extends FtrService { this.log.debug('returned from get, calling refresh'); await this.browser.refresh(); let currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl, insertTimestamp) + ? await this.loginIfPrompted(appUrl, insertTimestamp, disableWelcomePrompt) : await this.browser.getCurrentUrl(); if (currentUrl.includes('app/kibana')) { diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index c318635fc8548..8929026a28122 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -30,6 +30,10 @@ export class HomePageObject extends FtrService { return !(await this.testSubjects.exists(`addSampleDataSet${id}`)); } + async isWelcomeInterstitialDisplayed() { + return await this.testSubjects.isDisplayed('homeWelcomeInterstitial'); + } + async getVisibileSolutions() { const solutionPanels = await this.testSubjects.findAll('~homSolutionPanel', 2000); const panelAttributes = await Promise.all( diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index d38203d5d07d3..73d92f8ff722b 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -469,6 +469,15 @@ class BrowserService extends FtrService { await this.driver.executeScript('return window.localStorage.removeItem(arguments[0]);', key); } + /** + * Clears all values in local storage for the focused window/frame. + * + * @return {Promise} + */ + public async clearLocalStorage(): Promise { + await this.driver.executeScript('return window.localStorage.clear();'); + } + /** * Clears session storage for the focused window/frame. * diff --git a/x-pack/examples/alerting_example/kibana.json b/x-pack/examples/alerting_example/kibana.json index f2950db96ba2c..13117713a9a7e 100644 --- a/x-pack/examples/alerting_example/kibana.json +++ b/x-pack/examples/alerting_example/kibana.json @@ -2,9 +2,22 @@ "id": "alertingExample", "version": "0.0.1", "kibanaVersion": "kibana", + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "server": true, "ui": true, - "requiredPlugins": ["triggersActionsUi", "charts", "data", "alerting", "actions", "kibanaReact", "features", "developerExamples"], + "requiredPlugins": [ + "triggersActionsUi", + "charts", + "data", + "alerting", + "actions", + "kibanaReact", + "features", + "developerExamples" + ], "optionalPlugins": [], "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 58c932c3ca164..55f2b4ccd71e9 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -173,7 +173,9 @@ export const App = (props: { timeRange: time, attributes: getLensAttributes(props.defaultIndexPattern!, color), }, - true + { + openInNewTab: true, + } ); // eslint-disable-next-line no-bitwise const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16); @@ -195,7 +197,9 @@ export const App = (props: { timeRange: time, attributes: getLensAttributes(props.defaultIndexPattern!, color), }, - false + { + openInNewTab: false, + } ); }} > diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index 59a0926118962..4fa62668dd557 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -5,6 +5,10 @@ "configPath": ["ui_actions_enhanced_examples"], "server": false, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "requiredPlugins": [ "uiActions", "uiActionsEnhanced", @@ -15,10 +19,5 @@ "developerExamples" ], "optionalPlugins": [], - "requiredBundles": [ - "dashboardEnhanced", - "embeddable", - "kibanaUtils", - "kibanaReact" - ] + "requiredBundles": ["dashboardEnhanced", "embeddable", "kibanaUtils", "kibanaReact"] } diff --git a/x-pack/plugins/actions/kibana.json b/x-pack/plugins/actions/kibana.json index ef604a9cf6417..aa3a9f3f6c34c 100644 --- a/x-pack/plugins/actions/kibana.json +++ b/x-pack/plugins/actions/kibana.json @@ -1,5 +1,9 @@ { "id": "actions", + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "server": true, "version": "8.0.0", "kibanaVersion": "kibana", diff --git a/x-pack/plugins/alerting/kibana.json b/x-pack/plugins/alerting/kibana.json index af2d08e69f597..82d8de0daf14a 100644 --- a/x-pack/plugins/alerting/kibana.json +++ b/x-pack/plugins/alerting/kibana.json @@ -2,6 +2,10 @@ "id": "alerting", "server": true, "ui": true, + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "alerting"], diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 512f4618792fb..b1460a5fe5cd8 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -1012,6 +1012,410 @@ describe('successful migrations', () => { }); }); }); + + describe('7.15.0', () => { + test('security solution is migrated to saved object references if it has 1 exceptionsList', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', // extra data to ensure we do not overwrite other params + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }); + + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution is migrated to saved object references if it has 2 exceptionsLists', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', // extra data to ensure we do not overwrite other params + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'agnostic', + }, + { + id: '789', + list_id: '0123', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }); + + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list-agnostic', + }, + { + name: 'param:exceptionsList_1', + id: '789', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution is migrated to saved object references if it has 3 exceptionsLists', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', // extra data to ensure we do not overwrite other params + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + { + id: '789', + list_id: '0123', + type: 'detection', + namespace_type: 'agnostic', + }, + { + id: '101112', + list_id: '777', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }); + + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + { + name: 'param:exceptionsList_1', + id: '789', + type: 'exception-list-agnostic', + }, + { + name: 'param:exceptionsList_2', + id: '101112', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution does not change anything if exceptionsList is missing', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + }, + }); + + expect(migration7150(alert, migrationContext)).toEqual(alert); + }); + + test('security solution will keep existing references if we do not have an exceptionsList but we do already have references', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + }, + }), + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + ], + }; + + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution keep any foreign references if they exist but still migrate other references', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + { + id: '789', + list_id: '0123', + type: 'detection', + namespace_type: 'single', + }, + { + id: '101112', + list_id: '777', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }), + references: [ + { + name: 'foreign-name', + id: '999', + type: 'foreign-name', + }, + ], + }; + + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'foreign-name', + id: '999', + type: 'foreign-name', + }, + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + { + name: 'param:exceptionsList_1', + id: '789', + type: 'exception-list', + }, + { + name: 'param:exceptionsList_2', + id: '101112', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution is idempotent and if re-run on the same migrated data will keep the same items', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + { + id: '789', + list_id: '0123', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }), + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + { + name: 'param:exceptionsList_1', + id: '789', + type: 'exception-list', + }, + ], + }; + + expect(migration7150(alert, migrationContext)).toEqual(alert); + }); + + test('security solution will migrate with only missing data if we have partially migrated data', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + { + id: '789', + list_id: '0123', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }), + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + ], + }; + + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + { + name: 'param:exceptionsList_1', + id: '789', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution will not migrate if exception list if it is invalid data', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + exceptionsList: [{ invalid: 'invalid' }], + }, + }), + }; + expect(migration7150(alert, migrationContext)).toEqual(alert); + }); + + test('security solution will migrate valid data if it is mixed with invalid data', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + exceptionsList: [ + { + id: '123', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + { id: 555 }, // <-- Id is a number and not a string, and is invalid + { + id: '456', + list_id: '456', + type: 'detection', + namespace_type: 'single', + }, + ], + }, + }), + }; + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + { + name: 'param:exceptionsList_1', + id: '456', + type: 'exception-list', + }, + ], + }); + }); + + test('security solution will not migrate if exception list is invalid data but will keep existing references', () => { + const migration7150 = getMigrations(encryptedSavedObjectsSetup)['7.15.0']; + const alert = { + ...getMockData({ + alertTypeId: 'siem.signals', + params: { + note: 'some note', + exceptionsList: [{ invalid: 'invalid' }], + }, + }), + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + ], + }; + expect(migration7150(alert, migrationContext)).toEqual({ + ...alert, + references: [ + { + name: 'param:exceptionsList_0', + id: '123', + type: 'exception-list', + }, + ], + }); + }); + }); }); describe('handles errors during migrations', () => { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 944acbdca0182..6823a9b9b20da 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isString } from 'lodash/fp'; import { LogMeta, SavedObjectMigrationMap, @@ -13,6 +14,7 @@ import { SavedObjectMigrationContext, SavedObjectAttributes, SavedObjectAttribute, + SavedObjectReference, } from '../../../../../src/core/server'; import { RawAlert, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; @@ -91,12 +93,19 @@ export function getMigrations( pipeMigrations(removeNullAuthorFromSecurityRules) ); + const migrationSecurityRules715 = createEsoMigration( + encryptedSavedObjects, + (doc): doc is SavedObjectUnsanitizedDoc => isSecuritySolutionRule(doc), + pipeMigrations(addExceptionListsToReferences) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), + '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), }; } @@ -467,6 +476,97 @@ function removeNullAuthorFromSecurityRules( }; } +/** + * This migrates exception list containers to saved object references on an upgrade. + * We only migrate if we find these conditions: + * - exceptionLists are an array and not null, undefined, or malformed data. + * - The exceptionList item is an object and id is a string and not null, undefined, or malformed data + * - The existing references do not already have an exceptionItem reference already found within it. + * Some of these issues could crop up during either user manual errors of modifying things, earlier migration + * issues, etc... + * @param doc The document that might have exceptionListItems to migrate + * @returns The document migrated with saved object references + */ +function addExceptionListsToReferences( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { + params: { exceptionsList }, + }, + references, + } = doc; + if (!Array.isArray(exceptionsList)) { + // early return if we are not an array such as being undefined or null or malformed. + return doc; + } else { + const exceptionsToTransform = removeMalformedExceptionsList(exceptionsList); + const newReferences = exceptionsToTransform.flatMap( + (exceptionItem, index) => { + const existingReferenceFound = references?.find((reference) => { + return ( + reference.id === exceptionItem.id && + ((reference.type === 'exception-list' && exceptionItem.namespace_type === 'single') || + (reference.type === 'exception-list-agnostic' && + exceptionItem.namespace_type === 'agnostic')) + ); + }); + if (existingReferenceFound) { + // skip if the reference already exists for some uncommon reason so we do not add an additional one. + // This enables us to be idempotent and you can run this migration multiple times and get the same output. + return []; + } else { + return [ + { + name: `param:exceptionsList_${index}`, + id: String(exceptionItem.id), + type: + exceptionItem.namespace_type === 'agnostic' + ? 'exception-list-agnostic' + : 'exception-list', + }, + ]; + } + } + ); + if (references == null && newReferences.length === 0) { + // Avoid adding an empty references array if the existing saved object never had one to begin with + return doc; + } else { + return { ...doc, references: [...(references ?? []), ...newReferences] }; + } + } +} + +/** + * This will do a flatMap reduce where we only return exceptionsLists and their items if: + * - exceptionLists are an array and not null, undefined, or malformed data. + * - The exceptionList item is an object and id is a string and not null, undefined, or malformed data + * + * Some of these issues could crop up during either user manual errors of modifying things, earlier migration + * issues, etc... + * @param exceptionsList The list of exceptions + * @returns The exception lists if they are a valid enough shape + */ +function removeMalformedExceptionsList( + exceptionsList: SavedObjectAttribute +): SavedObjectAttributes[] { + if (!Array.isArray(exceptionsList)) { + // early return if we are not an array such as being undefined or null or malformed. + return []; + } else { + return exceptionsList.flatMap((exceptionItem) => { + if (!(exceptionItem instanceof Object) || !isString(exceptionItem.id)) { + // return early if we are not an object such as being undefined or null or malformed + // or the exceptionItem.id is not a string from being malformed + return []; + } else { + return [exceptionItem]; + } + }); + } +} + function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 650e72751749e..b41ae949d5867 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -86,3 +86,7 @@ export function isIosAgentName(agentName?: string) { const lowercased = agentName && agentName.toLowerCase(); return lowercased === 'ios/swift' || lowercased === 'opentelemetry/swift'; } + +export function isJRubyAgent(agentName?: string, runtimeName?: string) { + return agentName === 'ruby' && runtimeName?.toLowerCase() === 'jruby'; +} diff --git a/x-pack/plugins/apm/common/backends.ts b/x-pack/plugins/apm/common/backends.ts new file mode 100644 index 0000000000000..35a52cf3778f1 --- /dev/null +++ b/x-pack/plugins/apm/common/backends.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ProcessorEvent } from './processor_event'; +import { + PROCESSOR_EVENT, + SPAN_DESTINATION_SERVICE_RESOURCE, +} from './elasticsearch_fieldnames'; +import { environmentQuery } from './utils/environment_query'; + +export const kueryBarPlaceholder = i18n.translate( + 'xpack.apm.backends.kueryBarPlaceholder', + { + defaultMessage: `Search backend metrics (e.g. span.destination.service.resource:elasticsearch)`, + } +); + +export const getKueryBarBoolFilter = ({ + backendName, + environment, +}: { + backendName?: string; + environment: string; +}) => { + return [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.metric } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, + ...(backendName + ? [{ term: { [SPAN_DESTINATION_SERVICE_RESOURCE]: backendName } }] + : []), + ...environmentQuery(environment), + ]; +}; diff --git a/x-pack/plugins/apm/common/search_strategies/correlations/types.ts b/x-pack/plugins/apm/common/search_strategies/correlations/types.ts index 70c1c7524cfe9..703106628f561 100644 --- a/x-pack/plugins/apm/common/search_strategies/correlations/types.ts +++ b/x-pack/plugins/apm/common/search_strategies/correlations/types.ts @@ -27,7 +27,7 @@ export interface SearchServiceParams { start?: string; end?: string; percentileThreshold?: number; - percentileThresholdValue?: number; + analyzeCorrelations?: boolean; } export interface SearchServiceFetchParams extends SearchServiceParams { diff --git a/x-pack/plugins/apm/common/search_strategies/failure_correlations/constants.ts b/x-pack/plugins/apm/common/search_strategies/failure_correlations/constants.ts new file mode 100644 index 0000000000000..a80918f0e399e --- /dev/null +++ b/x-pack/plugins/apm/common/search_strategies/failure_correlations/constants.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY = + 'apmFailedTransactionsCorrelationsSearchStrategy'; + +export const FAILED_TRANSACTIONS_IMPACT_THRESHOLD = { + HIGH: i18n.translate( + 'xpack.apm.correlations.failedTransactions.highImpactText', + { + defaultMessage: 'High', + } + ), + MEDIUM: i18n.translate( + 'xpack.apm.correlations.failedTransactions.mediumImpactText', + { + defaultMessage: 'Medium', + } + ), + LOW: i18n.translate( + 'xpack.apm.correlations.failedTransactions.lowImpactText', + { + defaultMessage: 'Low', + } + ), +} as const; diff --git a/x-pack/plugins/apm/common/search_strategies/failure_correlations/types.ts b/x-pack/plugins/apm/common/search_strategies/failure_correlations/types.ts new file mode 100644 index 0000000000000..08e05d46ba013 --- /dev/null +++ b/x-pack/plugins/apm/common/search_strategies/failure_correlations/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from './constants'; + +export interface FailedTransactionsCorrelationValue { + key: string; + doc_count: number; + bg_count: number; + score: number; + pValue: number | null; + fieldName: string; + fieldValue: string; +} + +export type FailureCorrelationImpactThreshold = typeof FAILED_TRANSACTIONS_IMPACT_THRESHOLD[keyof typeof FAILED_TRANSACTIONS_IMPACT_THRESHOLD]; + +export interface CorrelationsTerm { + fieldName: string; + fieldValue: string; +} diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx index 5b4ecb8e73752..b1aa4c9231839 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx @@ -15,8 +15,6 @@ import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_ra import { DependenciesTable } from '../../shared/dependencies_table'; import { useApmBackendContext } from '../../../context/apm_backend/use_apm_backend_context'; import { ServiceLink } from '../../shared/service_link'; -import { useApmRouter } from '../../../hooks/use_apm_router'; -import { DependenciesTableServiceMapLink } from '../../shared/dependencies_table/dependencies_table_service_map_link'; export function BackendDetailDependenciesTable() { const { @@ -27,17 +25,6 @@ export function BackendDetailDependenciesTable() { query: { rangeFrom, rangeTo, kuery, environment }, } = useApmParams('/backends/:backendName/overview'); - const router = useApmRouter(); - - const serviceMapLink = router.link('/service-map', { - query: { - rangeFrom, - rangeTo, - environment, - kuery, - }, - }); - const { offset } = getTimeRangeComparison({ start, end, @@ -112,7 +99,6 @@ export function BackendDetailDependenciesTable() { )} status={status} compact={false} - link={} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx index 0ee533120020a..1adb41acab70a 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx @@ -23,6 +23,10 @@ import { BackendDetailDependenciesTable } from './backend_detail_dependencies_ta import { BackendThroughputChart } from './backend_throughput_chart'; import { BackendFailedTransactionRateChart } from './backend_error_rate_chart'; import { BackendDetailTemplate } from '../../routing/templates/backend_detail_template'; +import { + getKueryBarBoolFilter, + kueryBarPlaceholder, +} from '../../../../common/backends'; import { useBreakPoints } from '../../../hooks/use_break_points'; export function BackendDetailOverview() { @@ -54,12 +58,21 @@ export function BackendDetailOverview() { }, ]); + const kueryBarBoolFilter = getKueryBarBoolFilter({ + environment, + backendName, + }); + const largeScreenOrSmaller = useBreakPoints().isLarge; return ( - + - + diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx index cfee03ef36095..796dae4b84c72 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx @@ -5,19 +5,17 @@ * 2.0. */ +import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { METRIC_TYPE } from '@kbn/analytics'; -import { useApmRouter } from '../../../../hooks/use_apm_router'; +import { useUiTracker } from '../../../../../../observability/public'; import { getNodeName, NodeType } from '../../../../../common/connections'; -import { useApmParams } from '../../../../hooks/use_apm_params'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; -import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison'; -import { DependenciesTable } from '../../../shared/dependencies_table'; import { BackendLink } from '../../../shared/backend_link'; -import { DependenciesTableServiceMapLink } from '../../../shared/dependencies_table/dependencies_table_service_map_link'; -import { useUiTracker } from '../../../../../../observability/public'; +import { DependenciesTable } from '../../../shared/dependencies_table'; +import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison'; export function BackendInventoryDependenciesTable() { const { @@ -28,19 +26,8 @@ export function BackendInventoryDependenciesTable() { query: { rangeFrom, rangeTo, environment, kuery }, } = useApmParams('/backends'); - const router = useApmRouter(); - const trackEvent = useUiTracker(); - const serviceMapLink = router.link('/service-map', { - query: { - rangeFrom, - rangeTo, - environment, - kuery, - }, - }); - const { offset } = getTimeRangeComparison({ start, end, @@ -106,12 +93,7 @@ export function BackendInventoryDependenciesTable() { return ( } /> ); } diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx index 2a1f9e5f5b60d..433d187bda0b3 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx @@ -5,14 +5,33 @@ * 2.0. */ +import { EuiSpacer } from '@elastic/eui'; import React from 'react'; +import { + getKueryBarBoolFilter, + kueryBarPlaceholder, +} from '../../../../common/backends'; +import { useApmParams } from '../../../hooks/use_apm_params'; import { SearchBar } from '../../shared/search_bar'; import { BackendInventoryDependenciesTable } from './backend_inventory_dependencies_table'; export function BackendInventory() { + const { + query: { environment }, + } = useApmParams('/backends'); + + const kueryBarBoolFilter = getKueryBarBoolFilter({ + environment, + }); + return ( <> - + + ); diff --git a/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx index 62d566963699d..28f671183ed87 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx @@ -7,32 +7,17 @@ import React, { useCallback, useMemo, useState } from 'react'; import { debounce } from 'lodash'; -import { - EuiIcon, - EuiLink, - EuiBasicTable, - EuiBasicTableColumn, - EuiToolTip, -} from '@elastic/eui'; +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useHistory } from 'react-router-dom'; -import { asInteger, asPercent } from '../../../../common/utils/formatters'; -import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import { createHref, push } from '../../shared/Links/url_helpers'; -import { ImpactBar } from '../../shared/ImpactBar'; import { useUiTracker } from '../../../../../observability/public'; import { useTheme } from '../../../hooks/use_theme'; +import { CorrelationsTerm } from '../../../../common/search_strategies/failure_correlations/types'; const PAGINATION_SIZE_OPTIONS = [5, 10, 20, 50]; -type CorrelationsApiResponse = - | APIReturnType<'GET /api/apm/correlations/errors/failed_transactions'> - | APIReturnType<'GET /api/apm/correlations/latency/slow_transactions'>; -export type SignificantTerm = CorrelationsApiResponse['significantTerms'][0]; - -export type SelectedSignificantTerm = Pick< - SignificantTerm, +export type SelectedCorrelationTerm = Pick< + T, 'fieldName' | 'fieldValue' >; @@ -40,24 +25,22 @@ interface Props { significantTerms?: T[]; status: FETCH_STATUS; percentageColumnName?: string; - setSelectedSignificantTerm: (term: SelectedSignificantTerm | null) => void; + setSelectedSignificantTerm: (term: T | null) => void; selectedTerm?: { fieldName: string; fieldValue: string }; - onFilter: () => void; - columns?: Array>; + onFilter?: () => void; + columns: Array>; } -export function CorrelationsTable({ +export function CorrelationsTable({ significantTerms, status, - percentageColumnName, setSelectedSignificantTerm, - onFilter, columns, selectedTerm, }: Props) { const euiTheme = useTheme(); const trackApmEvent = useUiTracker({ app: 'apm' }); - const trackSelectSignificantTerm = useCallback( + const trackSelectSignificantCorrelationTerm = useCallback( () => debounce( () => trackApmEvent({ metric: 'select_significant_term' }), @@ -65,7 +48,6 @@ export function CorrelationsTable({ ), [trackApmEvent] ); - const history = useHistory(); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); @@ -92,140 +74,6 @@ export function CorrelationsTable({ setPageSize(size); }, []); - const tableColumns: Array> = columns ?? [ - { - width: '116px', - field: 'impact', - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.impactLabel', - { defaultMessage: 'Impact' } - ), - render: (_: any, term: T) => { - return ; - }, - }, - { - field: 'percentage', - name: - percentageColumnName ?? - i18n.translate( - 'xpack.apm.correlations.correlationsTable.percentageLabel', - { defaultMessage: 'Percentage' } - ), - render: (_: any, term: T) => { - return ( - - <>{asPercent(term.valueCount, term.fieldCount)} - - ); - }, - }, - { - field: 'fieldName', - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.fieldNameLabel', - { defaultMessage: 'Field name' } - ), - }, - { - field: 'fieldValue', - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.fieldValueLabel', - { defaultMessage: 'Field value' } - ), - render: (_: any, term: T) => String(term.fieldValue).slice(0, 50), - }, - { - width: '100px', - actions: [ - { - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.filterLabel', - { defaultMessage: 'Filter' } - ), - description: i18n.translate( - 'xpack.apm.correlations.correlationsTable.filterDescription', - { defaultMessage: 'Filter by value' } - ), - icon: 'plusInCircle', - type: 'icon', - onClick: (term: T) => { - push(history, { - query: { - kuery: `${term.fieldName}:"${encodeURIComponent( - term.fieldValue - )}"`, - }, - }); - onFilter(); - trackApmEvent({ metric: 'correlations_term_include_filter' }); - }, - }, - { - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.excludeLabel', - { defaultMessage: 'Exclude' } - ), - description: i18n.translate( - 'xpack.apm.correlations.correlationsTable.excludeDescription', - { defaultMessage: 'Filter out value' } - ), - icon: 'minusInCircle', - type: 'icon', - onClick: (term: T) => { - push(history, { - query: { - kuery: `not ${term.fieldName}:"${encodeURIComponent( - term.fieldValue - )}"`, - }, - }); - onFilter(); - trackApmEvent({ metric: 'correlations_term_exclude_filter' }); - }, - }, - ], - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.actionsLabel', - { defaultMessage: 'Filter' } - ), - render: (_: any, term: T) => { - return ( - <> - - - -  /  - - - - - ); - }, - }, - ]; - return ( ({ status === FETCH_STATUS.LOADING ? loadingText : noDataText } loading={status === FETCH_STATUS.LOADING} - columns={tableColumns} + columns={columns} rowProps={(term) => { return { onMouseEnter: () => { setSelectedSignificantTerm(term); - trackSelectSignificantTerm(); + trackSelectSignificantCorrelationTerm(); }, onMouseLeave: () => setSelectedSignificantTerm(null), style: diff --git a/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx deleted file mode 100644 index 298206f30d614..0000000000000 --- a/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx +++ /dev/null @@ -1,283 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - Axis, - Chart, - CurveType, - LineSeries, - Position, - ScaleType, - Settings, - timeFormatter, -} from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { useUiTracker } from '../../../../../observability/public'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { useLocalStorage } from '../../../hooks/useLocalStorage'; -import { useApmParams } from '../../../hooks/use_apm_params'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { useTheme } from '../../../hooks/use_theme'; -import { APIReturnType } from '../../../services/rest/createCallApmApi'; -import { ChartContainer } from '../../shared/charts/chart_container'; -import { - CorrelationsTable, - SelectedSignificantTerm, -} from './correlations_table'; -import { CustomFields } from './custom_fields'; -import { useFieldNames } from './use_field_names'; - -type OverallErrorsApiResponse = NonNullable< - APIReturnType<'GET /api/apm/correlations/errors/overall_timeseries'> ->; - -type CorrelationsApiResponse = NonNullable< - APIReturnType<'GET /api/apm/correlations/errors/failed_transactions'> ->; - -interface Props { - onClose: () => void; -} - -export function ErrorCorrelations({ onClose }: Props) { - const [ - selectedSignificantTerm, - setSelectedSignificantTerm, - ] = useState(null); - - const { serviceName } = useApmServiceContext(); - const { urlParams } = useUrlParams(); - const { transactionName, transactionType, start, end } = urlParams; - const { defaultFieldNames } = useFieldNames(); - const [fieldNames, setFieldNames] = useLocalStorage( - `apm.correlations.errors.fields:${serviceName}`, - defaultFieldNames - ); - const hasFieldNames = fieldNames.length > 0; - - const { - query: { environment, kuery }, - } = useApmParams('/services/:serviceName'); - - const { data: overallData, status: overallStatus } = useFetcher( - (callApmApi) => { - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/correlations/errors/overall_timeseries', - params: { - query: { - environment, - kuery, - serviceName, - transactionName, - transactionType, - start, - end, - }, - }, - }); - } - }, - [ - environment, - kuery, - serviceName, - start, - end, - transactionName, - transactionType, - ] - ); - - const { data: correlationsData, status: correlationsStatus } = useFetcher( - (callApmApi) => { - if (start && end && hasFieldNames) { - return callApmApi({ - endpoint: 'GET /api/apm/correlations/errors/failed_transactions', - params: { - query: { - environment, - kuery, - serviceName, - transactionName, - transactionType, - start, - end, - fieldNames: fieldNames.join(','), - }, - }, - }); - } - }, - [ - environment, - kuery, - serviceName, - start, - end, - transactionName, - transactionType, - fieldNames, - hasFieldNames, - ] - ); - - const trackApmEvent = useUiTracker({ app: 'apm' }); - trackApmEvent({ metric: 'view_errors_correlations' }); - - return ( - <> - - - -

- {i18n.translate('xpack.apm.correlations.error.description', { - defaultMessage: - 'Why are some transactions failing and returning errors? Correlations will help discover a possible culprit in a particular cohort of your data. Either by host, version, or other custom fields.', - })} -

-
-
- - -

- {i18n.translate('xpack.apm.correlations.error.chart.title', { - defaultMessage: 'Error rate over time', - })} -

-
-
- - - - - - - - - -
- - ); -} - -function getSelectedTimeseries( - significantTerms: CorrelationsApiResponse['significantTerms'], - selectedSignificantTerm: SelectedSignificantTerm -) { - if (!significantTerms) { - return []; - } - return ( - significantTerms.find( - ({ fieldName, fieldValue }) => - selectedSignificantTerm.fieldName === fieldName && - selectedSignificantTerm.fieldValue === fieldValue - )?.timeseries || [] - ); -} - -function ErrorTimeseriesChart({ - overallData, - correlationsData, - selectedSignificantTerm, - status, -}: { - overallData?: OverallErrorsApiResponse; - correlationsData?: CorrelationsApiResponse; - selectedSignificantTerm: SelectedSignificantTerm | null; - status: FETCH_STATUS; -}) { - const theme = useTheme(); - const dateFormatter = timeFormatter('HH:mm:ss'); - - return ( - - - - - - `${roundFloat(d * 100)}%`} - /> - - - - {correlationsData && selectedSignificantTerm ? ( - - ) : null} - - - ); -} - -function roundFloat(n: number, digits = 2) { - const factor = Math.pow(10, digits); - return Math.round(n * factor) / factor; -} diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx new file mode 100644 index 0000000000000..3ec663ba36848 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -0,0 +1,437 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + EuiCallOut, + EuiCode, + EuiAccordion, + EuiPanel, + EuiBasicTableColumn, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, + EuiText, + EuiBadge, + EuiIcon, + EuiLink, + EuiTitle, + EuiBetaBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useHistory } from 'react-router-dom'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { CorrelationsTable } from './correlations_table'; +import { enableInspectEsQueries } from '../../../../../observability/public'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { FailedTransactionsCorrelationsHelpPopover } from './failed_transactions_correlations_help_popover'; +import { FailedTransactionsCorrelationValue } from '../../../../common/search_strategies/failure_correlations/types'; +import { ImpactBar } from '../../shared/ImpactBar'; +import { isErrorMessage } from './utils/is_error_message'; +import { Summary } from '../../shared/Summary'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { getFailedTransactionsCorrelationImpactLabel } from './utils/get_failed_transactions_correlation_impact_label'; +import { createHref, push } from '../../shared/Links/url_helpers'; +import { useUiTracker } from '../../../../../observability/public'; +import { useFailedTransactionsCorrelationsFetcher } from '../../../hooks/use_failed_transactions_correlations_fetcher'; +import { SearchServiceParams } from '../../../../common/search_strategies/correlations/types'; +import { useApmParams } from '../../../hooks/use_apm_params'; + +export function FailedTransactionsCorrelations() { + const { + core: { notifications, uiSettings }, + } = useApmPluginContext(); + const trackApmEvent = useUiTracker({ app: 'apm' }); + + const { serviceName, transactionType } = useApmServiceContext(); + + const { + query: { kuery, environment }, + } = useApmParams('/services/:serviceName'); + + const { urlParams } = useUrlParams(); + const { transactionName, start, end } = urlParams; + + const displayLog = uiSettings.get(enableInspectEsQueries); + + const searchServicePrams: SearchServiceParams = { + environment, + kuery, + serviceName, + transactionName, + transactionType, + start, + end, + }; + + const result = useFailedTransactionsCorrelationsFetcher(searchServicePrams); + + const { + ccsWarning, + log, + error, + isRunning, + progress, + startFetch, + cancelFetch, + } = result; + // start fetching on load + // we want this effect to execute exactly once after the component mounts + useEffect(() => { + startFetch(); + + return () => { + // cancel any running async partial request when unmounting the component + // we want this effect to execute exactly once after the component mounts + cancelFetch(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [ + selectedSignificantTerm, + setSelectedSignificantTerm, + ] = useState(null); + + const selectedTerm = useMemo(() => { + if (selectedSignificantTerm) return selectedSignificantTerm; + return result?.values && + Array.isArray(result.values) && + result.values.length > 0 + ? result?.values[0] + : undefined; + }, [selectedSignificantTerm, result]); + + const history = useHistory(); + + const failedTransactionsCorrelationsColumns: Array< + EuiBasicTableColumn + > = useMemo( + () => [ + { + width: '116px', + field: 'normalizedScore', + name: ( + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel', + { + defaultMessage: 'Score', + } + )} + + ), + render: (normalizedScore: number) => { + return ( + <> + + + ); + }, + }, + { + width: '116px', + field: 'pValue', + name: ( + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.impactLabel', + { + defaultMessage: 'Impact', + } + )} + + ), + render: getFailedTransactionsCorrelationImpactLabel, + }, + { + field: 'fieldName', + name: i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.fieldNameLabel', + { defaultMessage: 'Field name' } + ), + }, + { + field: 'key', + name: i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.fieldValueLabel', + { defaultMessage: 'Field value' } + ), + render: (fieldValue: string) => String(fieldValue).slice(0, 50), + }, + { + width: '100px', + actions: [ + { + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.filterLabel', + { defaultMessage: 'Filter' } + ), + description: i18n.translate( + 'xpack.apm.correlations.correlationsTable.filterDescription', + { defaultMessage: 'Filter by value' } + ), + icon: 'plusInCircle', + type: 'icon', + onClick: (term: FailedTransactionsCorrelationValue) => { + push(history, { + query: { + kuery: `${term.fieldName}:"${encodeURIComponent( + term.fieldValue + )}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_include_filter' }); + }, + }, + { + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.excludeLabel', + { defaultMessage: 'Exclude' } + ), + description: i18n.translate( + 'xpack.apm.correlations.correlationsTable.excludeDescription', + { defaultMessage: 'Filter out value' } + ), + icon: 'minusInCircle', + type: 'icon', + onClick: (term: FailedTransactionsCorrelationValue) => { + push(history, { + query: { + kuery: `not ${term.fieldName}:"${encodeURIComponent( + term.fieldValue + )}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_exclude_filter' }); + }, + }, + ], + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.actionsLabel', + { defaultMessage: 'Filter' } + ), + render: (_: unknown, term: FailedTransactionsCorrelationValue) => { + return ( + <> + + + +  /  + + + + + ); + }, + }, + ], + [history, trackApmEvent] + ); + + useEffect(() => { + if (isErrorMessage(error)) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.correlations.failedTransactions.errorTitle', + { + defaultMessage: + 'An error occurred performing correlations on failed transactions', + } + ), + text: error.toString(), + }); + } + }, [error, notifications.toasts]); + return ( + <> + + + +
+ {i18n.translate( + 'xpack.apm.correlations.failedTransactions.panelTitle', + { + defaultMessage: 'Failed transactions', + } + )} +
+
+
+ + + + +
+ + + + {!isRunning && ( + + + + )} + {isRunning && ( + + + + )} + + + + + + + + + + + + + + + + + + {selectedTerm?.pValue != null ? ( + <> + + + {`${selectedTerm.fieldName}: ${selectedTerm.key}`} + , + <>{`p-value: ${selectedTerm.pValue.toPrecision(3)}`}, + ]} + /> + + + ) : null} + + columns={failedTransactionsCorrelationsColumns} + significantTerms={result?.values} + status={FETCH_STATUS.SUCCESS} + setSelectedSignificantTerm={setSelectedSignificantTerm} + selectedTerm={selectedTerm} + /> + + {ccsWarning && ( + <> + + +

+ {i18n.translate( + 'xpack.apm.correlations.failedTransactions.ccsWarningCalloutBody', + { + defaultMessage: + 'Data for the correlation analysis could not be fully retrieved. This feature is supported only for 7.15 and later versions.', + } + )} +

+
+ + )} + + + {log.length > 0 && displayLog && ( + + + {log.map((d, i) => { + const splitItem = d.split(': '); + return ( +

+ + {splitItem[0]} {splitItem[1]} + +

+ ); + })} +
+
+ )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations_help_popover.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations_help_popover.tsx new file mode 100644 index 0000000000000..bebc889cc4ed9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations_help_popover.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { HelpPopover, HelpPopoverButton } from '../help_popover/help_popover'; + +export function FailedTransactionsCorrelationsHelpPopover() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + { + setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen); + }} + /> + } + closePopover={() => setIsPopoverOpen(false)} + isOpen={isPopoverOpen} + title={i18n.translate('xpack.apm.correlations.failurePopoverTitle', { + defaultMessage: 'Failure correlations', + })} + > +

+ +

+

+ +

+

+ +

+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/index.tsx b/x-pack/plugins/apm/public/components/app/correlations/index.tsx deleted file mode 100644 index 57ba75d945ee5..0000000000000 --- a/x-pack/plugins/apm/public/components/app/correlations/index.tsx +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState } from 'react'; -import { - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiPortal, - EuiCode, - EuiLink, - EuiCallOut, - EuiButton, - EuiTab, - EuiTabs, - EuiSpacer, - EuiBetaBadge, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useHistory } from 'react-router-dom'; -import { MlLatencyCorrelations } from './ml_latency_correlations'; -import { ErrorCorrelations } from './error_correlations'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { createHref } from '../../shared/Links/url_helpers'; -import { - METRIC_TYPE, - useTrackMetric, -} from '../../../../../observability/public'; -import { isActivePlatinumLicense } from '../../../../common/license_check'; -import { useLicenseContext } from '../../../context/license/use_license_context'; -import { LicensePrompt } from '../../shared/license_prompt'; -import { IUrlParams } from '../../../context/url_params_context/types'; -import { - IStickyProperty, - StickyProperties, -} from '../../shared/sticky_properties'; -import { getEnvironmentLabel } from '../../../../common/environment_filter_values'; -import { - SERVICE_ENVIRONMENT, - SERVICE_NAME, - TRANSACTION_NAME, -} from '../../../../common/elasticsearch_fieldnames'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useApmParams } from '../../../hooks/use_apm_params'; - -const errorRateTab = { - key: 'errorRate', - label: i18n.translate('xpack.apm.correlations.tabs.errorRateLabel', { - defaultMessage: 'Failed transaction rate', - }), - component: ErrorCorrelations, -}; -const latencyCorrelationsTab = { - key: 'latencyCorrelations', - label: i18n.translate('xpack.apm.correlations.tabs.latencyLabel', { - defaultMessage: 'Latency', - }), - component: MlLatencyCorrelations, -}; -const tabs = [latencyCorrelationsTab, errorRateTab]; - -export function Correlations() { - const license = useLicenseContext(); - const hasActivePlatinumLicense = isActivePlatinumLicense(license); - const { urlParams } = useUrlParams(); - const { serviceName } = useApmServiceContext(); - - const { - query: { environment }, - } = useApmParams('/services/:serviceName'); - - const history = useHistory(); - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [currentTab, setCurrentTab] = useState(latencyCorrelationsTab.key); - const { component: TabContent } = - tabs.find((tab) => tab.key === currentTab) ?? latencyCorrelationsTab; - const metric = { - app: 'apm' as const, - metric: hasActivePlatinumLicense - ? 'correlations_flyout_view' - : 'correlations_license_prompt', - metricType: METRIC_TYPE.COUNT as METRIC_TYPE.COUNT, - }; - useTrackMetric(metric); - useTrackMetric({ ...metric, delay: 15000 }); - - const stickyProperties: IStickyProperty[] = useMemo(() => { - const properties: IStickyProperty[] = []; - if (serviceName !== undefined) { - properties.push({ - label: i18n.translate('xpack.apm.correlations.serviceLabel', { - defaultMessage: 'Service', - }), - fieldName: SERVICE_NAME, - val: serviceName, - width: '20%', - }); - } - - properties.push({ - label: i18n.translate('xpack.apm.correlations.environmentLabel', { - defaultMessage: 'Environment', - }), - fieldName: SERVICE_ENVIRONMENT, - val: getEnvironmentLabel(environment), - width: '20%', - }); - - if (urlParams.transactionName) { - properties.push({ - label: i18n.translate('xpack.apm.correlations.transactionLabel', { - defaultMessage: 'Transaction', - }), - fieldName: TRANSACTION_NAME, - val: urlParams.transactionName, - width: '20%', - }); - } - - return properties; - }, [serviceName, environment, urlParams.transactionName]); - - return ( - <> - { - setIsFlyoutVisible(true); - }} - > - {i18n.translate('xpack.apm.correlations.buttonLabel', { - defaultMessage: 'View correlations', - })} - - - {isFlyoutVisible && ( - - setIsFlyoutVisible(false)} - > - - -

- {CORRELATIONS_TITLE} -   - -

-
- {hasActivePlatinumLicense && ( - <> - - - - {urlParams.kuery ? ( - <> - - - - ) : ( - - )} - - {tabs.map(({ key, label }) => ( - { - setCurrentTab(key); - }} - > - {label} - - ))} - - - )} -
- - {hasActivePlatinumLicense ? ( - <> - setIsFlyoutVisible(false)} /> - - ) : ( - - )} - -
-
- )} - - ); -} - -function Filters({ - urlParams, - history, -}: { - urlParams: IUrlParams; - history: ReturnType; -}) { - if (!urlParams.kuery) { - return null; - } - - return ( - - - {i18n.translate('xpack.apm.correlations.filteringByLabel', { - defaultMessage: 'Filtering by', - })} - - {urlParams.kuery} - - - {i18n.translate('xpack.apm.correlations.clearFiltersLabel', { - defaultMessage: 'Clear', - })} - - - - ); -} - -const CORRELATIONS_TITLE = i18n.translate('xpack.apm.correlations.title', { - defaultMessage: 'Correlations', -}); diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 0871447337780..bcf4d21baefd9 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -5,338 +5,419 @@ * 2.0. */ +import React, { useEffect, useMemo, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { - ScaleType, - Chart, - Axis, - BarSeries, - Position, - Settings, -} from '@elastic/charts'; -import React, { useState } from 'react'; -import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + EuiCallOut, + EuiCode, + EuiAccordion, + EuiPanel, + EuiIcon, + EuiBasicTableColumn, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { getDurationFormatter } from '../../../../common/utils/formatters'; +import { FormattedMessage } from '@kbn/i18n/react'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { useTransactionLatencyCorrelationsFetcher } from '../../../hooks/use_transaction_latency_correlations_fetcher'; +import { TransactionDistributionChart } from '../../shared/charts/transaction_distribution_chart'; +import { CorrelationsTable } from './correlations_table'; +import { push } from '../../shared/Links/url_helpers'; import { - CorrelationsTable, - SelectedSignificantTerm, -} from './correlations_table'; -import { ChartContainer } from '../../shared/charts/chart_container'; -import { useTheme } from '../../../hooks/use_theme'; -import { CustomFields, PercentileOption } from './custom_fields'; -import { useFieldNames } from './use_field_names'; -import { useLocalStorage } from '../../../hooks/useLocalStorage'; -import { useUiTracker } from '../../../../../observability/public'; + enableInspectEsQueries, + useUiTracker, +} from '../../../../../observability/public'; +import { asPreciseDecimal } from '../../../../common/utils/formatters'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { LatencyCorrelationsHelpPopover } from './latency_correlations_help_popover'; import { useApmParams } from '../../../hooks/use_apm_params'; +import { isErrorMessage } from './utils/is_error_message'; -type OverallLatencyApiResponse = NonNullable< - APIReturnType<'GET /api/apm/correlations/latency/overall_distribution'> ->; +const DEFAULT_PERCENTILE_THRESHOLD = 95; -type CorrelationsApiResponse = NonNullable< - APIReturnType<'GET /api/apm/correlations/latency/slow_transactions'> ->; - -interface Props { - onClose: () => void; +interface MlCorrelationsTerms { + correlation: number; + ksTest: number; + fieldName: string; + fieldValue: string; + duplicatedFields?: string[]; } -export function LatencyCorrelations({ onClose }: Props) { - const [ - selectedSignificantTerm, - setSelectedSignificantTerm, - ] = useState(null); +export function LatencyCorrelations() { + const { + core: { notifications, uiSettings }, + } = useApmPluginContext(); - const { serviceName } = useApmServiceContext(); + const { serviceName, transactionType } = useApmServiceContext(); const { query: { kuery, environment }, } = useApmParams('/services/:serviceName'); const { urlParams } = useUrlParams(); - const { transactionName, transactionType, start, end } = urlParams; - const { defaultFieldNames } = useFieldNames(); - const [fieldNames, setFieldNames] = useLocalStorage( - `apm.correlations.latency.fields:${serviceName}`, - defaultFieldNames - ); - const hasFieldNames = fieldNames.length > 0; + + const { transactionName, start, end } = urlParams; + + const displayLog = uiSettings.get(enableInspectEsQueries); + + const { + ccsWarning, + log, + error, + histograms, + percentileThresholdValue, + isRunning, + progress, + startFetch, + cancelFetch, + overallHistogram, + } = useTransactionLatencyCorrelationsFetcher({ + environment, + kuery, + serviceName, + transactionName, + transactionType, + start, + end, + percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, + }); + + // start fetching on load + // we want this effect to execute exactly once after the component mounts + useEffect(() => { + startFetch(); + + return () => { + // cancel any running async partial request when unmounting the component + // we want this effect to execute exactly once after the component mounts + cancelFetch(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isErrorMessage(error)) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.errorTitle', + { + defaultMessage: 'An error occurred fetching correlations', + } + ), + text: error.toString(), + }); + } + }, [error, notifications.toasts]); const [ - durationPercentile, - setDurationPercentile, - ] = useLocalStorage( - `apm.correlations.latency.threshold:${serviceName}`, - 75 - ); + selectedSignificantTerm, + setSelectedSignificantTerm, + ] = useState(null); - const { data: overallData, status: overallStatus } = useFetcher( - (callApmApi) => { - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/correlations/latency/overall_distribution', - params: { - query: { - environment, - kuery, - serviceName, - transactionName, - transactionType, - start, - end, - }, - }, - }); - } - }, - [ - environment, - kuery, - serviceName, - start, - end, - transactionName, - transactionType, - ] - ); + let selectedHistogram = histograms.length > 0 ? histograms[0] : undefined; - const maxLatency = overallData?.maxLatency; - const distributionInterval = overallData?.distributionInterval; - const fieldNamesCommaSeparated = fieldNames.join(','); + if (histograms.length > 0 && selectedSignificantTerm !== null) { + selectedHistogram = histograms.find( + (h) => + h.field === selectedSignificantTerm.fieldName && + h.value === selectedSignificantTerm.fieldValue + ); + } + const history = useHistory(); + const trackApmEvent = useUiTracker({ app: 'apm' }); - const { data: correlationsData, status: correlationsStatus } = useFetcher( - (callApmApi) => { - if (start && end && hasFieldNames && maxLatency && distributionInterval) { - return callApmApi({ - endpoint: 'GET /api/apm/correlations/latency/slow_transactions', - params: { - query: { - environment, - kuery, - serviceName, - transactionName, - transactionType, - start, - end, - durationPercentile: durationPercentile.toString(10), - fieldNames: fieldNamesCommaSeparated, - maxLatency: maxLatency.toString(10), - distributionInterval: distributionInterval.toString(10), + const mlCorrelationColumns: Array< + EuiBasicTableColumn + > = useMemo( + () => [ + { + width: '116px', + field: 'correlation', + name: ( + + <> + {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel', + { + defaultMessage: 'Correlation', + } + )} + + + + ), + render: (correlation: number) => { + return
{asPreciseDecimal(correlation, 2)}
; + }, + }, + { + field: 'fieldName', + name: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldNameLabel', + { defaultMessage: 'Field name' } + ), + }, + { + field: 'fieldValue', + name: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldValueLabel', + { defaultMessage: 'Field value' } + ), + render: (fieldValue: string) => String(fieldValue).slice(0, 50), + }, + { + width: '100px', + actions: [ + { + name: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.filterLabel', + { defaultMessage: 'Filter' } + ), + description: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.filterDescription', + { defaultMessage: 'Filter by value' } + ), + icon: 'plusInCircle', + type: 'icon', + onClick: (term: MlCorrelationsTerms) => { + push(history, { + query: { + kuery: `${term.fieldName}:"${encodeURIComponent( + term.fieldValue + )}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_include_filter' }); + }, + }, + { + name: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.excludeLabel', + { defaultMessage: 'Exclude' } + ), + description: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.excludeDescription', + { defaultMessage: 'Filter out value' } + ), + icon: 'minusInCircle', + type: 'icon', + onClick: (term: MlCorrelationsTerms) => { + push(history, { + query: { + kuery: `not ${term.fieldName}:"${encodeURIComponent( + term.fieldValue + )}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_exclude_filter' }); }, }, - }); - } - }, - [ - environment, - kuery, - serviceName, - start, - end, - transactionName, - transactionType, - durationPercentile, - fieldNamesCommaSeparated, - hasFieldNames, - maxLatency, - distributionInterval, - ] + ], + name: i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel', + { defaultMessage: 'Filter' } + ), + }, + ], + [history, trackApmEvent] ); - const trackApmEvent = useUiTracker({ app: 'apm' }); - trackApmEvent({ metric: 'view_latency_correlations' }); + const histogramTerms: MlCorrelationsTerms[] = useMemo(() => { + return histograms.map((d) => { + return { + fieldName: d.field, + fieldValue: d.value, + ksTest: d.ksTest, + correlation: d.correlation, + duplicatedFields: d.duplicatedFields, + }; + }); + }, [histograms]); return ( <> - - - -

- {i18n.translate('xpack.apm.correlations.latency.description', { - defaultMessage: - 'What is slowing down my service? Correlations will help discover a slower performance in a particular cohort of your data. Either by host, version, or other custom fields.', - })} -

-
-
- - - - -

- {i18n.translate( - 'xpack.apm.correlations.latency.chart.title', - { defaultMessage: 'Latency distribution' } - )} -

-
- + + +
+ {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.panelTitle', + { + defaultMessage: 'Latency distribution', } - status={overallStatus} - selectedSignificantTerm={selectedSignificantTerm} - /> - - - - - - - - + )} +
+
- - ); -} -function getAxisMaxes(data?: OverallLatencyApiResponse) { - if (!data?.overallDistribution) { - return { xMax: 0, yMax: 0 }; - } - const { overallDistribution } = data; - const xValues = overallDistribution.map((p) => p.x ?? 0); - const yValues = overallDistribution.map((p) => p.y ?? 0); - return { - xMax: Math.max(...xValues), - yMax: Math.max(...yValues), - }; -} + -function getSelectedDistribution( - significantTerms: CorrelationsApiResponse['significantTerms'], - selectedSignificantTerm: SelectedSignificantTerm -) { - if (!significantTerms) { - return []; - } - return ( - significantTerms.find( - ({ fieldName, fieldValue }) => - selectedSignificantTerm.fieldName === fieldName && - selectedSignificantTerm.fieldValue === fieldValue - )?.distribution || [] - ); -} + -function LatencyDistributionChart({ - overallData, - correlationsData, - selectedSignificantTerm, - status, -}: { - overallData?: OverallLatencyApiResponse; - correlationsData?: CorrelationsApiResponse['significantTerms']; - selectedSignificantTerm: SelectedSignificantTerm | null; - status: FETCH_STATUS; -}) { - const theme = useTheme(); - const { xMax, yMax } = getAxisMaxes(overallData); - const durationFormatter = getDurationFormatter(xMax); + + + + {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.tableTitle', + { + defaultMessage: 'Correlations', + } + )} + + - return ( - - - { - const start = durationFormatter(obj.value); - const end = durationFormatter( - obj.value + overallData?.distributionInterval - ); + - return `${start.value} - ${end.formatted}`; - }, - }} - /> - durationFormatter(d).formatted} - /> - `${d}%`} - domain={{ min: 0, max: yMax }} - /> - - + + {!isRunning && ( + + + )} - xScaleType={ScaleType.Linear} - yScaleType={ScaleType.Linear} - xAccessor={'x'} - yAccessors={['y']} - color={theme.eui.euiColorVis1} - data={overallData?.overallDistribution || []} - minBarHeight={5} - tickFormat={(d) => `${roundFloat(d)}%`} - /> - - {correlationsData && selectedSignificantTerm ? ( - + + + )} + + + + + + + + + + + + + + + + +
+ {ccsWarning && ( + <> + + `${roundFloat(d)}%`} + color="warning" + > +

+ {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.ccsWarningCalloutBody', + { + defaultMessage: + 'Data for the correlation analysis could not be fully retrieved. This feature is supported only for 7.14 and later versions.', + } + )} +

+
+ + )} + +
+ {histograms.length > 0 && selectedHistogram !== undefined && ( + + columns={mlCorrelationColumns} + significantTerms={histogramTerms} + status={FETCH_STATUS.SUCCESS} + setSelectedSignificantTerm={setSelectedSignificantTerm} + selectedTerm={{ + fieldName: selectedHistogram.field, + fieldValue: selectedHistogram.value, + }} /> + )} + {histograms.length < 1 && progress > 0.99 ? ( + <> + + + + + ) : null} - - +
+ {log.length > 0 && displayLog && ( + + + {log.map((d, i) => { + const splitItem = d.split(': '); + return ( +

+ + {splitItem[0]} {splitItem[1]} + +

+ ); + })} +
+
+ )} + ); } - -function roundFloat(n: number, digits = 2) { - const factor = Math.pow(10, digits); - return Math.round(n * factor) / factor; -} diff --git a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations_help_popover.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations_help_popover.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations_help_popover.tsx rename to x-pack/plugins/apm/public/components/app/correlations/latency_correlations_help_popover.tsx diff --git a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx deleted file mode 100644 index bbd6648ccaf6e..0000000000000 --- a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx +++ /dev/null @@ -1,430 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useEffect, useMemo, useState } from 'react'; -import { useHistory } from 'react-router-dom'; -import { - EuiCallOut, - EuiCode, - EuiAccordion, - EuiPanel, - EuiIcon, - EuiBasicTableColumn, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiProgress, - EuiSpacer, - EuiText, - EuiTitle, - EuiToolTip, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import { - CorrelationsChart, - replaceHistogramDotsWithBars, -} from './correlations_chart'; -import { - CorrelationsTable, - SelectedSignificantTerm, -} from './correlations_table'; -import { useCorrelations } from './use_correlations'; -import { push } from '../../shared/Links/url_helpers'; -import { - enableInspectEsQueries, - useUiTracker, -} from '../../../../../observability/public'; -import { asPreciseDecimal } from '../../../../common/utils/formatters'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { LatencyCorrelationsHelpPopover } from './ml_latency_correlations_help_popover'; - -const DEFAULT_PERCENTILE_THRESHOLD = 95; -const isErrorMessage = (arg: unknown): arg is Error => { - return arg instanceof Error; -}; - -interface Props { - onClose: () => void; -} - -interface MlCorrelationsTerms { - correlation: number; - ksTest: number; - fieldName: string; - fieldValue: string; - duplicatedFields?: string[]; -} - -export function MlLatencyCorrelations({ onClose }: Props) { - const { - core: { notifications, uiSettings }, - } = useApmPluginContext(); - - const { serviceName, transactionType } = useApmServiceContext(); - const { urlParams } = useUrlParams(); - - const { environment, kuery, transactionName, start, end } = urlParams; - - const displayLog = uiSettings.get(enableInspectEsQueries); - - const { - ccsWarning, - log, - error, - histograms, - percentileThresholdValue, - isRunning, - progress, - startFetch, - cancelFetch, - overallHistogram: originalOverallHistogram, - } = useCorrelations({ - ...{ - ...{ - environment, - kuery, - serviceName, - transactionName, - transactionType, - start, - end, - }, - percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, - }, - }); - - const overallHistogram = useMemo( - () => replaceHistogramDotsWithBars(originalOverallHistogram), - [originalOverallHistogram] - ); - - // start fetching on load - // we want this effect to execute exactly once after the component mounts - useEffect(() => { - startFetch(); - - return () => { - // cancel any running async partial request when unmounting the component - // we want this effect to execute exactly once after the component mounts - cancelFetch(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (isErrorMessage(error)) { - notifications.toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.errorTitle', - { - defaultMessage: 'An error occurred fetching correlations', - } - ), - text: error.toString(), - }); - } - }, [error, notifications.toasts]); - - const [ - selectedSignificantTerm, - setSelectedSignificantTerm, - ] = useState(null); - - let selectedHistogram = histograms.length > 0 ? histograms[0] : undefined; - - if (histograms.length > 0 && selectedSignificantTerm !== null) { - selectedHistogram = histograms.find( - (h) => - h.field === selectedSignificantTerm.fieldName && - h.value === selectedSignificantTerm.fieldValue - ); - } - const history = useHistory(); - const trackApmEvent = useUiTracker({ app: 'apm' }); - - const mlCorrelationColumns: Array< - EuiBasicTableColumn - > = useMemo( - () => [ - { - width: '116px', - field: 'correlation', - name: ( - - <> - {i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel', - { - defaultMessage: 'Correlation', - } - )} - - - - ), - render: (correlation: number) => { - return
{asPreciseDecimal(correlation, 2)}
; - }, - }, - { - field: 'fieldName', - name: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldNameLabel', - { defaultMessage: 'Field name' } - ), - }, - { - field: 'fieldValue', - name: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldValueLabel', - { defaultMessage: 'Field value' } - ), - render: (fieldValue: string) => String(fieldValue).slice(0, 50), - }, - { - width: '100px', - actions: [ - { - name: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.filterLabel', - { defaultMessage: 'Filter' } - ), - description: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.filterDescription', - { defaultMessage: 'Filter by value' } - ), - icon: 'plusInCircle', - type: 'icon', - onClick: (term: MlCorrelationsTerms) => { - push(history, { - query: { - kuery: `${term.fieldName}:"${encodeURIComponent( - term.fieldValue - )}"`, - }, - }); - onClose(); - trackApmEvent({ metric: 'correlations_term_include_filter' }); - }, - }, - { - name: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.excludeLabel', - { defaultMessage: 'Exclude' } - ), - description: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.excludeDescription', - { defaultMessage: 'Filter out value' } - ), - icon: 'minusInCircle', - type: 'icon', - onClick: (term: MlCorrelationsTerms) => { - push(history, { - query: { - kuery: `not ${term.fieldName}:"${encodeURIComponent( - term.fieldValue - )}"`, - }, - }); - onClose(); - trackApmEvent({ metric: 'correlations_term_exclude_filter' }); - }, - }, - ], - name: i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel', - { defaultMessage: 'Filter' } - ), - }, - ], - [history, onClose, trackApmEvent] - ); - - const histogramTerms: MlCorrelationsTerms[] = useMemo(() => { - return histograms.map((d) => { - return { - fieldName: d.field, - fieldValue: d.value, - ksTest: d.ksTest, - correlation: d.correlation, - duplicatedFields: d.duplicatedFields, - }; - }); - }, [histograms]); - - return ( - <> - - - {!isRunning && ( - - - - )} - {isRunning && ( - - - - )} - - - - - - - - - - - - - - - - - - - {ccsWarning && ( - <> - - -

- {i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.ccsWarningCalloutBody', - { - defaultMessage: - 'Data for the correlation analysis could not be fully retrieved. This feature is supported only for 7.14 and later versions.', - } - )} -

-
- - )} - - - -

- {i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.chartTitle', - { - defaultMessage: 'Latency distribution for {name} (Log-Log Plot)', - values: { - name: transactionName ?? serviceName, - }, - } - )} -

-
- - - - - -
- {histograms.length > 0 && selectedHistogram !== undefined && ( - - )} - {histograms.length < 1 && progress > 0.99 ? ( - <> - - - - - - ) : null} -
- {log.length > 0 && displayLog && ( - - - {log.map((d, i) => { - const splitItem = d.split(': '); - return ( -

- - {splitItem[0]} {splitItem[1]} - -

- ); - })} -
-
- )} - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_correlations.ts deleted file mode 100644 index 05cb367a9fde7..0000000000000 --- a/x-pack/plugins/apm/public/components/app/correlations/use_correlations.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useRef, useState } from 'react'; -import type { Subscription } from 'rxjs'; -import { - IKibanaSearchRequest, - IKibanaSearchResponse, - isCompleteResponse, - isErrorResponse, -} from '../../../../../../../src/plugins/data/public'; -import type { - HistogramItem, - SearchServiceValue, -} from '../../../../common/search_strategies/correlations/types'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { ApmPluginStartDeps } from '../../../plugin'; - -interface CorrelationsOptions { - environment?: string; - kuery?: string; - serviceName?: string; - transactionName?: string; - transactionType?: string; - start?: string; - end?: string; -} - -interface RawResponse { - percentileThresholdValue?: number; - took: number; - values: SearchServiceValue[]; - overallHistogram: HistogramItem[]; - log: string[]; - ccsWarning: boolean; -} - -export const useCorrelations = (params: CorrelationsOptions) => { - const { - services: { data }, - } = useKibana(); - - const [error, setError] = useState(); - const [isComplete, setIsComplete] = useState(false); - const [isRunning, setIsRunning] = useState(false); - const [loaded, setLoaded] = useState(0); - const [rawResponse, setRawResponse] = useState(); - const [timeTook, setTimeTook] = useState(); - const [total, setTotal] = useState(100); - const abortCtrl = useRef(new AbortController()); - const searchSubscription$ = useRef(); - - function setResponse(response: IKibanaSearchResponse) { - // @TODO: optimize rawResponse.overallHistogram if histogram is the same - setIsRunning(response.isRunning || false); - setRawResponse(response.rawResponse); - setLoaded(response.loaded!); - setTotal(response.total!); - setTimeTook(response.rawResponse.took); - } - - const startFetch = () => { - setError(undefined); - setIsComplete(false); - searchSubscription$.current?.unsubscribe(); - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - - const req = { params }; - - // Submit the search request using the `data.search` service. - searchSubscription$.current = data.search - .search>(req, { - strategy: 'apmCorrelationsSearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (res: IKibanaSearchResponse) => { - setResponse(res); - if (isCompleteResponse(res)) { - searchSubscription$.current?.unsubscribe(); - setIsRunning(false); - setIsComplete(true); - } else if (isErrorResponse(res)) { - searchSubscription$.current?.unsubscribe(); - setError((res as unknown) as Error); - setIsRunning(false); - } - }, - error: (e: Error) => { - setError(e); - setIsRunning(false); - }, - }); - }; - - const cancelFetch = () => { - searchSubscription$.current?.unsubscribe(); - searchSubscription$.current = undefined; - abortCtrl.current.abort(); - setIsRunning(false); - }; - - return { - ccsWarning: rawResponse?.ccsWarning ?? false, - log: rawResponse?.log ?? [], - error, - histograms: rawResponse?.values ?? [], - percentileThresholdValue: - rawResponse?.percentileThresholdValue ?? undefined, - overallHistogram: rawResponse?.overallHistogram, - isComplete, - isRunning, - progress: loaded / total, - timeTook, - startFetch, - cancelFetch, - }; -}; diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts new file mode 100644 index 0000000000000..d133ed1060ebe --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFailedTransactionsCorrelationImpactLabel } from './get_failed_transactions_correlation_impact_label'; +import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from '../../../../../common/search_strategies/failure_correlations/constants'; + +describe('getFailedTransactionsCorrelationImpactLabel', () => { + it('returns null if value is invalid ', () => { + expect(getFailedTransactionsCorrelationImpactLabel(-0.03)).toBe(null); + expect(getFailedTransactionsCorrelationImpactLabel(NaN)).toBe(null); + expect(getFailedTransactionsCorrelationImpactLabel(Infinity)).toBe(null); + }); + + it('returns null if value is greater than or equal to the threshold ', () => { + expect(getFailedTransactionsCorrelationImpactLabel(0.02)).toBe(null); + expect(getFailedTransactionsCorrelationImpactLabel(0.1)).toBe(null); + }); + + it('returns High if value is within [0, 1e-6) ', () => { + expect(getFailedTransactionsCorrelationImpactLabel(0)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH + ); + expect(getFailedTransactionsCorrelationImpactLabel(1e-7)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH + ); + }); + + it('returns Medium if value is within [1e-6, 1e-3) ', () => { + expect(getFailedTransactionsCorrelationImpactLabel(1e-6)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM + ); + expect(getFailedTransactionsCorrelationImpactLabel(1e-5)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM + ); + expect(getFailedTransactionsCorrelationImpactLabel(1e-4)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM + ); + }); + + it('returns Low if value is within [1e-3, 0.02) ', () => { + expect(getFailedTransactionsCorrelationImpactLabel(1e-3)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW + ); + expect(getFailedTransactionsCorrelationImpactLabel(0.009)).toBe( + FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW + ); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts new file mode 100644 index 0000000000000..af64c50617019 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FailureCorrelationImpactThreshold } from '../../../../../common/search_strategies/failure_correlations/types'; +import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from '../../../../../common/search_strategies/failure_correlations/constants'; + +export function getFailedTransactionsCorrelationImpactLabel( + pValue: number +): FailureCorrelationImpactThreshold | null { + // The lower the p value, the higher the impact + if (pValue >= 0 && pValue < 1e-6) + return FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH; + if (pValue >= 1e-6 && pValue < 0.001) + return FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM; + if (pValue >= 0.001 && pValue < 0.02) + return FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW; + + return null; +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/is_error_message.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/is_error_message.ts new file mode 100644 index 0000000000000..06eb75d6b3314 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/is_error_message.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const isErrorMessage = (arg: unknown): arg is Error => { + return arg instanceof Error; +}; diff --git a/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx b/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx index 1488c4773359c..bffe8997684a2 100644 --- a/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_dependencies/index.tsx @@ -31,7 +31,7 @@ export function ServiceDependencies() { - + ); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index a5774a6fdbe95..a3ad01b4442ed 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -16,8 +16,6 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { useApmParams } from '../../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; -import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout'; import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; import { SearchBar } from '../../shared/search_bar'; import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; @@ -159,9 +157,6 @@ export function ServiceInventory() { query: { environment, kuery }, } = useApmParams('/services'); - const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ - kuery, - }); const { mainStatisticsData, mainStatisticsStatus, @@ -197,11 +192,6 @@ export function ServiceInventory() { setUserHasDismissedCallout(true)} /> )} - {fallbackToTransactions && ( - - - - )} ; type Items = ServiceListAPIResponse['items']; @@ -237,6 +239,11 @@ export function ServiceList({ const { query } = useApmParams('/services'); + const { kuery } = query; + const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ + kuery, + }); + const serviceColumns = useMemo( () => getServiceColumns({ @@ -256,14 +263,18 @@ export function ServiceList({ : 'transactionsPerMinute'; return ( - + - + + {fallbackToTransactions && ( + + + + )} { beforeAll(() => { mockMoment(); + + const callApmApiSpy = getCallApmApiSpy().mockImplementation( + ({ endpoint }) => { + if (endpoint === 'GET /api/apm/fallback_to_transactions') { + return Promise.resolve({ fallbackToTransactions: false }); + } + return Promise.reject(`Response for ${endpoint} is not defined`); + } + ); + + getCreateCallApmApiSpy().mockImplementation(() => callApmApiSpy as any); }); it('renders empty state', () => { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index a9fdd11805840..601aba269112c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React from 'react'; import { isRumAgentName, isIosAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; @@ -22,7 +23,8 @@ import { ServiceOverviewThroughputChart } from './service_overview_throughput_ch import { TransactionsTable } from '../../shared/transactions_table'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; -import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout'; +import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; +import { useApmRouter } from '../../../hooks/use_apm_router'; /** * The height a chart should be if it's next to a table with 5 rows and a title. @@ -33,6 +35,7 @@ export const chartHeight = 288; export function ServiceOverview() { const { agentName, serviceName } = useApmServiceContext(); const { + query, query: { environment, kuery }, } = useApmParams('/services/:serviceName/overview'); const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ @@ -46,6 +49,14 @@ export function ServiceOverview() { const isRumAgent = isRumAgentName(agentName); const isIosAgent = isIosAgentName(agentName); + const router = useApmRouter(); + const dependenciesLink = router.link('/services/:serviceName/dependencies', { + path: { + serviceName, + }, + query, + }); + return ( {fallbackToTransactions && ( - + )} @@ -82,7 +93,11 @@ export function ServiceOverview() { - + @@ -126,7 +141,17 @@ export function ServiceOverview() { {!isRumAgent && ( - + + {i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableTabLink', + { defaultMessage: 'View dependencies' } + )} + + } + /> )} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 642725727eedf..a589ffebd8ecc 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import { EuiLink } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; import { METRIC_TYPE } from '@kbn/analytics'; +import { i18n } from '@kbn/i18n'; +import React, { ReactNode } from 'react'; import { useUiTracker } from '../../../../../../observability/public'; -import { useApmRouter } from '../../../../hooks/use_apm_router'; import { getNodeName, NodeType } from '../../../../../common/connections'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; @@ -21,7 +19,15 @@ import { DependenciesTable } from '../../../shared/dependencies_table'; import { ServiceLink } from '../../../shared/service_link'; import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison'; -export function ServiceOverviewDependenciesTable() { +interface ServiceOverviewDependenciesTableProps { + fixedHeight?: boolean; + link?: ReactNode; +} + +export function ServiceOverviewDependenciesTable({ + fixedHeight, + link, +}: ServiceOverviewDependenciesTableProps) { const { urlParams: { start, @@ -33,7 +39,6 @@ export function ServiceOverviewDependenciesTable() { } = useUrlParams(); const { - query, query: { environment, kuery, rangeFrom, rangeTo }, } = useApmParams('/services/:serviceName/*'); @@ -46,15 +51,6 @@ export function ServiceOverviewDependenciesTable() { const { serviceName, transactionType } = useApmServiceContext(); - const router = useApmRouter(); - - const dependenciesLink = router.link('/services/:serviceName/dependencies', { - path: { - serviceName, - }, - query, - }); - const trackEvent = useUiTracker(); const { data, status } = useFetcher( @@ -78,7 +74,7 @@ export function ServiceOverviewDependenciesTable() { data?.serviceDependencies.map((dependency) => { const { location } = dependency; const name = getNodeName(location); - const link = + const itemLink = location.type === NodeType.backend ? ( - {i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableTabLink', - { defaultMessage: 'View dependencies' } - )} - - } + link={link} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index 68e6873caf2f8..dc10bf413bfe9 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -203,6 +203,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { ; const DEFAULT_RESPONSE: TracesAPIResponse = { @@ -58,7 +58,7 @@ export function TraceOverview() { {fallbackToTransactions && ( - + )} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/custom_tooltip.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/custom_tooltip.tsx deleted file mode 100644 index ba007015b25f8..0000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/custom_tooltip.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { TooltipInfo } from '@elastic/charts'; -import { EuiIcon, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { TimeFormatter } from '../../../../../common/utils/formatters'; -import { useTheme } from '../../../../hooks/use_theme'; -import { formatYLong, IChartPoint } from './'; - -export function CustomTooltip( - props: TooltipInfo & { - serie?: IChartPoint; - isSamplesEmpty: boolean; - timeFormatter: TimeFormatter; - } -) { - const theme = useTheme(); - const { values, header, serie, isSamplesEmpty, timeFormatter } = props; - const { color, value } = values[0]; - - let headerTitle = `${timeFormatter(header?.value)}`; - if (serie) { - const xFormatted = timeFormatter(serie.x); - const x0Formatted = timeFormatter(serie.x0); - headerTitle = `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; - } - - return ( -
- <> -
{headerTitle}
-
-
-
-
-
-
- {formatYLong(value)} - {value} -
-
-
- - {isSamplesEmpty && ( -
- - - {i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSamplesAvailable', - { defaultMessage: 'No samples available' } - )} - -
- )} -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/distribution.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/distribution.test.ts deleted file mode 100644 index 5d6d73f36fac1..0000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/distribution.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getFormattedBuckets } from './index'; - -describe('Distribution', () => { - it('getFormattedBuckets', () => { - const buckets = [ - { key: 0, count: 0, samples: [] }, - { key: 20, count: 0, samples: [] }, - { key: 40, count: 0, samples: [] }, - { - key: 60, - count: 5, - samples: [ - { - transactionId: 'someTransactionId', - traceId: 'someTraceId', - }, - ], - }, - { - key: 80, - count: 100, - samples: [ - { - transactionId: 'anotherTransactionId', - traceId: 'anotherTraceId', - }, - ], - }, - ]; - - expect(getFormattedBuckets(buckets, 20)).toEqual([ - { x: 20, x0: 0, y: 0, style: { cursor: 'default' } }, - { x: 40, x0: 20, y: 0, style: { cursor: 'default' } }, - { x: 60, x0: 40, y: 0, style: { cursor: 'default' } }, - { - x: 80, - x0: 60, - y: 5, - style: { cursor: 'pointer' }, - }, - { - x: 100, - x0: 80, - y: 100, - style: { cursor: 'pointer' }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx deleted file mode 100644 index 4ff094c025451..0000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - Axis, - Chart, - HistogramBarSeries, - Position, - ProjectionClickListener, - RectAnnotation, - ScaleType, - Settings, - SettingsSpec, - TooltipInfo, - XYChartSeriesIdentifier, -} from '@elastic/charts'; -import { EuiIconTip, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import d3 from 'd3'; -import { isEmpty, keyBy } from 'lodash'; -import React from 'react'; -import { ValuesType } from 'utility-types'; -import { getDurationFormatter } from '../../../../../common/utils/formatters'; -import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; -import { useTheme } from '../../../../hooks/use_theme'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -import { unit } from '../../../../utils/style'; -import { ChartContainer } from '../../../shared/charts/chart_container'; -import { EmptyMessage } from '../../../shared/EmptyMessage'; -import { CustomTooltip } from './custom_tooltip'; - -type TransactionDistributionAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; - -type DistributionBucket = TransactionDistributionAPIResponse['buckets'][0]; - -export interface IChartPoint { - x0: number; - x: number; - y: number; - style: { - cursor: string; - }; -} - -export function getFormattedBuckets( - buckets?: DistributionBucket[], - bucketSize?: number -) { - if (!buckets || !bucketSize) { - return []; - } - - return buckets.map( - ({ samples, count, key }): IChartPoint => { - return { - x0: key, - x: key + bucketSize, - y: count, - style: { - cursor: isEmpty(samples) ? 'default' : 'pointer', - }, - }; - } - ); -} - -const formatYShort = (t: number) => { - return i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel', - { - defaultMessage: '{transCount} trans.', - values: { transCount: t }, - } - ); -}; - -export const formatYLong = (t: number) => { - return i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {transactions} one {transaction} other {transactions}}', - values: { - transCount: t, - }, - } - ); -}; - -interface Props { - distribution?: TransactionDistributionAPIResponse; - fetchStatus: FETCH_STATUS; - bucketIndex: number; - onBucketClick: ( - bucket: ValuesType - ) => void; -} - -export function TransactionDistribution({ - distribution, - fetchStatus, - bucketIndex, - onBucketClick, -}: Props) { - const theme = useTheme(); - - // no data in response - if ( - (!distribution || distribution.noHits) && - fetchStatus !== FETCH_STATUS.LOADING - ) { - return ( - - ); - } - - const buckets = getFormattedBuckets( - distribution?.buckets, - distribution?.bucketSize - ); - - const xMin = d3.min(buckets, (d) => d.x0) || 0; - const xMax = d3.max(buckets, (d) => d.x0) || 0; - const timeFormatter = getDurationFormatter(xMax); - - const distributionMap = keyBy(distribution?.buckets, 'key'); - const bucketsMap = keyBy(buckets, 'x0'); - - const tooltip: SettingsSpec['tooltip'] = { - stickTo: 'top', - customTooltip: (props: TooltipInfo) => { - const datum = props.header?.datum as IChartPoint; - const selectedDistribution = distributionMap[datum?.x0]; - const serie = bucketsMap[datum?.x0]; - return ( - - ); - }, - }; - - const onBarClick: ProjectionClickListener = ({ x }) => { - const clickedBucket = distribution?.buckets.find((bucket) => { - return bucket.key === x; - }); - if (clickedBucket) { - onBucketClick(clickedBucket); - } - }; - - const selectedBucket = buckets[bucketIndex]; - - return ( -
- -
- {i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle', - { - defaultMessage: 'Latency distribution', - } - )}{' '} - -
-
- - - - {selectedBucket && ( - - )} - timeFormatter(time).formatted} - /> - formatYShort(value)} - /> - value} - minBarHeight={2} - id="transactionDurationDistribution" - name={(series: XYChartSeriesIdentifier) => { - const bucketCount = series.splitAccessors.get( - series.yAccessor - ) as number; - return formatYLong(bucketCount); - }} - splitSeriesAccessors={['y']} - xScaleType={ScaleType.Linear} - yScaleType={ScaleType.Linear} - xAccessor="x0" - yAccessors={['y']} - data={buckets} - color={theme.eui.euiColorVis1} - /> - - -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx new file mode 100644 index 0000000000000..afb784dde5593 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { BrushEndListener, XYBrushArea } from '@elastic/charts'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { useTransactionDistributionFetcher } from '../../../../hooks/use_transaction_distribution_fetcher'; +import { TransactionDistributionChart } from '../../../shared/charts/transaction_distribution_chart'; +import { useUiTracker } from '../../../../../../observability/public'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { isErrorMessage } from '../../correlations/utils/is_error_message'; + +const DEFAULT_PERCENTILE_THRESHOLD = 95; + +interface Props { + markerCurrentTransaction?: number; + onChartSelection: BrushEndListener; + onClearSelection: () => void; + selection?: [number, number]; +} + +export function TransactionDistribution({ + markerCurrentTransaction, + onChartSelection, + onClearSelection, + selection, +}: Props) { + const { + core: { notifications }, + } = useApmPluginContext(); + + const { serviceName, transactionType } = useApmServiceContext(); + + const { + query: { kuery, environment }, + } = useApmParams('/services/:serviceName'); + + const { urlParams } = useUrlParams(); + + const { transactionName, start, end } = urlParams; + + const clearSelectionButtonLabel = i18n.translate( + 'xpack.apm.transactionDetails.clearSelectionButtonLabel', + { + defaultMessage: 'Clear selection', + } + ); + + const { + error, + percentileThresholdValue, + startFetch, + cancelFetch, + transactionDistribution, + } = useTransactionDistributionFetcher({ + environment, + kuery, + serviceName, + transactionName, + transactionType, + start, + end, + percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, + }); + + // start fetching on load + // we want this effect to execute exactly once after the component mounts + useEffect(() => { + startFetch(); + + return () => { + // cancel any running async partial request when unmounting the component + // we want this effect to execute exactly once after the component mounts + cancelFetch(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isErrorMessage(error)) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.transactionDetails.distribution.errorTitle', + { + defaultMessage: 'An error occurred fetching the distribution', + } + ), + text: error.toString(), + }); + } + }, [error, notifications.toasts]); + + const trackApmEvent = useUiTracker({ app: 'apm' }); + + const onTrackedChartSelection: BrushEndListener = ( + brushArea: XYBrushArea + ) => { + onChartSelection(brushArea); + trackApmEvent({ metric: 'transaction_distribution_chart_selection' }); + }; + + const onTrackedClearSelection = () => { + onClearSelection(); + trackApmEvent({ metric: 'transaction_distribution_chart_clear_selection' }); + }; + + return ( + <> + + + +
+ {i18n.translate( + 'xpack.apm.transactionDetails.distribution.panelTitle', + { + defaultMessage: 'Latency distribution', + } + )} +
+
+
+ {selection && ( + + + + + {i18n.translate( + 'xpack.apm.transactionDetails.distribution.selectionText', + { + defaultMessage: `Selection: {selectionFrom} - {selectionTo}ms`, + values: { + selectionFrom: Math.round(selection[0] / 1000), + selectionTo: Math.round(selection[1] / 1000), + }, + } + )} + + + + + {clearSelectionButtonLabel} + + + + + )} +
+ + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/failed_transactions_correlations_tab.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/failed_transactions_correlations_tab.tsx new file mode 100644 index 0000000000000..c1c74965ed27d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/failed_transactions_correlations_tab.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + METRIC_TYPE, + useTrackMetric, +} from '../../../../../observability/public'; + +import { isActivePlatinumLicense } from '../../../../common/license_check'; + +import { useLicenseContext } from '../../../context/license/use_license_context'; + +import { LicensePrompt } from '../../shared/license_prompt'; + +import { FailedTransactionsCorrelations } from '../correlations/failed_transactions_correlations'; + +import type { TabContentProps } from './types'; + +function FailedTransactionsCorrelationsTab({}: TabContentProps) { + const license = useLicenseContext(); + + const hasActivePlatinumLicense = isActivePlatinumLicense(license); + + const metric = { + app: 'apm' as const, + metric: hasActivePlatinumLicense + ? 'failed_transactions_tab_view' + : 'failed_transactions_license_prompt', + metricType: METRIC_TYPE.COUNT as METRIC_TYPE.COUNT, + }; + useTrackMetric(metric); + useTrackMetric({ ...metric, delay: 15000 }); + + return hasActivePlatinumLicense ? ( + + ) : ( + + ); +} + +export const failedTransactionsCorrelationsTab = { + dataTestSubj: 'apmFailedTransactionsCorrelationsTabButton', + key: 'failedTransactionsCorrelations', + label: ( + <> + {i18n.translate( + 'xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsLabel', + { + defaultMessage: 'Failed transaction correlations', + } + )} + + ), + component: FailedTransactionsCorrelationsTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index 143e82649facd..0c6f03047dc7d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -5,55 +5,23 @@ * 2.0. */ -import { EuiHorizontalRule, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { flatten, isEmpty } from 'lodash'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import React from 'react'; -import { useHistory } from 'react-router-dom'; import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useApmRouter } from '../../../hooks/use_apm_router'; -import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import { useTransactionDistributionFetcher } from '../../../hooks/use_transaction_distribution_fetcher'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; -import { HeightRetainer } from '../../shared/HeightRetainer'; -import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { TransactionDistribution } from './Distribution'; -import { useWaterfallFetcher } from './use_waterfall_fetcher'; -import { WaterfallWithSummary } from './waterfall_with_summary'; -interface Sample { - traceId: string; - transactionId: string; -} +import { TransactionDetailsTabs } from './transaction_details_tabs'; export function TransactionDetails() { - const { urlParams } = useUrlParams(); - const history = useHistory(); - - const { - waterfall, - exceedsMax, - status: waterfallStatus, - } = useWaterfallFetcher(); - const { path, query } = useApmParams( '/services/:serviceName/transactions/view' ); - - const apmRouter = useApmRouter(); - const { transactionName } = query; - const { - distributionData, - distributionStatus, - } = useTransactionDistributionFetcher({ - transactionName, - environment: query.environment, - kuery: query.kuery, - }); + const apmRouter = useApmRouter(); useBreadcrumb({ title: transactionName, @@ -63,36 +31,6 @@ export function TransactionDetails() { }), }); - const selectedSample = flatten( - distributionData.buckets.map((bucket) => bucket.samples) - ).find( - (sample) => - sample.transactionId === urlParams.transactionId && - sample.traceId === urlParams.traceId - ); - - const bucketWithSample = - selectedSample && - distributionData.buckets.find((bucket) => - bucket.samples.includes(selectedSample) - ); - - const traceSamples = bucketWithSample?.samples ?? []; - const bucketIndex = bucketWithSample - ? distributionData.buckets.indexOf(bucketWithSample) - : -1; - - const selectSampleFromBucketClick = (sample: Sample) => { - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - transactionId: sample.transactionId, - traceId: sample.traceId, - }), - }); - }; - return ( <> @@ -110,32 +48,9 @@ export function TransactionDetails() { /> - - - - { - if (!isEmpty(bucket.samples)) { - selectSampleFromBucketClick(bucket.samples[0]); - } - }} - /> - - - + - - - + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/latency_correlations_tab.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/latency_correlations_tab.tsx new file mode 100644 index 0000000000000..c396b6317c311 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/latency_correlations_tab.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + METRIC_TYPE, + useTrackMetric, +} from '../../../../../observability/public'; + +import { isActivePlatinumLicense } from '../../../../common/license_check'; + +import { useLicenseContext } from '../../../context/license/use_license_context'; + +import { LicensePrompt } from '../../shared/license_prompt'; + +import { LatencyCorrelations } from '../correlations/latency_correlations'; + +import type { TabContentProps } from './types'; + +function LatencyCorrelationsTab({}: TabContentProps) { + const license = useLicenseContext(); + + const hasActivePlatinumLicense = isActivePlatinumLicense(license); + + const metric = { + app: 'apm' as const, + metric: hasActivePlatinumLicense + ? 'correlations_tab_view' + : 'correlations_license_prompt', + metricType: METRIC_TYPE.COUNT as METRIC_TYPE.COUNT, + }; + useTrackMetric(metric); + useTrackMetric({ ...metric, delay: 15000 }); + + return hasActivePlatinumLicense ? ( + + ) : ( + + ); +} + +export const latencyCorrelationsTab = { + dataTestSubj: 'apmLatencyCorrelationsTabButton', + key: 'latencyCorrelations', + label: i18n.translate('xpack.apm.transactionDetails.tabs.latencyLabel', { + defaultMessage: 'Latency correlations', + }), + component: LatencyCorrelationsTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx new file mode 100644 index 0000000000000..0421fcd055d6c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/trace_samples_tab.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiSpacer } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; + +import { TransactionDistribution } from './distribution'; +import { useWaterfallFetcher } from './use_waterfall_fetcher'; +import type { TabContentProps } from './types'; +import { WaterfallWithSummary } from './waterfall_with_summary'; + +function TraceSamplesTab({ + selectSampleFromChartSelection, + clearChartSelection, + sampleRangeFrom, + sampleRangeTo, + traceSamples, +}: TabContentProps) { + const { urlParams } = useUrlParams(); + + const { + waterfall, + exceedsMax, + status: waterfallStatus, + } = useWaterfallFetcher(); + + return ( + <> + + + + + + + ); +} + +export const traceSamplesTab = { + dataTestSubj: 'apmTraceSamplesTabButton', + key: 'traceSamples', + label: i18n.translate('xpack.apm.transactionDetails.tabs.traceSamplesLabel', { + defaultMessage: 'Trace samples', + }), + component: TraceSamplesTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx new file mode 100644 index 0000000000000..8cdfd44c7581a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; + +import { omit } from 'lodash'; +import { useHistory } from 'react-router-dom'; + +import { XYBrushArea } from '@elastic/charts'; +import { EuiPanel, EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; + +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useTransactionTraceSamplesFetcher } from '../../../hooks/use_transaction_trace_samples_fetcher'; + +import { maybe } from '../../../../common/utils/maybe'; +import { HeightRetainer } from '../../shared/HeightRetainer'; +import { fromQuery, push, toQuery } from '../../shared/Links/url_helpers'; + +import { failedTransactionsCorrelationsTab } from './failed_transactions_correlations_tab'; +import { latencyCorrelationsTab } from './latency_correlations_tab'; +import { traceSamplesTab } from './trace_samples_tab'; + +const tabs = [ + traceSamplesTab, + latencyCorrelationsTab, + failedTransactionsCorrelationsTab, +]; + +export function TransactionDetailsTabs() { + const { query } = useApmParams('/services/:serviceName/transactions/view'); + + const { urlParams } = useUrlParams(); + const history = useHistory(); + + const [currentTab, setCurrentTab] = useState(traceSamplesTab.key); + const { component: TabContent } = + tabs.find((tab) => tab.key === currentTab) ?? traceSamplesTab; + + const { environment, kuery, transactionName } = query; + const { traceSamplesData } = useTransactionTraceSamplesFetcher({ + transactionName, + kuery, + environment, + }); + + const selectSampleFromChartSelection = (selection: XYBrushArea) => { + if (selection !== undefined) { + const { x } = selection; + if (Array.isArray(x)) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + sampleRangeFrom: Math.round(x[0]), + sampleRangeTo: Math.round(x[1]), + }), + }); + } + } + }; + + const { sampleRangeFrom, sampleRangeTo, transactionId, traceId } = urlParams; + const { traceSamples } = traceSamplesData; + + const clearChartSelection = () => { + // enforces a reset of the current sample to be highlighted in the chart + // and selected in waterfall section below, otherwise we end up with + // stale data for the selected sample + push(history, { + query: { + sampleRangeFrom: '', + sampleRangeTo: '', + traceId: '', + transactionId: '', + }, + }); + }; + + useEffect(() => { + const selectedSample = traceSamples.find( + (sample) => + sample.transactionId === transactionId && sample.traceId === traceId + ); + + if (!selectedSample) { + // selected sample was not found. select a new one: + const preferredSample = maybe(traceSamples[0]); + + history.replace({ + ...history.location, + search: fromQuery({ + ...omit(toQuery(history.location.search), [ + 'traceId', + 'transactionId', + ]), + ...preferredSample, + }), + }); + } + }, [history, traceSamples, transactionId, traceId]); + + return ( + <> + + {tabs.map(({ dataTestSubj, key, label }) => ( + { + setCurrentTab(key); + }} + > + {label} + + ))} + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/types.ts b/x-pack/plugins/apm/public/components/app/transaction_details/types.ts new file mode 100644 index 0000000000000..5396d5a8a538d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { XYBrushArea } from '@elastic/charts'; + +import type { TraceSample } from '../../../hooks/use_transaction_trace_samples_fetcher'; + +export interface TabContentProps { + selectSampleFromChartSelection: (selection: XYBrushArea) => void; + clearChartSelection: () => void; + sampleRangeFrom?: number; + sampleRangeTo?: number; + traceSamples: TraceSample[]; +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx index 64c4e7dcb42b9..19199cda9495e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/index.tsx @@ -10,34 +10,29 @@ import { EuiFlexGroup, EuiFlexItem, EuiPagination, - EuiPanel, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import type { IUrlParams } from '../../../../context/url_params_context/types'; import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { TransactionSummary } from '../../../shared/Summary/TransactionSummary'; import { TransactionActionMenu } from '../../../shared/transaction_action_menu/TransactionActionMenu'; +import type { TraceSample } from '../../../../hooks/use_transaction_trace_samples_fetcher'; import { MaybeViewTraceLink } from './MaybeViewTraceLink'; import { TransactionTabs } from './TransactionTabs'; import { IWaterfall } from './waterfall_container/Waterfall/waterfall_helpers/waterfall_helpers'; import { useApmParams } from '../../../../hooks/use_apm_params'; -type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; - -type DistributionBucket = DistributionApiResponse['buckets'][0]; - interface Props { urlParams: IUrlParams; waterfall: IWaterfall; exceedsMax: boolean; isLoading: boolean; - traceSamples: DistributionBucket['samples']; + traceSamples: TraceSample[]; } export function WaterfallWithSummary({ @@ -88,13 +83,13 @@ export function WaterfallWithSummary({ /> ); - return {content}; + return content; } const entryTransaction = entryWaterfallTransaction.doc; return ( - + <> @@ -142,6 +137,6 @@ export function WaterfallWithSummary({ waterfall={waterfall} exceedsMax={exceedsMax} /> - + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index e53ca324eac0a..39e317569a0ae 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -14,10 +14,11 @@ import { IUrlParams } from '../../../context/url_params_context/types'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; -import { AggregatedTransactionsCallout } from '../../shared/aggregated_transactions_callout'; +import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { TransactionsTable } from '../../shared/transactions_table'; + import { useRedirect } from './useRedirect'; function getRedirectLocation({ @@ -69,7 +70,7 @@ export function TransactionOverview() { <> - + diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index c7dd0f46cfc22..8d7d14191a851 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -16,7 +16,7 @@ import { UrlParamsProvider } from '../../../context/url_params_context/url_param import { IUrlParams } from '../../../context/url_params_context/types'; import * as useFetcherHook from '../../../hooks/use_fetcher'; import * as useServiceTransactionTypesHook from '../../../context/apm_service/use_service_transaction_types_fetcher'; -import * as useServiceAgentNameHook from '../../../context/apm_service/use_service_agent_name_fetcher'; +import * as useServiceAgentNameHook from '../../../context/apm_service/use_service_agent_fetcher'; import { disableConsoleWarning, renderWithTheme, @@ -52,9 +52,10 @@ function setup({ // mock agent jest - .spyOn(useServiceAgentNameHook, 'useServiceAgentNameFetcher') + .spyOn(useServiceAgentNameHook, 'useServiceAgentFetcher') .mockReturnValue({ agentName: 'nodejs', + runtimeName: 'node', error: undefined, status: useFetcherHook.FETCH_STATUS.SUCCESS, }); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx new file mode 100644 index 0000000000000..5a481b2d6f10c --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isMetricsTabHidden, isJVMsTabHidden } from './'; + +describe('APM service template', () => { + describe('isMetricsTabHidden', () => { + describe('hides metrics tab', () => { + [ + { agentName: undefined }, + { agentName: 'js-base' }, + { agentName: 'rum-js' }, + { agentName: 'opentelemetry/webjs' }, + { agentName: 'java' }, + { agentName: 'opentelemetry/java' }, + { agentName: 'ios/swift' }, + { agentName: 'opentelemetry/swift' }, + { agentName: 'ruby', runtimeName: 'jruby' }, + ].map((input) => { + it(`when input ${JSON.stringify(input)}`, () => { + expect(isMetricsTabHidden(input)).toBeTruthy(); + }); + }); + }); + describe('shows metrics tab', () => { + [ + { agentName: 'ruby', runtimeName: 'ruby' }, + { agentName: 'ruby' }, + { agentName: 'dotnet' }, + { agentName: 'go' }, + { agentName: 'nodejs' }, + { agentName: 'php' }, + { agentName: 'python' }, + ].map((input) => { + it(`when input ${JSON.stringify(input)}`, () => { + expect(isMetricsTabHidden(input)).toBeFalsy(); + }); + }); + }); + }); + describe('isJVMsTabHidden', () => { + describe('hides JVMs tab', () => { + [ + { agentName: undefined }, + { agentName: 'ruby', runtimeName: 'ruby' }, + { agentName: 'ruby' }, + { agentName: 'dotnet' }, + { agentName: 'go' }, + { agentName: 'nodejs' }, + { agentName: 'php' }, + { agentName: 'python' }, + ].map((input) => { + it(`when input ${JSON.stringify(input)}`, () => { + expect(isJVMsTabHidden(input)).toBeTruthy(); + }); + }); + }); + describe('shows JVMs tab', () => { + [ + { agentName: 'java' }, + { agentName: 'opentelemetry/java' }, + { agentName: 'ruby', runtimeName: 'jruby' }, + ].map((input) => { + it(`when input ${JSON.stringify(input)}`, () => { + expect(isJVMsTabHidden(input)).toBeFalsy(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index d332048338cc0..c12fdab09613c 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { isIosAgentName, isJavaAgentName, + isJRubyAgent, isRumAgentName, } from '../../../../../common/agent_name'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; @@ -26,7 +27,6 @@ import { useApmServiceContext } from '../../../../context/apm_service/use_apm_se import { useBreadcrumb } from '../../../../context/breadcrumbs/use_breadcrumb'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useApmRouter } from '../../../../hooks/use_apm_router'; -import { Correlations } from '../../../app/correlations'; import { SearchBar } from '../../../shared/search_bar'; import { ServiceIcons } from '../../../shared/service_icons'; import { ApmMainTemplate } from '../apm_main_template'; @@ -108,10 +108,6 @@ function TemplateWithContext({ - - - - ), }} @@ -123,8 +119,34 @@ function TemplateWithContext({ ); } +export function isMetricsTabHidden({ + agentName, + runtimeName, +}: { + agentName?: string; + runtimeName?: string; +}) { + return ( + !agentName || + isRumAgentName(agentName) || + isJavaAgentName(agentName) || + isIosAgentName(agentName) || + isJRubyAgent(agentName, runtimeName) + ); +} + +export function isJVMsTabHidden({ + agentName, + runtimeName, +}: { + agentName?: string; + runtimeName?: string; +}) { + return !(isJavaAgentName(agentName) || isJRubyAgent(agentName, runtimeName)); +} + function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { - const { agentName } = useApmServiceContext(); + const { agentName, runtimeName } = useApmServiceContext(); const { config } = useApmPluginContext(); const router = useApmRouter(); @@ -172,6 +194,8 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { label: i18n.translate('xpack.apm.serviceDetails.dependenciesTabLabel', { defaultMessage: 'Dependencies', }), + hidden: + !agentName || isRumAgentName(agentName) || isIosAgentName(agentName), }, { key: 'errors', @@ -192,11 +216,7 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { label: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { defaultMessage: 'Metrics', }), - hidden: - !agentName || - isRumAgentName(agentName) || - isJavaAgentName(agentName) || - isIosAgentName(agentName), + hidden: isMetricsTabHidden({ agentName, runtimeName }), }, { key: 'nodes', @@ -207,7 +227,7 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { label: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { defaultMessage: 'JVMs', }), - hidden: !isJavaAgentName(agentName), + hidden: isJVMsTabHidden({ agentName, runtimeName }), }, { key: 'service-map', diff --git a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts index 9acc04f18f187..b0cadd50b3d61 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts @@ -65,6 +65,8 @@ export function createHref( } export type APMQueryParams = { + sampleRangeFrom?: number; + sampleRangeTo?: number; transactionId?: string; transactionName?: string; transactionType?: string; diff --git a/x-pack/plugins/apm/public/components/shared/aggregated_transactions_badge/index.tsx b/x-pack/plugins/apm/public/components/shared/aggregated_transactions_badge/index.tsx new file mode 100644 index 0000000000000..69cc78f1e72c2 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/aggregated_transactions_badge/index.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function AggregatedTransactionsBadge() { + return ( +
+ + + {i18n.translate('xpack.apm.aggregatedTransactions.fallback.badge', { + defaultMessage: `Based on sampled transactions`, + })} + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/aggregated_transactions_callout/index.tsx b/x-pack/plugins/apm/public/components/shared/aggregated_transactions_callout/index.tsx deleted file mode 100644 index 71aeb54d43702..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/aggregated_transactions_callout/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiCallOut, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -export function AggregatedTransactionsCallout() { - return ( - - {i18n.translate('xpack.apm.aggregatedTransactions.callout.title', { - defaultMessage: `This page is using transaction event data as no metrics events were found in the current time range.`, - })} - - } - iconType="iInCircle" - /> - ); -} diff --git a/x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx similarity index 61% rename from x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx index e3ff631ae1a6f..51250818a2269 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/correlations_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx @@ -5,38 +5,42 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { AnnotationDomainType, - Chart, - CurveType, - Settings, - Axis, - ScaleType, - Position, AreaSeries, - RecursivePartial, + Axis, AxisStyle, - PartialTheme, + BrushEndListener, + Chart, + CurveType, LineAnnotation, LineAnnotationDatum, + PartialTheme, + Position, + RectAnnotation, + RecursivePartial, + ScaleType, + Settings, } from '@elastic/charts'; import euiVars from '@elastic/eui/dist/eui_theme_light.json'; +import { euiPaletteColorBlind } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { getDurationUnitKey, getUnitLabelAndConvertedValue, -} from '../../../../common/utils/formatters'; +} from '../../../../../common/utils/formatters'; -import { HistogramItem } from '../../../../common/search_strategies/correlations/types'; +import { HistogramItem } from '../../../../../common/search_strategies/correlations/types'; -import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import { useTheme } from '../../../hooks/use_theme'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { useTheme } from '../../../../hooks/use_theme'; -import { ChartContainer } from '../../shared/charts/chart_container'; +import { ChartContainer } from '../chart_container'; const { euiColorMediumShade } = euiVars; const axisColor = euiColorMediumShade; @@ -79,25 +83,28 @@ interface CorrelationsChartProps { field?: string; value?: string; histogram?: HistogramItem[]; + markerCurrentTransaction?: number; markerValue: number; markerPercentile: number; overallHistogram?: HistogramItem[]; + onChartSelection?: BrushEndListener; + selection?: [number, number]; } -const annotationsStyle = { +const getAnnotationsStyle = (color = 'gray') => ({ line: { strokeWidth: 1, - stroke: 'gray', + stroke: color, opacity: 0.8, }, details: { fontSize: 8, fontFamily: 'Arial', fontStyle: 'normal', - fill: 'gray', + fill: color, padding: 0, }, -}; +}); const CHART_PLACEHOLDER_VALUE = 0.0001; @@ -123,21 +130,29 @@ export const replaceHistogramDotsWithBars = ( } }; -export function CorrelationsChart({ +export function TransactionDistributionChart({ field, value, histogram: originalHistogram, + markerCurrentTransaction, markerValue, markerPercentile, overallHistogram, + onChartSelection, + selection, }: CorrelationsChartProps) { const euiTheme = useTheme(); + const patchedOverallHistogram = useMemo( + () => replaceHistogramDotsWithBars(overallHistogram), + [overallHistogram] + ); + const annotationsDataValues: LineAnnotationDatum[] = [ { dataValue: markerValue, details: i18n.translate( - 'xpack.apm.correlations.latency.chart.percentileMarkerLabel', + 'xpack.apm.transactionDistribution.chart.percentileMarkerLabel', { defaultMessage: '{markerPercentile}th percentile', values: { @@ -159,6 +174,21 @@ export function CorrelationsChart({ const histogram = replaceHistogramDotsWithBars(originalHistogram); + const selectionAnnotation = + selection !== undefined + ? [ + { + coordinates: { + x0: selection[0], + x1: selection[1], + y0: 0, + y1: 100000, + }, + details: 'selection', + }, + ] + : undefined; + return (
0} + hasData={ + Array.isArray(patchedOverallHistogram) && + patchedOverallHistogram.length > 0 + } status={ - Array.isArray(overallHistogram) + Array.isArray(patchedOverallHistogram) ? FETCH_STATUS.SUCCESS : FETCH_STATUS.LOADING } @@ -179,12 +212,51 @@ export function CorrelationsChart({ theme={chartTheme} showLegend legendPosition={Position.Bottom} + onBrushEnd={onChartSelection} /> + {selectionAnnotation !== undefined && ( + + )} + {typeof markerCurrentTransaction === 'number' && ( + + )} @@ -208,7 +280,7 @@ export function CorrelationsChart({ id="y-axis" domain={yAxisDomain} title={i18n.translate( - 'xpack.apm.correlations.latency.chart.numberOfTransactionsLabel', + 'xpack.apm.transactionDistribution.chart.numberOfTransactionsLabel', { defaultMessage: '# transactions' } )} position={Position.Left} @@ -216,12 +288,12 @@ export function CorrelationsChart({ /> {title} - {link} + {link && {link}} !startsWith(suggestion.text, 'span.')) - .slice(0, 15); + ).slice(0, 15); if (currentRequest !== currentRequestCheck) { return; diff --git a/x-pack/plugins/apm/public/components/shared/overview_table_container/index.tsx b/x-pack/plugins/apm/public/components/shared/overview_table_container/index.tsx index 303d281711aed..207fa8e1fea76 100644 --- a/x-pack/plugins/apm/public/components/shared/overview_table_container/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/overview_table_container/index.tsx @@ -25,11 +25,12 @@ const tableHeight = 282; * Hide the empty message when we don't yet have any items and are still loading. */ const OverviewTableContainerDiv = euiStyled.div<{ + fixedHeight?: boolean; isEmptyAndLoading: boolean; shouldUseMobileLayout: boolean; }>` - ${({ shouldUseMobileLayout }) => - shouldUseMobileLayout + ${({ fixedHeight, shouldUseMobileLayout }) => + shouldUseMobileLayout || !fixedHeight ? '' : ` min-height: ${tableHeight}px; @@ -54,15 +55,18 @@ const OverviewTableContainerDiv = euiStyled.div<{ export function OverviewTableContainer({ children, + fixedHeight, isEmptyAndLoading, }: { children?: ReactNode; + fixedHeight?: boolean; isEmptyAndLoading: boolean; }) { const { isMedium } = useBreakPoints(); return ( diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index b356f68dba2f0..6e5896c9b5e4b 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; import { EuiCallOut, EuiFlexGroup, @@ -30,6 +31,8 @@ interface Props { showKueryBar?: boolean; showTimeComparison?: boolean; showTransactionTypeSelector?: boolean; + kueryBarPlaceholder?: string; + kueryBarBoolFilter?: QueryDslQueryContainer[]; } function DebugQueryCallout() { @@ -83,6 +86,8 @@ export function SearchBar({ showKueryBar = true, showTimeComparison = false, showTransactionTypeSelector = false, + kueryBarBoolFilter, + kueryBarPlaceholder, }: Props) { const { isSmall, isMedium, isLarge, isXl, isXXL, isXXXL } = useBreakPoints(); @@ -115,7 +120,10 @@ export function SearchBar({ {showKueryBar && ( - + )} diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 6a736f8009a74..2f7b1b01021e3 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -60,10 +60,12 @@ interface Props { numberOfTransactionsPerPage?: number; showAggregationAccurateCallout?: boolean; environment: string; + fixedHeight?: boolean; kuery: string; } export function TransactionsTable({ + fixedHeight = false, hideViewTransactionsLink = false, numberOfTransactionsPerPage = 5, showAggregationAccurateCallout = false, @@ -301,6 +303,7 @@ export function TransactionsTable({ ({ serviceName: '', transactionTypes: [], alerts: [] }); export function ApmServiceContextProvider({ @@ -40,7 +41,7 @@ export function ApmServiceContextProvider({ query, } = useApmParams('/services/:serviceName'); - const { agentName } = useServiceAgentNameFetcher(serviceName); + const { agentName, runtimeName } = useServiceAgentFetcher(serviceName); const transactionTypes = useServiceTransactionTypesFetcher(serviceName); @@ -65,6 +66,7 @@ export function ApmServiceContextProvider({ transactionType, transactionTypes, alerts, + runtimeName, }} children={children} /> diff --git a/x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts b/x-pack/plugins/apm/public/context/apm_service/use_service_agent_fetcher.ts similarity index 70% rename from x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts rename to x-pack/plugins/apm/public/context/apm_service/use_service_agent_fetcher.ts index 82198eb73b3cb..214b72a34d6e5 100644 --- a/x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts +++ b/x-pack/plugins/apm/public/context/apm_service/use_service_agent_fetcher.ts @@ -8,14 +8,19 @@ import { useFetcher } from '../../hooks/use_fetcher'; import { useUrlParams } from '../url_params_context/use_url_params'; -export function useServiceAgentNameFetcher(serviceName?: string) { +const INITIAL_STATE = { + agentName: undefined, + runtimeName: undefined, +}; + +export function useServiceAgentFetcher(serviceName?: string) { const { urlParams } = useUrlParams(); const { start, end } = urlParams; - const { data, error, status } = useFetcher( + const { data = INITIAL_STATE, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end) { return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/agent_name', + endpoint: 'GET /api/apm/services/{serviceName}/agent', params: { path: { serviceName }, query: { start, end }, @@ -26,5 +31,5 @@ export function useServiceAgentNameFetcher(serviceName?: string) { [serviceName, start, end] ); - return { agentName: data?.agentName, status, error }; + return { ...data, status, error }; } diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index 37e8dc82a0408..c1b56a4979765 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -30,6 +30,8 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { const query = toQuery(location.search); const { + sampleRangeFrom, + sampleRangeTo, traceId, transactionId, transactionName, @@ -73,6 +75,8 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { pageSize: pageSize ? toNumber(pageSize) : undefined, transactionId: toString(transactionId), traceId: toString(traceId), + sampleRangeFrom: sampleRangeFrom ? toNumber(sampleRangeFrom) : undefined, + sampleRangeTo: sampleRangeTo ? toNumber(sampleRangeTo) : undefined, waterfallItemId: toString(waterfallItemId), detailTab: toString(detailTab), flyoutDetailTab: toString(flyoutDetailTab), diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 370cbfec156b1..68b672362a1da 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -24,6 +24,8 @@ export type IUrlParams = { sortDirection?: string; sortField?: string; start?: string; + sampleRangeFrom?: number; + sampleRangeTo?: number; traceId?: string; transactionId?: string; transactionName?: string; diff --git a/x-pack/plugins/apm/public/hooks/use_failed_transactions_correlations_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_failed_transactions_correlations_fetcher.ts new file mode 100644 index 0000000000000..3841419e860fc --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_failed_transactions_correlations_fetcher.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRef, useState } from 'react'; +import type { Subscription } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../../src/plugins/data/public'; +import type { SearchServiceParams } from '../../common/search_strategies/correlations/types'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../plugin'; +import { FailedTransactionsCorrelationValue } from '../../common/search_strategies/failure_correlations/types'; +import { FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY } from '../../common/search_strategies/failure_correlations/constants'; + +interface RawResponse { + took: number; + values: FailedTransactionsCorrelationValue[]; + log: string[]; + ccsWarning: boolean; +} + +interface FailedTransactionsCorrelationsFetcherState { + error?: Error; + isComplete: boolean; + isRunning: boolean; + loaded: number; + ccsWarning: RawResponse['ccsWarning']; + values: RawResponse['values']; + log: RawResponse['log']; + timeTook?: number; + total: number; +} + +export const useFailedTransactionsCorrelationsFetcher = ( + params: Omit +) => { + const { + services: { data }, + } = useKibana(); + + const [ + fetchState, + setFetchState, + ] = useState({ + isComplete: false, + isRunning: false, + loaded: 0, + ccsWarning: false, + values: [], + log: [], + total: 100, + }); + + const abortCtrl = useRef(new AbortController()); + const searchSubscription$ = useRef(); + + function setResponse(response: IKibanaSearchResponse) { + setFetchState((prevState) => ({ + ...prevState, + isRunning: response.isRunning || false, + ccsWarning: response.rawResponse?.ccsWarning ?? false, + values: response.rawResponse?.values ?? [], + log: response.rawResponse?.log ?? [], + loaded: response.loaded!, + total: response.total!, + timeTook: response.rawResponse.took, + })); + } + + const startFetch = () => { + setFetchState((prevState) => ({ + ...prevState, + error: undefined, + isComplete: false, + })); + searchSubscription$.current?.unsubscribe(); + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + + const req = { params }; + + // Submit the search request using the `data.search` service. + searchSubscription$.current = data.search + .search>(req, { + strategy: FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY, + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (res: IKibanaSearchResponse) => { + setResponse(res); + if (isCompleteResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + isRunnning: false, + isComplete: true, + })); + } else if (isErrorResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + error: (res as unknown) as Error, + setIsRunning: false, + })); + } + }, + error: (error: Error) => { + setFetchState((prevState) => ({ + ...prevState, + error, + setIsRunning: false, + })); + }, + }); + }; + + const cancelFetch = () => { + searchSubscription$.current?.unsubscribe(); + searchSubscription$.current = undefined; + abortCtrl.current.abort(); + setFetchState((prevState) => ({ + ...prevState, + setIsRunning: false, + })); + }; + + return { + ...fetchState, + progress: fetchState.loaded / fetchState.total, + startFetch, + cancelFetch, + }; +}; diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts index 7bf01e976e923..9f39e6a01d065 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts @@ -5,112 +5,154 @@ * 2.0. */ -import { flatten, omit, isEmpty } from 'lodash'; -import { useHistory } from 'react-router-dom'; -import { useFetcher } from './use_fetcher'; -import { toQuery, fromQuery } from '../components/shared/Links/url_helpers'; -import { maybe } from '../../common/utils/maybe'; -import { APIReturnType } from '../services/rest/createCallApmApi'; -import { useUrlParams } from '../context/url_params_context/use_url_params'; -import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; +import { useRef, useState } from 'react'; +import type { Subscription } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../../src/plugins/data/public'; +import type { + HistogramItem, + SearchServiceParams, + SearchServiceValue, +} from '../../common/search_strategies/correlations/types'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../plugin'; -type APIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; - -const INITIAL_DATA = { - buckets: [] as APIResponse['buckets'], - noHits: true, - bucketSize: 0, -}; +interface RawResponse { + percentileThresholdValue?: number; + took: number; + values: SearchServiceValue[]; + overallHistogram: HistogramItem[]; + log: string[]; + ccsWarning: boolean; +} -export function useTransactionDistributionFetcher({ - transactionName, - kuery, - environment, -}: { - transactionName: string; - kuery: string; - environment: string; -}) { - const { serviceName, transactionType } = useApmServiceContext(); +interface TransactionDistributionFetcherState { + error?: Error; + isComplete: boolean; + isRunning: boolean; + loaded: number; + ccsWarning: RawResponse['ccsWarning']; + log: RawResponse['log']; + transactionDistribution?: RawResponse['overallHistogram']; + percentileThresholdValue?: RawResponse['percentileThresholdValue']; + timeTook?: number; + total: number; +} +export function useTransactionDistributionFetcher( + params: Omit +) { const { - urlParams: { start, end, transactionId, traceId }, - } = useUrlParams(); + services: { data }, + } = useKibana(); - const history = useHistory(); - const { data = INITIAL_DATA, status, error } = useFetcher( - async (callApmApi) => { - if (serviceName && start && end && transactionType && transactionName) { - const response = await callApmApi({ - endpoint: - 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', - params: { - path: { - serviceName, - }, - query: { - environment, - kuery, - start, - end, - transactionType, - transactionName, - transactionId, - traceId, - }, - }, - }); + const [ + fetchState, + setFetchState, + ] = useState({ + isComplete: false, + isRunning: false, + loaded: 0, + ccsWarning: false, + log: [], + total: 100, + }); - const selectedSample = - transactionId && traceId - ? flatten(response.buckets.map((bucket) => bucket.samples)).find( - (sample) => - sample.transactionId === transactionId && - sample.traceId === traceId - ) - : undefined; + const abortCtrl = useRef(new AbortController()); + const searchSubscription$ = useRef(); - if (!selectedSample) { - // selected sample was not found. select a new one: - // sorted by total number of requests, but only pick - // from buckets that have samples - const bucketsSortedByCount = response.buckets - .filter((bucket) => !isEmpty(bucket.samples)) - .sort((bucket) => bucket.count); + function setResponse(response: IKibanaSearchResponse) { + setFetchState((prevState) => ({ + ...prevState, + isRunning: response.isRunning || false, + ccsWarning: response.rawResponse?.ccsWarning ?? false, + histograms: response.rawResponse?.values ?? [], + log: response.rawResponse?.log ?? [], + loaded: response.loaded!, + total: response.total!, + timeTook: response.rawResponse.took, + // only set percentileThresholdValue and overallHistogram once it's repopulated on a refresh, + // otherwise the consuming chart would flicker with an empty state on reload. + ...(response.rawResponse?.percentileThresholdValue !== undefined && + response.rawResponse?.overallHistogram !== undefined + ? { + transactionDistribution: response.rawResponse?.overallHistogram, + percentileThresholdValue: + response.rawResponse?.percentileThresholdValue, + } + : {}), + })); + } - const preferredSample = maybe(bucketsSortedByCount[0]?.samples[0]); + const startFetch = () => { + setFetchState((prevState) => ({ + ...prevState, + error: undefined, + isComplete: false, + })); + searchSubscription$.current?.unsubscribe(); + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); - history.replace({ - ...history.location, - search: fromQuery({ - ...omit(toQuery(history.location.search), [ - 'traceId', - 'transactionId', - ]), - ...preferredSample, - }), - }); - } + const searchServiceParams: SearchServiceParams = { + ...params, + analyzeCorrelations: false, + }; + const req = { params: searchServiceParams }; - return response; - } - }, - // the histogram should not be refetched if the transactionId or traceId changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - environment, - kuery, - serviceName, - start, - end, - transactionType, - transactionName, - ] - ); + // Submit the search request using the `data.search` service. + searchSubscription$.current = data.search + .search>(req, { + strategy: 'apmCorrelationsSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (res: IKibanaSearchResponse) => { + setResponse(res); + if (isCompleteResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + isRunnning: false, + isComplete: true, + })); + } else if (isErrorResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + error: (res as unknown) as Error, + setIsRunning: false, + })); + } + }, + error: (error: Error) => { + setFetchState((prevState) => ({ + ...prevState, + error, + setIsRunning: false, + })); + }, + }); + }; + + const cancelFetch = () => { + searchSubscription$.current?.unsubscribe(); + searchSubscription$.current = undefined; + abortCtrl.current.abort(); + setFetchState((prevState) => ({ + ...prevState, + setIsRunning: false, + })); + }; return { - distributionData: data, - distributionStatus: status, - distributionError: error, + ...fetchState, + progress: fetchState.loaded / fetchState.total, + startFetch, + cancelFetch, }; } diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts new file mode 100644 index 0000000000000..538792bbf23a8 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_correlations_fetcher.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRef, useState } from 'react'; +import type { Subscription } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../../src/plugins/data/public'; +import type { + HistogramItem, + SearchServiceParams, + SearchServiceValue, +} from '../../common/search_strategies/correlations/types'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../plugin'; + +interface RawResponse { + percentileThresholdValue?: number; + took: number; + values: SearchServiceValue[]; + overallHistogram: HistogramItem[]; + log: string[]; + ccsWarning: boolean; +} + +interface TransactionLatencyCorrelationsFetcherState { + error?: Error; + isComplete: boolean; + isRunning: boolean; + loaded: number; + ccsWarning: RawResponse['ccsWarning']; + histograms: RawResponse['values']; + log: RawResponse['log']; + overallHistogram?: RawResponse['overallHistogram']; + percentileThresholdValue?: RawResponse['percentileThresholdValue']; + timeTook?: number; + total: number; +} + +export const useTransactionLatencyCorrelationsFetcher = ( + params: Omit +) => { + const { + services: { data }, + } = useKibana(); + + const [ + fetchState, + setFetchState, + ] = useState({ + isComplete: false, + isRunning: false, + loaded: 0, + ccsWarning: false, + histograms: [], + log: [], + total: 100, + }); + + const abortCtrl = useRef(new AbortController()); + const searchSubscription$ = useRef(); + + function setResponse(response: IKibanaSearchResponse) { + setFetchState((prevState) => ({ + ...prevState, + isRunning: response.isRunning || false, + ccsWarning: response.rawResponse?.ccsWarning ?? false, + histograms: response.rawResponse?.values ?? [], + log: response.rawResponse?.log ?? [], + loaded: response.loaded!, + total: response.total!, + timeTook: response.rawResponse.took, + // only set percentileThresholdValue and overallHistogram once it's repopulated on a refresh, + // otherwise the consuming chart would flicker with an empty state on reload. + ...(response.rawResponse?.percentileThresholdValue !== undefined && + response.rawResponse?.overallHistogram !== undefined + ? { + overallHistogram: response.rawResponse?.overallHistogram, + percentileThresholdValue: + response.rawResponse?.percentileThresholdValue, + } + : {}), + })); + } + + const startFetch = () => { + setFetchState((prevState) => ({ + ...prevState, + error: undefined, + isComplete: false, + })); + searchSubscription$.current?.unsubscribe(); + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + + const searchServiceParams: SearchServiceParams = { + ...params, + analyzeCorrelations: true, + }; + const req = { params: searchServiceParams }; + + // Submit the search request using the `data.search` service. + searchSubscription$.current = data.search + .search>(req, { + strategy: 'apmCorrelationsSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (res: IKibanaSearchResponse) => { + setResponse(res); + if (isCompleteResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + isRunnning: false, + isComplete: true, + })); + } else if (isErrorResponse(res)) { + searchSubscription$.current?.unsubscribe(); + setFetchState((prevState) => ({ + ...prevState, + error: (res as unknown) as Error, + setIsRunning: false, + })); + } + }, + error: (error: Error) => { + setFetchState((prevState) => ({ + ...prevState, + error, + setIsRunning: false, + })); + }, + }); + }; + + const cancelFetch = () => { + searchSubscription$.current?.unsubscribe(); + searchSubscription$.current = undefined; + abortCtrl.current.abort(); + setFetchState((prevState) => ({ + ...prevState, + setIsRunning: false, + })); + }; + + return { + ...fetchState, + progress: fetchState.loaded / fetchState.total, + startFetch, + cancelFetch, + }; +}; diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts new file mode 100644 index 0000000000000..673c1086033b5 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_transaction_trace_samples_fetcher.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useFetcher } from './use_fetcher'; +import { useUrlParams } from '../context/url_params_context/use_url_params'; +import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; + +export interface TraceSample { + traceId: string; + transactionId: string; +} + +const INITIAL_DATA = { + noHits: true, + traceSamples: [] as TraceSample[], +}; + +export function useTransactionTraceSamplesFetcher({ + transactionName, + kuery, + environment, +}: { + transactionName: string; + kuery: string; + environment: string; +}) { + const { serviceName, transactionType } = useApmServiceContext(); + + const { + urlParams: { + start, + end, + transactionId, + traceId, + sampleRangeFrom, + sampleRangeTo, + }, + } = useUrlParams(); + + const { data = INITIAL_DATA, status, error } = useFetcher( + async (callApmApi) => { + if (serviceName && start && end && transactionType && transactionName) { + const response = await callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/traces/samples', + params: { + path: { + serviceName, + }, + query: { + environment, + kuery, + start, + end, + transactionType, + transactionName, + transactionId, + traceId, + sampleRangeFrom, + sampleRangeTo, + }, + }, + }); + + if (response.noHits) { + return response; + } + + const { traceSamples } = response; + + return { + noHits: false, + traceSamples, + }; + } + }, + // the samples should not be refetched if the transactionId or traceId changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + environment, + kuery, + serviceName, + start, + end, + transactionType, + transactionName, + sampleRangeFrom, + sampleRangeTo, + ] + ); + + return { + traceSamplesData: data, + traceSamplesStatus: status, + traceSamplesError: error, + }; +} diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts index ae42a0c94fe9c..e9986bd9f0cf5 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts @@ -49,14 +49,14 @@ export const asyncSearchServiceProvider = ( // 95th percentile to be displayed as a marker in the log log chart const { totalDocs, - percentiles: percentileThreshold, + percentiles: percentilesResponseThresholds, } = await fetchTransactionDurationPercentiles( esClient, params, params.percentileThreshold ? [params.percentileThreshold] : undefined ); const percentileThresholdValue = - percentileThreshold[`${params.percentileThreshold}.0`]; + percentilesResponseThresholds[`${params.percentileThreshold}.0`]; state.setPercentileThresholdValue(percentileThresholdValue); addLogMessage( @@ -107,11 +107,31 @@ export const asyncSearchServiceProvider = ( return; } + // finish early if correlation analysis is not required. + if (params.analyzeCorrelations === false) { + addLogMessage( + `Finish service since correlation analysis wasn't requested.` + ); + state.setProgress({ + loadedHistogramStepsize: 1, + loadedOverallHistogram: 1, + loadedFieldCanditates: 1, + loadedFieldValuePairs: 1, + loadedHistograms: 1, + }); + state.setIsRunning(false); + return; + } + // Create an array of ranges [2, 4, 6, ..., 98] - const percents = Array.from(range(2, 100, 2)); + const percentileAggregationPercents = range(2, 100, 2); const { percentiles: percentilesRecords, - } = await fetchTransactionDurationPercentiles(esClient, params, percents); + } = await fetchTransactionDurationPercentiles( + esClient, + params, + percentileAggregationPercents + ); const percentiles = Object.values(percentilesRecords); addLogMessage(`Loaded percentiles.`); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.test.ts index 4b10ceb035e15..3be3438b2d18f 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.test.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.test.ts @@ -49,7 +49,6 @@ describe('correlations', () => { end: '2021', environment: 'dev', kuery: '', - percentileThresholdValue: 75, includeFrozen: false, }, }); @@ -85,13 +84,6 @@ describe('correlations', () => { 'transaction.name': 'actualTransactionName', }, }, - { - range: { - 'transaction.duration.us': { - gte: 75, - }, - }, - }, ], }, }); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.ts index f28556f7a90b5..8bd9f3d4e582c 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/queries/get_query_with_params.ts @@ -10,28 +10,11 @@ import { getOrElse } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import * as t from 'io-ts'; import { failure } from 'io-ts/lib/PathReporter'; -import { TRANSACTION_DURATION } from '../../../../../common/elasticsearch_fieldnames'; import type { SearchServiceFetchParams } from '../../../../../common/search_strategies/correlations/types'; import { rangeRt } from '../../../../routes/default_api_types'; import { getCorrelationsFilters } from '../../../correlations/get_filters'; import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; -const getPercentileThresholdValueQuery = ( - percentileThresholdValue: number | undefined -): estypes.QueryDslQueryContainer[] => { - return percentileThresholdValue - ? [ - { - range: { - [TRANSACTION_DURATION]: { - gte: percentileThresholdValue, - }, - }, - }, - ] - : []; -}; - export const getTermsQuery = ( fieldName: string | undefined, fieldValue: string | undefined @@ -55,7 +38,6 @@ export const getQueryWithParams = ({ serviceName, start, end, - percentileThresholdValue, transactionType, transactionName, } = params; @@ -82,7 +64,6 @@ export const getQueryWithParams = ({ filter: [ ...filters, ...getTermsQuery(fieldName, fieldValue), - ...getPercentileThresholdValueQuery(percentileThresholdValue), ] as estypes.QueryDslQueryContainer[], }, }; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/async_search_service.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/async_search_service.ts new file mode 100644 index 0000000000000..9afe9d916b38e --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/async_search_service.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from 'src/core/server'; +import { chunk } from 'lodash'; +import type { SearchServiceParams } from '../../../../common/search_strategies/correlations/types'; +import type { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; +import { asyncSearchServiceLogProvider } from '../correlations/async_search_service_log'; +import { asyncErrorCorrelationsSearchServiceStateProvider } from './async_search_service_state'; +import { fetchTransactionDurationFieldCandidates } from '../correlations/queries'; +import type { SearchServiceFetchParams } from '../../../../common/search_strategies/correlations/types'; +import { fetchFailedTransactionsCorrelationPValues } from './queries/query_failure_correlation'; +import { ERROR_CORRELATION_THRESHOLD } from './constants'; + +export const asyncErrorCorrelationSearchServiceProvider = ( + esClient: ElasticsearchClient, + getApmIndices: () => Promise, + searchServiceParams: SearchServiceParams, + includeFrozen: boolean +) => { + const { addLogMessage, getLogMessages } = asyncSearchServiceLogProvider(); + + const state = asyncErrorCorrelationsSearchServiceStateProvider(); + + async function fetchErrorCorrelations() { + try { + const indices = await getApmIndices(); + const params: SearchServiceFetchParams = { + ...searchServiceParams, + index: indices['apm_oss.transactionIndices'], + includeFrozen, + }; + + const { fieldCandidates } = await fetchTransactionDurationFieldCandidates( + esClient, + params + ); + + addLogMessage(`Identified ${fieldCandidates.length} fieldCandidates.`); + + state.setProgress({ loadedFieldCandidates: 1 }); + + let fieldCandidatesFetchedCount = 0; + if (params !== undefined && fieldCandidates.length > 0) { + const batches = chunk(fieldCandidates, 10); + for (let i = 0; i < batches.length; i++) { + try { + const results = await Promise.allSettled( + batches[i].map((fieldName) => + fetchFailedTransactionsCorrelationPValues( + esClient, + params, + fieldName + ) + ) + ); + + results.forEach((result, idx) => { + if (result.status === 'fulfilled') { + state.addValues( + result.value.filter( + (record) => + record && + typeof record.pValue === 'number' && + record.pValue < ERROR_CORRELATION_THRESHOLD + ) + ); + } else { + // If one of the fields in the batch had an error + addLogMessage( + `Error getting error correlation for field ${batches[i][idx]}: ${result.reason}.` + ); + } + }); + } catch (e) { + state.setError(e); + + if (params?.index.includes(':')) { + state.setCcsWarning(true); + } + } finally { + fieldCandidatesFetchedCount += batches[i].length; + state.setProgress({ + loadedErrorCorrelations: + fieldCandidatesFetchedCount / fieldCandidates.length, + }); + } + } + + addLogMessage( + `Identified correlations for ${fieldCandidatesFetchedCount} fields out of ${fieldCandidates.length} candidates.` + ); + } + } catch (e) { + state.setError(e); + } + + addLogMessage( + `Identified ${ + state.getState().values.length + } significant correlations relating to failed transactions.` + ); + + state.setIsRunning(false); + } + + fetchErrorCorrelations(); + + return () => { + const { ccsWarning, error, isRunning, progress } = state.getState(); + + return { + ccsWarning, + error, + log: getLogMessages(), + isRunning, + loaded: Math.round(state.getOverallProgress() * 100), + started: progress.started, + total: 100, + values: state.getValuesSortedByScore(), + cancel: () => { + addLogMessage(`Service cancelled.`); + state.setIsCancelled(true); + }, + }; + }; +}; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/async_search_service_state.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/async_search_service_state.ts new file mode 100644 index 0000000000000..fb0c6fea4879a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/async_search_service_state.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FailedTransactionsCorrelationValue } from '../../../../common/search_strategies/failure_correlations/types'; + +interface Progress { + started: number; + loadedFieldCandidates: number; + loadedErrorCorrelations: number; +} +export const asyncErrorCorrelationsSearchServiceStateProvider = () => { + let ccsWarning = false; + function setCcsWarning(d: boolean) { + ccsWarning = d; + } + + let error: Error; + function setError(d: Error) { + error = d; + } + + let isCancelled = false; + function setIsCancelled(d: boolean) { + isCancelled = d; + } + + let isRunning = true; + function setIsRunning(d: boolean) { + isRunning = d; + } + + let progress: Progress = { + started: Date.now(), + loadedFieldCandidates: 0, + loadedErrorCorrelations: 0, + }; + function getOverallProgress() { + return ( + progress.loadedFieldCandidates * 0.025 + + progress.loadedErrorCorrelations * (1 - 0.025) + ); + } + function setProgress(d: Partial>) { + progress = { + ...progress, + ...d, + }; + } + + const values: FailedTransactionsCorrelationValue[] = []; + function addValue(d: FailedTransactionsCorrelationValue) { + values.push(d); + } + function addValues(d: FailedTransactionsCorrelationValue[]) { + values.push(...d); + } + + function getValuesSortedByScore() { + return values.sort((a, b) => b.score - a.score); + } + + function getState() { + return { + ccsWarning, + error, + isCancelled, + isRunning, + progress, + values, + }; + } + + return { + addValue, + addValues, + getOverallProgress, + getState, + getValuesSortedByScore, + setCcsWarning, + setError, + setIsCancelled, + setIsRunning, + setProgress, + }; +}; + +export type AsyncSearchServiceState = ReturnType< + typeof asyncErrorCorrelationsSearchServiceStateProvider +>; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/constants.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/constants.ts new file mode 100644 index 0000000000000..711c5f736d774 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ERROR_CORRELATION_THRESHOLD = 0.02; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts new file mode 100644 index 0000000000000..f7e24ac6e1335 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { apmFailedTransactionsCorrelationsSearchStrategyProvider } from './search_strategy'; +export { FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY } from '../../../../common/search_strategies/failure_correlations/constants'; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/queries/query_failure_correlation.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/queries/query_failure_correlation.ts new file mode 100644 index 0000000000000..22424d68f07ff --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/queries/query_failure_correlation.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from 'kibana/server'; +import { SearchServiceFetchParams } from '../../../../../common/search_strategies/correlations/types'; +import { + getQueryWithParams, + getTermsQuery, +} from '../../correlations/queries/get_query_with_params'; +import { getRequestBase } from '../../correlations/queries/get_request_base'; +import { EVENT_OUTCOME } from '../../../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../../../common/event_outcome'; + +export const getFailureCorrelationRequest = ( + params: SearchServiceFetchParams, + fieldName: string +): estypes.SearchRequest => { + const query = getQueryWithParams({ + params, + }); + + const queryWithFailure = { + ...query, + bool: { + ...query.bool, + filter: [ + ...query.bool.filter, + ...getTermsQuery(EVENT_OUTCOME, EventOutcome.failure), + ], + }, + }; + + const body = { + query: queryWithFailure, + size: 0, + aggs: { + failure_p_value: { + significant_terms: { + field: fieldName, + background_filter: { + // Important to have same query as above here + // without it, we would be comparing sets of different filtered elements + ...query, + }, + // No need to have must_not "event.outcome": "failure" clause + // if background_is_superset is set to true + p_value: { background_is_superset: true }, + }, + }, + }, + }; + + return { + ...getRequestBase(params), + body, + }; +}; + +export const fetchFailedTransactionsCorrelationPValues = async ( + esClient: ElasticsearchClient, + params: SearchServiceFetchParams, + fieldName: string +) => { + const resp = await esClient.search( + getFailureCorrelationRequest(params, fieldName) + ); + + if (resp.body.aggregations === undefined) { + throw new Error( + 'fetchErrorCorrelation failed, did not return aggregations.' + ); + } + + const result = (resp.body.aggregations + .failure_p_value as estypes.AggregationsMultiBucketAggregate<{ + key: string; + doc_count: number; + bg_count: number; + score: number; + }>).buckets.map((b) => { + const score = b.score; + + // Scale the score into a value from 0 - 1 + // using a concave piecewise linear function in -log(p-value) + const normalizedScore = + 0.5 * Math.min(Math.max((score - 3.912) / 2.995, 0), 1) + + 0.25 * Math.min(Math.max((score - 6.908) / 6.908, 0), 1) + + 0.25 * Math.min(Math.max((score - 13.816) / 101.314, 0), 1); + + return { + ...b, + fieldName, + fieldValue: b.key, + pValue: Math.exp(-score), + normalizedScore, + }; + }); + + return result; +}; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/search_strategy.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/search_strategy.ts new file mode 100644 index 0000000000000..415f19e892741 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/search_strategy.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import { of } from 'rxjs'; + +import type { ISearchStrategy } from '../../../../../../../src/plugins/data/server'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../../../../../src/plugins/data/common'; + +import type { SearchServiceParams } from '../../../../common/search_strategies/correlations/types'; +import type { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; + +import { asyncErrorCorrelationSearchServiceProvider } from './async_search_service'; +import { FailedTransactionsCorrelationValue } from '../../../../common/search_strategies/failure_correlations/types'; + +export type PartialSearchRequest = IKibanaSearchRequest; +export type PartialSearchResponse = IKibanaSearchResponse<{ + values: FailedTransactionsCorrelationValue[]; +}>; + +export const apmFailedTransactionsCorrelationsSearchStrategyProvider = ( + getApmIndices: () => Promise, + includeFrozen: boolean +): ISearchStrategy => { + const asyncSearchServiceMap = new Map< + string, + ReturnType + >(); + + return { + search: (request, options, deps) => { + if (request.params === undefined) { + throw new Error('Invalid request parameters.'); + } + + // The function to fetch the current state of the async search service. + // This will be either an existing service for a follow up fetch or a new one for new requests. + let getAsyncSearchServiceState: ReturnType< + typeof asyncErrorCorrelationSearchServiceProvider + >; + + // If the request includes an ID, we require that the async search service already exists + // otherwise we throw an error. The client should never poll a service that's been cancelled or finished. + // This also avoids instantiating async search services when the service gets called with random IDs. + if (typeof request.id === 'string') { + const existingGetAsyncSearchServiceState = asyncSearchServiceMap.get( + request.id + ); + + if (typeof existingGetAsyncSearchServiceState === 'undefined') { + throw new Error( + `AsyncSearchService with ID '${request.id}' does not exist.` + ); + } + + getAsyncSearchServiceState = existingGetAsyncSearchServiceState; + } else { + getAsyncSearchServiceState = asyncErrorCorrelationSearchServiceProvider( + deps.esClient.asCurrentUser, + getApmIndices, + request.params, + includeFrozen + ); + } + + // Reuse the request's id or create a new one. + const id = request.id ?? uuid(); + + const { + ccsWarning, + error, + log, + isRunning, + loaded, + started, + total, + values, + } = getAsyncSearchServiceState(); + + if (error instanceof Error) { + asyncSearchServiceMap.delete(id); + throw error; + } else if (isRunning) { + asyncSearchServiceMap.set(id, getAsyncSearchServiceState); + } else { + asyncSearchServiceMap.delete(id); + } + + const took = Date.now() - started; + + return of({ + id, + loaded, + total, + isRunning, + isPartial: isRunning, + rawResponse: { + ccsWarning, + log, + took, + values, + }, + }); + }, + cancel: async (id, options, deps) => { + const getAsyncSearchServiceState = asyncSearchServiceMap.get(id); + if (getAsyncSearchServiceState !== undefined) { + getAsyncSearchServiceState().cancel(); + asyncSearchServiceMap.delete(id); + } + }, + }; +}; diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index be664529abab4..1b5df64dd8d00 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -63,14 +63,10 @@ Object { ], }, "body": Object { - "aggs": Object { - "agents": Object { - "terms": Object { - "field": "agent.name", - "size": 1, - }, - }, - }, + "_source": Array [ + "service.runtime.name", + "agent.name", + ], "query": Object { "bool": Object { "filter": Array [ @@ -88,10 +84,20 @@ Object { }, }, }, + Object { + "exists": Object { + "field": "service.runtime.name", + }, + }, + Object { + "exists": Object { + "field": "agent.name", + }, + }, ], }, }, - "size": 0, + "size": 1, }, "terminateAfter": 1, } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts b/x-pack/plugins/apm/server/lib/services/get_service_agent.ts similarity index 64% rename from x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts rename to x-pack/plugins/apm/server/lib/services/get_service_agent.ts index 49489f2b33888..2a6ec74bc0d1a 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_agent.ts @@ -9,12 +9,24 @@ import { ProcessorEvent } from '../../../common/processor_event'; import { AGENT_NAME, SERVICE_NAME, + SERVICE_RUNTIME_NAME, } from '../../../common/elasticsearch_fieldnames'; import { rangeQuery } from '../../../../observability/server'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; -export async function getServiceAgentName({ +interface ServiceAgent { + service?: { + runtime: { + name: string; + }; + }; + agent?: { + name: string; + }; +} + +export async function getServiceAgent({ serviceName, setup, searchAggregatedTransactions, @@ -37,27 +49,37 @@ export async function getServiceAgentName({ ], }, body: { - size: 0, + size: 1, + _source: [SERVICE_RUNTIME_NAME, AGENT_NAME], query: { bool: { filter: [ { term: { [SERVICE_NAME]: serviceName } }, ...rangeQuery(start, end), + { + exists: { + field: SERVICE_RUNTIME_NAME, + }, + }, + { + exists: { + field: AGENT_NAME, + }, + }, ], }, }, - aggs: { - agents: { - terms: { field: AGENT_NAME, size: 1 }, - }, - }, }, }; - const { aggregations } = await apmEventClient.search( + const response = await apmEventClient.search( 'get_service_agent_name', params ); - const agentName = aggregations?.agents.buckets[0]?.key as string | undefined; - return { agentName }; + if (response.hits.total.value === 0) { + return {}; + } + + const { service, agent } = response.hits.hits[0]._source as ServiceAgent; + return { agentName: agent?.name, runtimeName: service?.runtime.name }; } diff --git a/x-pack/plugins/apm/server/lib/services/queries.test.ts b/x-pack/plugins/apm/server/lib/services/queries.test.ts index a34382ddaf1fb..be5f280477a09 100644 --- a/x-pack/plugins/apm/server/lib/services/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/services/queries.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getServiceAgentName } from './get_service_agent_name'; +import { getServiceAgent } from './get_service_agent'; import { getServiceTransactionTypes } from './get_service_transaction_types'; import { getServicesItems } from './get_services/get_services_items'; import { getLegacyDataStatus } from './get_services/get_legacy_data_status'; @@ -25,7 +25,7 @@ describe('services queries', () => { it('fetches the service agent name', async () => { mock = await inspectSearchParams((setup) => - getServiceAgentName({ + getServiceAgent({ serviceName: 'foo', setup, searchAggregatedTransactions: false, diff --git a/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap index baa9b3ae230fe..44125d557dcc8 100644 --- a/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap @@ -335,7 +335,7 @@ Object { } `; -exports[`transaction queries fetches transaction distribution 1`] = ` +exports[`transaction queries fetches transaction trace samples 1`] = ` Object { "apm": Object { "events": Array [ @@ -343,13 +343,6 @@ Object { ], }, "body": Object { - "aggs": Object { - "stats": Object { - "max": Object { - "field": "transaction.duration.us", - }, - }, - }, "query": Object { "bool": Object { "filter": Array [ @@ -377,10 +370,27 @@ Object { }, }, }, + Object { + "term": Object { + "transaction.sampled": true, + }, + }, + ], + "should": Array [ + Object { + "term": Object { + "trace.id": "qux", + }, + }, + Object { + "term": Object { + "transaction.id": "quz", + }, + }, ], }, }, - "size": 0, + "size": 500, }, } `; diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts deleted file mode 100644 index e868f7de049f9..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; -import { withApmSpan } from '../../../../utils/with_apm_span'; -import { - SERVICE_NAME, - TRACE_ID, - TRANSACTION_DURATION, - TRANSACTION_ID, - TRANSACTION_NAME, - TRANSACTION_SAMPLED, - TRANSACTION_TYPE, -} from '../../../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../../../common/processor_event'; -import { joinByKey } from '../../../../../common/utils/join_by_key'; -import { rangeQuery, kqlQuery } from '../../../../../../observability/server'; -import { environmentQuery } from '../../../../../common/utils/environment_query'; -import { - getDocumentTypeFilterForAggregatedTransactions, - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../../../helpers/aggregated_transactions'; -import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; - -function getHistogramAggOptions({ - bucketSize, - field, - distributionMax, -}: { - bucketSize: number; - field: string; - distributionMax: number; -}) { - return { - field, - interval: bucketSize, - min_doc_count: 0, - extended_bounds: { - min: 0, - max: distributionMax, - }, - }; -} - -export async function getBuckets({ - environment, - kuery, - serviceName, - transactionName, - transactionType, - transactionId, - traceId, - distributionMax, - bucketSize, - setup, - searchAggregatedTransactions, -}: { - environment: string; - kuery: string; - serviceName: string; - transactionName: string; - transactionType: string; - transactionId: string; - traceId: string; - distributionMax: number; - bucketSize: number; - setup: Setup & SetupTimeRange; - searchAggregatedTransactions: boolean; -}) { - return withApmSpan( - 'get_latency_distribution_buckets_with_samples', - async () => { - const { start, end, apmEventClient } = setup; - - const commonFilters = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { term: { [TRANSACTION_NAME]: transactionName } }, - ...rangeQuery(start, end), - ...environmentQuery(environment), - ...kqlQuery(kuery), - ] as QueryDslQueryContainer[]; - - async function getSamplesForDistributionBuckets() { - const response = await apmEventClient.search( - 'get_samples_for_latency_distribution_buckets', - { - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - query: { - bool: { - filter: [ - ...commonFilters, - { term: { [TRANSACTION_SAMPLED]: true } }, - ], - should: [ - { term: { [TRACE_ID]: traceId } }, - { term: { [TRANSACTION_ID]: transactionId } }, - ] as QueryDslQueryContainer[], - }, - }, - aggs: { - distribution: { - histogram: getHistogramAggOptions({ - bucketSize, - field: TRANSACTION_DURATION, - distributionMax, - }), - aggs: { - samples: { - top_hits: { - _source: [TRANSACTION_ID, TRACE_ID], - size: 10, - sort: { - _score: 'desc' as const, - }, - }, - }, - }, - }, - }, - }, - } - ); - - return ( - response.aggregations?.distribution.buckets.map((bucket) => { - const samples = bucket.samples.hits.hits; - return { - key: bucket.key, - samples: samples.map(({ _source: sample }) => ({ - traceId: sample.trace.id, - transactionId: sample.transaction.id, - })), - }; - }) ?? [] - ); - } - - async function getDistributionBuckets() { - const response = await apmEventClient.search( - 'get_latency_distribution_buckets', - { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - query: { - bool: { - filter: [ - ...commonFilters, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - }, - aggs: { - distribution: { - histogram: getHistogramAggOptions({ - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - bucketSize, - distributionMax, - }), - }, - }, - }, - } - ); - - return ( - response.aggregations?.distribution.buckets.map((bucket) => { - return { - key: bucket.key, - count: bucket.doc_count, - }; - }) ?? [] - ); - } - - const [ - samplesForDistributionBuckets, - distributionBuckets, - ] = await Promise.all([ - getSamplesForDistributionBuckets(), - getDistributionBuckets(), - ]); - - const buckets = joinByKey( - [...samplesForDistributionBuckets, ...distributionBuckets], - 'key' - ).map((bucket) => ({ - ...bucket, - samples: bucket.samples ?? [], - count: bucket.count ?? 0, - })); - - return { - noHits: buckets.length === 0, - bucketSize, - buckets, - }; - } - ); -} diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts deleted file mode 100644 index 9c056bc506e92..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - SERVICE_NAME, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; -import { rangeQuery, kqlQuery } from '../../../../../observability/server'; -import { environmentQuery } from '../../../../common/utils/environment_query'; - -export async function getDistributionMax({ - environment, - kuery, - serviceName, - transactionName, - transactionType, - setup, - searchAggregatedTransactions, -}: { - environment: string; - kuery: string; - serviceName: string; - transactionName: string; - transactionType: string; - setup: Setup & SetupTimeRange; - searchAggregatedTransactions: boolean; -}) { - const { start, end, apmEventClient } = setup; - - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { term: { [TRANSACTION_NAME]: transactionName } }, - ...rangeQuery(start, end), - ...environmentQuery(environment), - ...kqlQuery(kuery), - ], - }, - }, - aggs: { - stats: { - max: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }, - }; - - const resp = await apmEventClient.search( - 'get_latency_distribution_max', - params - ); - return resp.aggregations?.stats.value ?? null; -} diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts deleted file mode 100644 index ef72f2434fde2..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { getBuckets } from './get_buckets'; -import { getDistributionMax } from './get_distribution_max'; -import { roundToNearestFiveOrTen } from '../../helpers/round_to_nearest_five_or_ten'; -import { MINIMUM_BUCKET_SIZE, BUCKET_TARGET_COUNT } from '../constants'; -import { withApmSpan } from '../../../utils/with_apm_span'; - -function getBucketSize(max: number) { - const bucketSize = max / BUCKET_TARGET_COUNT; - return roundToNearestFiveOrTen( - bucketSize > MINIMUM_BUCKET_SIZE ? bucketSize : MINIMUM_BUCKET_SIZE - ); -} - -export async function getTransactionDistribution({ - kuery, - environment, - serviceName, - transactionName, - transactionType, - transactionId, - traceId, - setup, - searchAggregatedTransactions, -}: { - environment: string; - kuery: string; - serviceName: string; - transactionName: string; - transactionType: string; - transactionId: string; - traceId: string; - setup: Setup & SetupTimeRange; - searchAggregatedTransactions: boolean; -}) { - return withApmSpan('get_transaction_latency_distribution', async () => { - const distributionMax = await getDistributionMax({ - environment, - kuery, - serviceName, - transactionName, - transactionType, - setup, - searchAggregatedTransactions, - }); - - if (distributionMax == null) { - return { noHits: true, buckets: [], bucketSize: 0 }; - } - - const bucketSize = getBucketSize(distributionMax); - - const { buckets, noHits } = await getBuckets({ - environment, - kuery, - serviceName, - transactionName, - transactionType, - transactionId, - traceId, - distributionMax, - bucketSize, - setup, - searchAggregatedTransactions, - }); - - return { - noHits, - buckets, - bucketSize, - }; - }); -} diff --git a/x-pack/plugins/apm/server/lib/transactions/queries.test.ts b/x-pack/plugins/apm/server/lib/transactions/queries.test.ts index b1d942a261387..b6b727d2273a1 100644 --- a/x-pack/plugins/apm/server/lib/transactions/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/queries.test.ts @@ -11,7 +11,7 @@ import { SearchParamsMock, } from '../../utils/test_helpers'; import { getTransactionBreakdown } from './breakdown'; -import { getTransactionDistribution } from './distribution'; +import { getTransactionTraceSamples } from './trace_samples'; import { getTransaction } from './get_transaction'; describe('transaction queries', () => { @@ -50,16 +50,15 @@ describe('transaction queries', () => { expect(mock.params).toMatchSnapshot(); }); - it('fetches transaction distribution', async () => { + it('fetches transaction trace samples', async () => { mock = await inspectSearchParams((setup) => - getTransactionDistribution({ + getTransactionTraceSamples({ serviceName: 'foo', transactionName: 'bar', transactionType: 'baz', traceId: 'qux', transactionId: 'quz', setup, - searchAggregatedTransactions: false, environment: ENVIRONMENT_ALL.value, kuery: '', }) diff --git a/x-pack/plugins/apm/server/lib/transactions/trace_samples/get_trace_samples/index.ts b/x-pack/plugins/apm/server/lib/transactions/trace_samples/get_trace_samples/index.ts new file mode 100644 index 0000000000000..98ef9ecaf346f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transactions/trace_samples/get_trace_samples/index.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; +import { withApmSpan } from '../../../../utils/with_apm_span'; +import { + SERVICE_NAME, + TRACE_ID, + TRANSACTION_ID, + TRANSACTION_NAME, + TRANSACTION_SAMPLED, + TRANSACTION_TYPE, +} from '../../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../../common/processor_event'; +import { rangeQuery, kqlQuery } from '../../../../../../observability/server'; +import { environmentQuery } from '../../../../../common/utils/environment_query'; +import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; + +const TRACE_SAMPLES_SIZE = 500; + +export async function getTraceSamples({ + environment, + kuery, + serviceName, + transactionName, + transactionType, + transactionId, + traceId, + sampleRangeFrom, + sampleRangeTo, + setup, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionName: string; + transactionType: string; + transactionId: string; + traceId: string; + sampleRangeFrom?: number; + sampleRangeTo?: number; + setup: Setup & SetupTimeRange; +}) { + return withApmSpan('get_trace_samples', async () => { + const { start, end, apmEventClient } = setup; + + const commonFilters = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { term: { [TRANSACTION_NAME]: transactionName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ] as QueryDslQueryContainer[]; + + if (sampleRangeFrom !== undefined && sampleRangeTo !== undefined) { + commonFilters.push({ + range: { + 'transaction.duration.us': { + gte: sampleRangeFrom, + lte: sampleRangeTo, + }, + }, + }); + } + + async function getTraceSamplesHits() { + const response = await apmEventClient.search('get_trace_samples_hits', { + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + ...commonFilters, + { term: { [TRANSACTION_SAMPLED]: true } }, + ], + should: [ + { term: { [TRACE_ID]: traceId } }, + { term: { [TRANSACTION_ID]: transactionId } }, + ] as QueryDslQueryContainer[], + }, + }, + size: TRACE_SAMPLES_SIZE, + }, + }); + + return response.hits.hits; + } + + const samplesForDistributionHits = await getTraceSamplesHits(); + + const traceSamples = samplesForDistributionHits.map((hit) => ({ + transactionId: hit._source.transaction.id, + traceId: hit._source.trace.id, + })); + + return { + noHits: samplesForDistributionHits.length === 0, + traceSamples, + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/transactions/trace_samples/index.ts b/x-pack/plugins/apm/server/lib/transactions/trace_samples/index.ts new file mode 100644 index 0000000000000..95548cd2afadf --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transactions/trace_samples/index.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getTraceSamples } from './get_trace_samples'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +export async function getTransactionTraceSamples({ + kuery, + environment, + serviceName, + transactionName, + transactionType, + transactionId, + traceId, + sampleRangeFrom, + sampleRangeTo, + setup, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionName: string; + transactionType: string; + transactionId: string; + traceId: string; + sampleRangeFrom?: number; + sampleRangeTo?: number; + setup: Setup & SetupTimeRange; +}) { + return withApmSpan('get_transaction_trace_samples', async () => { + return await getTraceSamples({ + environment, + kuery, + serviceName, + transactionName, + transactionType, + transactionId, + traceId, + sampleRangeFrom, + sampleRangeTo, + setup, + }); + }); +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 807d21768a50c..1e0e61bc2bf3a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -51,6 +51,10 @@ import { TRANSACTION_TYPE, } from '../common/elasticsearch_fieldnames'; import { tutorialProvider } from './tutorial'; +import { + apmFailedTransactionsCorrelationsSearchStrategyProvider, + FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY, +} from './lib/search_strategies/failed_transactions_correlations'; export class APMPlugin implements @@ -219,13 +223,25 @@ export class APMPlugin coreStart.savedObjects.createInternalRepository() ); + const includeFrozen = await coreStart.uiSettings + .asScopedToClient(savedObjectsClient) + .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); + + // Register APM latency correlations search strategy plugins.data.search.registerSearchStrategy( 'apmCorrelationsSearchStrategy', apmCorrelationsSearchStrategyProvider( boundGetApmIndices, - await coreStart.uiSettings - .asScopedToClient(savedObjectsClient) - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN) + includeFrozen + ) + ); + + // Register APM failed transactions correlations search strategy + plugins.data.search.registerSearchStrategy( + FAILED_TRANSACTIONS_CORRELATION_SEARCH_STRATEGY, + apmFailedTransactionsCorrelationsSearchStrategyProvider( + boundGetApmIndices, + includeFrozen ) ); })(); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index b4d185fecf5e2..32a7dcefb5cc8 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -16,7 +16,7 @@ import { getThroughputUnit } from '../lib/helpers/calculate_throughput'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAnnotations } from '../lib/services/annotations'; import { getServices } from '../lib/services/get_services'; -import { getServiceAgentName } from '../lib/services/get_service_agent_name'; +import { getServiceAgent } from '../lib/services/get_service_agent'; import { getServiceAlerts } from '../lib/services/get_service_alerts'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; import { getServiceInstanceMetadataDetails } from '../lib/services/get_service_instance_metadata_details'; @@ -164,8 +164,8 @@ const serviceMetadataIconsRoute = createApmServerRoute({ }, }); -const serviceAgentNameRoute = createApmServerRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/agent_name', +const serviceAgentRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/agent', params: t.type({ path: t.type({ serviceName: t.string, @@ -185,7 +185,7 @@ const serviceAgentNameRoute = createApmServerRoute({ kuery: '', }); - return getServiceAgentName({ + return getServiceAgent({ serviceName, setup, searchAggregatedTransactions, @@ -909,7 +909,7 @@ export const serviceRouteRepository = createApmServerRouteRepository() .add(servicesDetailedStatisticsRoute) .add(serviceMetadataDetailsRoute) .add(serviceMetadataIconsRoute) - .add(serviceAgentNameRoute) + .add(serviceAgentRoute) .add(serviceTransactionTypesRoute) .add(serviceNodeMetadataRoute) .add(serviceAnnotationsRoute) diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index f211e722958c5..c267487cd36b7 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -16,7 +16,7 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; import { getServiceTransactionGroupDetailedStatisticsPeriods } from '../lib/services/get_service_transaction_group_detailed_statistics'; import { getTransactionBreakdown } from '../lib/transactions/breakdown'; -import { getTransactionDistribution } from '../lib/transactions/distribution'; +import { getTransactionTraceSamples } from '../lib/transactions/trace_samples'; import { getAnomalySeries } from '../lib/transactions/get_anomaly_data'; import { getLatencyPeriods } from '../lib/transactions/get_latency_charts'; import { getErrorRatePeriods } from '../lib/transaction_groups/get_error_rate'; @@ -204,9 +204,8 @@ const transactionLatencyChartsRoute = createApmServerRoute({ }, }); -const transactionChartsDistributionRoute = createApmServerRoute({ - endpoint: - 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', +const transactionTraceSamplesRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/transactions/traces/samples', params: t.type({ path: t.type({ serviceName: t.string, @@ -219,6 +218,8 @@ const transactionChartsDistributionRoute = createApmServerRoute({ t.partial({ transactionId: t.string, traceId: t.string, + sampleRangeFrom: toNumberRt, + sampleRangeTo: toNumberRt, }), environmentRt, kueryRt, @@ -237,14 +238,11 @@ const transactionChartsDistributionRoute = createApmServerRoute({ transactionName, transactionId = '', traceId = '', + sampleRangeFrom, + sampleRangeTo, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions({ - ...setup, - kuery, - }); - - return getTransactionDistribution({ + return getTransactionTraceSamples({ environment, kuery, serviceName, @@ -252,8 +250,9 @@ const transactionChartsDistributionRoute = createApmServerRoute({ transactionName, transactionId, traceId, + sampleRangeFrom, + sampleRangeTo, setup, - searchAggregatedTransactions, }); }, }); @@ -347,6 +346,6 @@ export const transactionRouteRepository = createApmServerRouteRepository() .add(transactionGroupsMainStatisticsRoute) .add(transactionGroupsDetailedStatisticsRoute) .add(transactionLatencyChartsRoute) - .add(transactionChartsDistributionRoute) + .add(transactionTraceSamplesRoute) .add(transactionChartsBreakdownRoute) .add(transactionChartsErrorRateRoute); diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 25113ccbb30df..f894ca23dfbf0 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -43,6 +43,16 @@ cases: CasesUiStart; cases.getCreateCase({ onCancel: handleSetIsCancel, onSuccess, + lensIntegration?: { + plugins: { + parsingPlugin, + processingPluginRenderer, + uiPlugin, + }, + hooks: { + useInsertTimeline, + }, + } timelineIntegration?: { plugins: { parsingPlugin, diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 3edbd3443ffc1..bf4ec0da6ee56 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -18,6 +18,12 @@ import { UserActionField, } from '../api'; +export interface CasesUiConfigType { + markdownPlugins: { + lens: boolean; + }; +} + export const StatusAll = 'all' as const; export type StatusAllType = typeof StatusAll; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/constants.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/constants.ts new file mode 100644 index 0000000000000..bc67e1b3228bb --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LENS_ID = 'lens'; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/index.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/index.ts new file mode 100644 index 0000000000000..4f48da5838380 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './constants'; +export * from './parser'; +export * from './serializer'; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/parser.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/parser.ts new file mode 100644 index 0000000000000..58ebfd76d5ac5 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/parser.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Plugin } from 'unified'; +import { RemarkTokenizer } from '@elastic/eui'; +import { LENS_ID } from './constants'; + +export const LensParser: Plugin = function () { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + const tokenizeLens: RemarkTokenizer = function (eat, value, silent) { + if (value.startsWith(`!{${LENS_ID}`) === false) return true; + + const nextChar = value[6]; + + if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a lens + + if (silent) { + return true; + } + + // is there a configuration? + const hasConfiguration = nextChar === '{'; + + let match = `!{${LENS_ID}`; + let configuration = {}; + + if (hasConfiguration) { + let configurationString = ''; + + let openObjects = 0; + + for (let i = 6; i < value.length; i++) { + const char = value[i]; + if (char === '{') { + openObjects++; + configurationString += char; + } else if (char === '}') { + openObjects--; + if (openObjects === -1) { + break; + } + configurationString += char; + } else { + configurationString += char; + } + } + + match += configurationString; + try { + configuration = JSON.parse(configurationString); + } catch (e) { + const now = eat.now(); + this.file.fail(`Unable to parse lens JSON configuration: ${e}`, { + line: now.line, + column: now.column + 6, + }); + } + } + + match += '}'; + + return eat(match)({ + type: LENS_ID, + ...configuration, + }); + }; + + tokenizers.lens = tokenizeLens; + methods.splice(methods.indexOf('text'), 0, LENS_ID); +}; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts new file mode 100644 index 0000000000000..e561b2f8cfb8a --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimeRange } from 'src/plugins/data/common'; +import { LENS_ID } from './constants'; + +export interface LensSerializerProps { + attributes: Record; + timeRange: TimeRange; +} + +export const LensSerializer = ({ timeRange, attributes }: LensSerializerProps) => + `!{${LENS_ID}${JSON.stringify({ + timeRange, + attributes, + })}}`; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/index.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/index.ts new file mode 100644 index 0000000000000..c6a22791db5f6 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './parser'; +export * from './serializer'; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/parser.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/parser.ts new file mode 100644 index 0000000000000..0decdae8c7348 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/parser.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Plugin } from 'unified'; +import { RemarkTokenizer } from '@elastic/eui'; +import * as i18n from './translations'; + +export const ID = 'timeline'; +const PREFIX = '['; + +export const TimelineParser: Plugin = function () { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + const tokenizeTimeline: RemarkTokenizer = function (eat, value, silent) { + if ( + value.startsWith(PREFIX) === false || + (value.startsWith(PREFIX) === true && !value.includes('timelines?timeline=(id')) + ) { + return false; + } + + let index = 0; + const nextChar = value[index]; + + if (nextChar !== PREFIX) { + return false; + } + + if (silent) { + return true; + } + + function readArg(open: string, close: string) { + if (value[index] !== open) { + throw new Error(i18n.NO_PARENTHESES); + } + + index++; + + let body = ''; + let openBrackets = 0; + + for (; index < value.length; index++) { + const char = value[index]; + + if (char === close && openBrackets === 0) { + index++; + return body; + } else if (char === close) { + openBrackets--; + } else if (char === open) { + openBrackets++; + } + + body += char; + } + + return ''; + } + + const timelineTitle = readArg(PREFIX, ']'); + const timelineUrl = readArg('(', ')'); + const match = `[${timelineTitle}](${timelineUrl})`; + + return eat(match)({ + type: ID, + match, + }); + }; + + tokenizeTimeline.locator = (value: string, fromIndex: number) => { + return value.indexOf(PREFIX, fromIndex); + }; + + tokenizers.timeline = tokenizeTimeline; + methods.splice(methods.indexOf('url'), 0, ID); +}; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts new file mode 100644 index 0000000000000..0a95c9466b1ff --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TimelineSerializerProps { + match: string; +} + +export const TimelineSerializer = ({ match }: TimelineSerializerProps) => match; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/translations.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/translations.ts new file mode 100644 index 0000000000000..a1244f0ae67aa --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NO_PARENTHESES = i18n.translate( + 'xpack.cases.markdownEditor.plugins.timeline.noParenthesesErrorMsg', + { + defaultMessage: 'Expected left parentheses', + } +); diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index f72f0e012bd80..ebac6295166df 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -20,11 +20,15 @@ "requiredPlugins":[ "actions", "esUiShared", + "lens", "features", "kibanaReact", "kibanaUtils", "triggersActionsUi" ], + "requiredBundles": [ + "savedObjects" + ], "server":true, "ui":true, "version":"8.0.0" diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index 392b71befe2b4..fb5e3f89d74b1 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -12,7 +12,11 @@ import { createWithKibanaMock, } from '../kibana_react.mock'; -export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; +export const KibanaServices = { + get: jest.fn(), + getKibanaVersion: jest.fn(() => '8.0.0'), + getConfig: jest.fn(() => null), +}; export const useKibana = jest.fn().mockReturnValue({ services: createStartServicesMock(), }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts index ff03782447846..e1990efefeffc 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -15,6 +15,13 @@ import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common'; import { securityMock } from '../../../../../security/public/mocks'; import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks'; +export const mockCreateStartServicesMock = (): StartServices => + (({ + ...coreMock.createStart(), + security: securityMock.createStart(), + triggersActionsUi: triggersActionsUiMock.createStart(), + } as unknown) as StartServices); + export const createStartServicesMock = (): StartServices => (({ ...coreMock.createStart(), diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts index 94487bd3ca5e9..3a1f220d9794f 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -6,16 +6,23 @@ */ import { CoreStart } from 'kibana/public'; +import { CasesUiConfigType } from '../../../../common/ui/types'; type GlobalServices = Pick; export class KibanaServices { private static kibanaVersion?: string; private static services?: GlobalServices; + private static config?: CasesUiConfigType; - public static init({ http, kibanaVersion }: GlobalServices & { kibanaVersion: string }) { + public static init({ + http, + kibanaVersion, + config, + }: GlobalServices & { kibanaVersion: string; config: CasesUiConfigType }) { this.services = { http }; this.kibanaVersion = kibanaVersion; + this.config = config; } public static get(): GlobalServices { @@ -34,6 +41,10 @@ export class KibanaServices { return this.kibanaVersion; } + public static getConfig() { + return this.config; + } + private static throwUninitializedError(): never { throw new Error( 'Kibana services not initialized - are you trying to import this module from outside of the Cases app?' diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index db3f22a074d3b..06a3897687921 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -26,6 +26,7 @@ const onCommentPosted = jest.fn(); const postComment = jest.fn(); const addCommentProps: AddCommentProps = { + id: 'newComment', caseId: '1234', userCanCrud: true, onCommentSaving, diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index 4ec06d6b55197..f788456a30dff 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -6,7 +6,7 @@ */ import { EuiButton, EuiFlexItem, EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; -import React, { useCallback, forwardRef, useImperativeHandle } from 'react'; +import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; import { CommentType } from '../../../common'; @@ -19,6 +19,7 @@ import * as i18n from './translations'; import { schema, AddCommentFormSchema } from './schema'; import { InsertTimeline } from '../insert_timeline'; import { useOwnerContext } from '../owner_context/use_owner_context'; + const MySpinner = styled(EuiLoadingSpinner)` position: absolute; top: 50%; @@ -31,9 +32,11 @@ const initialCommentValue: AddCommentFormSchema = { export interface AddCommentRefObject { addQuote: (quote: string) => void; + setComment: (newComment: string) => void; } export interface AddCommentProps { + id: string; caseId: string; userCanCrud?: boolean; onCommentSaving?: () => void; @@ -47,6 +50,7 @@ export const AddComment = React.memo( forwardRef( ( { + id, caseId, userCanCrud, onCommentPosted, @@ -57,6 +61,7 @@ export const AddComment = React.memo( }, ref ) => { + const editorRef = useRef(); const owner = useOwnerContext(); const { isLoading, postComment } = usePostComment(); @@ -77,8 +82,17 @@ export const AddComment = React.memo( [comment, setFieldValue] ); + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue] + ); + useImperativeHandle(ref, () => ({ addQuote, + setComment, + editor: editorRef.current, })); const onSubmit = useCallback(async () => { @@ -106,6 +120,8 @@ export const AddComment = React.memo( path={fieldName} component={MarkdownEditorForm} componentProps={{ + ref: editorRef, + id, idAria: 'caseComment', isDisabled: isLoading, dataTestSubj: 'add-comment', diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index a44c2cb22010e..b333d908fa77c 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -105,6 +105,7 @@ export const CaseComponent = React.memo( const [initLoadingData, setInitLoadingData] = useState(true); const init = useRef(true); const timelineUi = useTimelineContext()?.ui; + const alertConsumers = useTimelineContext()?.alertConsumers; const { caseUserActions, @@ -486,7 +487,9 @@ export const CaseComponent = React.memo( - {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} + {timelineUi?.renderTimelineDetailsPanel + ? timelineUi.renderTimelineDetailsPanel({ alertConsumers }) + : null} ); } diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx index fcd1f82d64a53..923c73193f992 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -12,6 +12,7 @@ import { act } from '@testing-library/react'; import { useForm, Form, FormHook } from '../../common/shared_imports'; import { Description } from './description'; import { schema, FormProps } from './schema'; +jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); describe('Description', () => { let globalForm: FormHook; diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx index 0a7102cff1ad5..d11c64789c3f0 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -5,26 +5,43 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useEffect, useRef } from 'react'; import { MarkdownEditorForm } from '../markdown_editor'; -import { UseField } from '../../common/shared_imports'; +import { UseField, useFormContext } from '../../common/shared_imports'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; + interface Props { isLoading: boolean; } export const fieldName = 'description'; -const DescriptionComponent: React.FC = ({ isLoading }) => ( - -); +const DescriptionComponent: React.FC = ({ isLoading }) => { + const { draftComment, openLensModal } = useLensDraftComment(); + const { setFieldValue } = useFormContext(); + const editorRef = useRef>(); + + useEffect(() => { + if (draftComment?.commentId === fieldName && editorRef.current) { + setFieldValue(fieldName, draftComment.comment); + openLensModal({ editorRef: editorRef.current }); + } + }, [draftComment, openLensModal, setFieldValue]); + + return ( + + ); +}; DescriptionComponent.displayName = 'DescriptionComponent'; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 783ead9b271fd..9c3071fe27ee5 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -23,6 +23,7 @@ import { useCaseConfigureResponse } from '../configure_cases/__mock__'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); +jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); const useGetTagsMock = useGetTags as jest.Mock; const useConnectorsMock = useConnectors as jest.Mock; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/context.tsx b/x-pack/plugins/cases/public/components/markdown_editor/context.tsx new file mode 100644 index 0000000000000..d7f5b0612cb73 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/context.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const CommentEditorContext = React.createContext<{ + editorId: string; + value: string; +} | null>(null); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx index 4bd26678e41a2..64aac233f1bb9 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx @@ -5,15 +5,26 @@ * 2.0. */ -import React, { memo, useState, useCallback } from 'react'; +import React, { + memo, + forwardRef, + useCallback, + useMemo, + useRef, + useState, + useImperativeHandle, + ElementRef, +} from 'react'; import { PluggableList } from 'unified'; import { EuiMarkdownEditor, EuiMarkdownEditorUiPlugin } from '@elastic/eui'; +import { ContextShape } from '@elastic/eui/src/components/markdown_editor/markdown_context'; import { usePlugins } from './use_plugins'; +import { CommentEditorContext } from './context'; interface MarkdownEditorProps { ariaLabel: string; dataTestSubj?: string; - editorId?: string; + editorId: string; height?: number; onChange: (content: string) => void; parsingPlugins?: PluggableList; @@ -22,35 +33,64 @@ interface MarkdownEditorProps { value: string; } -const MarkdownEditorComponent: React.FC = ({ - ariaLabel, - dataTestSubj, - editorId, - height, - onChange, - value, -}) => { - const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); - const onParse = useCallback((err, { messages }) => { - setMarkdownErrorMessages(err ? [err] : messages); - }, []); - const { parsingPlugins, processingPlugins, uiPlugins } = usePlugins(); - - return ( - - ); -}; +type EuiMarkdownEditorRef = ElementRef; + +export interface MarkdownEditorRef { + textarea: HTMLTextAreaElement | null; + replaceNode: ContextShape['replaceNode']; + toolbar: HTMLDivElement | null; +} + +const MarkdownEditorComponent = forwardRef( + ({ ariaLabel, dataTestSubj, editorId, height, onChange, value }, ref) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + const { parsingPlugins, processingPlugins, uiPlugins } = usePlugins(); + const editorRef = useRef(null); + + const commentEditorContextValue = useMemo( + () => ({ + editorId, + value, + }), + [editorId, value] + ); + + // @ts-expect-error + useImperativeHandle(ref, () => { + if (!editorRef.current) { + return null; + } + + const editorNode = editorRef.current?.textarea?.closest('.euiMarkdownEditor'); + + return { + ...editorRef.current, + toolbar: editorNode?.querySelector('.euiMarkdownEditorToolbar'), + }; + }); + + return ( + + + + ); + } +); export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index c2b2e8c77cb38..2719f38f98fc2 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React from 'react'; +import React, { forwardRef } from 'react'; import styled from 'styled-components'; import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; -import { MarkdownEditor } from './editor'; +import { MarkdownEditor, MarkdownEditorRef } from './editor'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -26,40 +26,39 @@ const BottomContentWrapper = styled(EuiFlexGroup)` `} `; -export const MarkdownEditorForm: React.FC = ({ - id, - field, - dataTestSubj, - idAria, - bottomRightContent, -}) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); +export const MarkdownEditorForm = React.memo( + forwardRef( + ({ id, field, dataTestSubj, idAria, bottomRightContent }, ref) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - return ( - <> - - - - {bottomRightContent && ( - - {bottomRightContent} - - )} - - ); -}; + return ( + <> + + + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + ); + } + ) +); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts new file mode 100644 index 0000000000000..a0f0d49b211fb --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const useLensDraftComment = () => ({}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/constants.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/constants.ts new file mode 100644 index 0000000000000..05826f73fe007 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ID = 'lens'; +export const PREFIX = `[`; +export const LENS_VISUALIZATION_HEIGHT = 200; +export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/index.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/index.ts new file mode 100644 index 0000000000000..1d0bb2bf6c86e --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { plugin } from './plugin'; +import { LensParser } from './parser'; +import { LensMarkDownRenderer } from './processor'; +import { INSERT_LENS } from './translations'; + +export { plugin, LensParser as parser, LensMarkDownRenderer as renderer, INSERT_LENS }; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/modal_container.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/modal_container.tsx new file mode 100644 index 0000000000000..0f70e80deed41 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/modal_container.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; + +export const ModalContainer = styled.div` + width: ${({ theme }) => theme.eui.euiBreakpoints.m}; + + .euiModalBody { + min-height: 300px; + } +`; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/parser.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/parser.ts new file mode 100644 index 0000000000000..8d598fad260dc --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/parser.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Plugin } from 'unified'; +import { RemarkTokenizer } from '@elastic/eui'; +import { ID } from './constants'; + +export const LensParser: Plugin = function () { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + const tokenizeLens: RemarkTokenizer = function (eat, value, silent) { + if (value.startsWith(`!{${ID}`) === false) return false; + + const nextChar = value[6]; + + if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a lens + + if (silent) { + return true; + } + + // is there a configuration? + const hasConfiguration = nextChar === '{'; + + let match = `!{${ID}`; + let configuration = {}; + + if (hasConfiguration) { + let configurationString = ''; + + let openObjects = 0; + + for (let i = 6; i < value.length; i++) { + const char = value[i]; + if (char === '{') { + openObjects++; + configurationString += char; + } else if (char === '}') { + openObjects--; + if (openObjects === -1) { + break; + } + configurationString += char; + } else { + configurationString += char; + } + } + + match += configurationString; + try { + configuration = JSON.parse(configurationString); + } catch (e) { + const now = eat.now(); + this.file.fail(`Unable to parse lens JSON configuration: ${e}`, { + line: now.line, + column: now.column + 6, + }); + } + } + + match += '}'; + + return eat(match)({ + type: ID, + ...configuration, + }); + }; + + tokenizers.lens = tokenizeLens; + methods.splice(methods.indexOf('text'), 0, ID); +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx new file mode 100644 index 0000000000000..24dde054d2d19 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx @@ -0,0 +1,464 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { first } from 'rxjs/operators'; +import { + EuiFieldText, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiMarkdownEditorUiPlugin, + EuiMarkdownContext, + EuiCodeBlock, + EuiSpacer, + EuiModalFooter, + EuiButtonEmpty, + EuiButton, + EuiFlexItem, + EuiFlexGroup, + EuiFormRow, + EuiMarkdownAstNodePosition, + EuiBetaBadge, +} from '@elastic/eui'; +import React, { ReactNode, useCallback, useContext, useMemo, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useLocation } from 'react-router-dom'; +import styled from 'styled-components'; + +import type { TypedLensByValueInput } from '../../../../../../lens/public'; +import { useKibana } from '../../../../common/lib/kibana'; +import { LensMarkDownRenderer } from './processor'; +import { DRAFT_COMMENT_STORAGE_ID, ID } from './constants'; +import { CommentEditorContext } from '../../context'; +import { ModalContainer } from './modal_container'; +import type { EmbeddablePackageState } from '../../../../../../../../src/plugins/embeddable/public'; +import { + SavedObjectFinderUi, + SavedObjectFinderUiProps, +} from '../../../../../../../../src/plugins/saved_objects/public'; +import { useLensDraftComment } from './use_lens_draft_comment'; + +const BetaBadgeWrapper = styled.span` + display: inline-flex; + + .euiToolTipAnchor { + display: inline-flex; + } +`; + +type LensIncomingEmbeddablePackage = Omit & { + input: TypedLensByValueInput; +}; + +type LensEuiMarkdownEditorUiPlugin = EuiMarkdownEditorUiPlugin<{ + title: string; + timeRange: TypedLensByValueInput['timeRange']; + startDate: string; + endDate: string; + position: EuiMarkdownAstNodePosition; + attributes: TypedLensByValueInput['attributes']; +}>; + +interface LensSavedObjectsPickerProps { + children: ReactNode; + onChoose: SavedObjectFinderUiProps['onChoose']; +} + +const LensSavedObjectsPickerComponent: React.FC = ({ + children, + onChoose, +}) => { + const { savedObjects, uiSettings } = useKibana().services; + + const savedObjectMetaData = useMemo( + () => [ + { + type: 'lens', + getIconForSavedObject: () => 'lensApp', + name: i18n.translate( + 'xpack.cases.markdownEditor.plugins.lens.insertLensSavedObjectModal.searchSelection.savedObjectType.lens', + { + defaultMessage: 'Lens', + } + ), + includeFields: ['*'], + }, + ], + [] + ); + + return ( + + } + savedObjectMetaData={savedObjectMetaData} + fixedPageSize={10} + uiSettings={uiSettings} + savedObjects={savedObjects} + children={children} + /> + ); +}; + +export const LensSavedObjectsPicker = React.memo(LensSavedObjectsPickerComponent); + +const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({ + node, + onCancel, + onSave, +}) => { + const location = useLocation(); + const { + application: { currentAppId$ }, + embeddable, + lens, + storage, + data: { + query: { + timefilter: { timefilter }, + }, + }, + } = useKibana().services; + const [currentAppId, setCurrentAppId] = useState(undefined); + + const { draftComment, clearDraftComment } = useLensDraftComment(); + + const [nodePosition, setNodePosition] = useState( + undefined + ); + // const [editMode, setEditMode] = useState(!!node); + const [lensEmbeddableAttributes, setLensEmbeddableAttributes] = useState< + TypedLensByValueInput['attributes'] | null + >(node?.attributes || null); + const [timeRange, setTimeRange] = useState( + node?.timeRange ?? { + from: 'now-7d', + to: 'now', + mode: 'relative', + } + ); + const commentEditorContext = useContext(CommentEditorContext); + const markdownContext = useContext(EuiMarkdownContext); + + const handleTitleChange = useCallback((e) => { + const title = e.target.value ?? ''; + setLensEmbeddableAttributes((currentValue) => { + if (currentValue) { + return { ...currentValue, title } as TypedLensByValueInput['attributes']; + } + + return currentValue; + }); + }, []); + + const handleClose = useCallback(() => { + if (currentAppId) { + embeddable?.getStateTransfer().getIncomingEmbeddablePackage(currentAppId, true); + clearDraftComment(); + } + onCancel(); + }, [clearDraftComment, currentAppId, embeddable, onCancel]); + + const handleAdd = useCallback(() => { + if (nodePosition) { + markdownContext.replaceNode( + nodePosition, + `!{${ID}${JSON.stringify({ + timeRange, + attributes: lensEmbeddableAttributes, + })}}` + ); + + handleClose(); + return; + } + + if (lensEmbeddableAttributes) { + onSave( + `!{${ID}${JSON.stringify({ + timeRange, + attributes: lensEmbeddableAttributes, + })}}`, + { + block: true, + } + ); + } + + handleClose(); + }, [nodePosition, lensEmbeddableAttributes, handleClose, markdownContext, timeRange, onSave]); + + const handleDelete = useCallback(() => { + if (nodePosition) { + markdownContext.replaceNode(nodePosition, ``); + onCancel(); + } + }, [markdownContext, nodePosition, onCancel]); + + const originatingPath = useMemo(() => `${location.pathname}${location.search}`, [ + location.pathname, + location.search, + ]); + + const handleEditInLensClick = useCallback( + async (lensAttributes?) => { + storage.set(DRAFT_COMMENT_STORAGE_ID, { + commentId: commentEditorContext?.editorId, + comment: commentEditorContext?.value, + position: node?.position, + title: lensEmbeddableAttributes?.title, + }); + + lens?.navigateToPrefilledEditor( + lensAttributes || lensEmbeddableAttributes + ? { + id: '', + timeRange, + attributes: lensAttributes ?? lensEmbeddableAttributes, + } + : undefined, + { + originatingApp: currentAppId!, + originatingPath, + } + ); + }, + [ + storage, + commentEditorContext?.editorId, + commentEditorContext?.value, + node?.position, + lens, + lensEmbeddableAttributes, + timeRange, + currentAppId, + originatingPath, + ] + ); + + const handleChooseLensSO = useCallback( + (savedObjectId, savedObjectType, fullName, savedObject) => { + handleEditInLensClick({ + ...savedObject.attributes, + title: '', + references: savedObject.references, + }); + }, + [handleEditInLensClick] + ); + + useEffect(() => { + if (node?.attributes) { + setLensEmbeddableAttributes(node.attributes); + } + }, [node?.attributes]); + + useEffect(() => { + const position = node?.position || draftComment?.position; + if (position) { + setNodePosition(position); + } + }, [node?.position, draftComment?.position]); + + useEffect(() => { + const getCurrentAppId = async () => { + const appId = await currentAppId$.pipe(first()).toPromise(); + setCurrentAppId(appId); + }; + getCurrentAppId(); + }, [currentAppId$]); + + useEffect(() => { + let incomingEmbeddablePackage; + + if (currentAppId) { + incomingEmbeddablePackage = embeddable + ?.getStateTransfer() + .getIncomingEmbeddablePackage(currentAppId, true) as LensIncomingEmbeddablePackage; + } + + if ( + incomingEmbeddablePackage?.type === 'lens' && + incomingEmbeddablePackage?.input?.attributes + ) { + const attributesTitle = incomingEmbeddablePackage?.input.attributes.title.length + ? incomingEmbeddablePackage?.input.attributes.title + : null; + setLensEmbeddableAttributes({ + ...incomingEmbeddablePackage?.input.attributes, + title: attributesTitle ?? draftComment?.title ?? '', + }); + + const lensTime = timefilter.getTime(); + if (lensTime?.from && lensTime?.to) { + setTimeRange({ + from: lensTime.from, + to: lensTime.to, + mode: [lensTime.from, lensTime.to].join('').includes('now') ? 'relative' : 'absolute', + }); + } + } + }, [embeddable, storage, timefilter, currentAppId, draftComment?.title]); + + return ( + + + + + + {!!nodePosition ? ( + + ) : ( + + )} + + + + + + + + + + + {lensEmbeddableAttributes ? ( + <> + + + + + + + + + + + + + + + + ) : ( + + + + + + + + )} + + + + + + {!!nodePosition ? ( + + + + ) : null} + + {!!nodePosition ? ( + + ) : ( + + )} + + + + ); +}; + +export const LensEditor = React.memo(LensEditorComponent); + +export const plugin: LensEuiMarkdownEditorUiPlugin = { + name: ID, + button: { + label: i18n.translate('xpack.cases.markdownEditor.plugins.lens.insertLensButtonLabel', { + defaultMessage: 'Insert visualization', + }), + iconType: 'lensApp', + }, + helpText: ( + + {'!{lens}'} + + ), + editor: LensEditor, +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx new file mode 100644 index 0000000000000..cc8ef07392670 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { first } from 'rxjs/operators'; +import React, { useCallback, useEffect, useState } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { useLocation } from 'react-router-dom'; + +import { createGlobalStyle } from '../../../../../../../../src/plugins/kibana_react/common'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { useKibana } from '../../../../common/lib/kibana'; +import { LENS_VISUALIZATION_HEIGHT } from './constants'; + +const Container = styled.div` + min-height: ${LENS_VISUALIZATION_HEIGHT}px; +`; + +// when displaying chart in modal the tooltip is render under the modal +const LensChartTooltipFix = createGlobalStyle` + div.euiOverlayMask.euiOverlayMask--aboveHeader ~ [id^='echTooltipPortal'] { + z-index: ${({ theme }) => theme.eui.euiZLevel7} !important; + } +`; + +interface LensMarkDownRendererProps { + attributes: TypedLensByValueInput['attributes'] | null; + id?: string | null; + timeRange?: TypedLensByValueInput['timeRange']; + startDate?: string | null; + endDate?: string | null; + viewMode?: boolean | undefined; +} + +const LensMarkDownRendererComponent: React.FC = ({ + attributes, + timeRange, + viewMode = true, +}) => { + const location = useLocation(); + const { + application: { currentAppId$ }, + lens: { EmbeddableComponent, navigateToPrefilledEditor, canUseEditor }, + } = useKibana().services; + const [currentAppId, setCurrentAppId] = useState(undefined); + + const handleClick = useCallback(() => { + const options = viewMode + ? { + openInNewTab: true, + } + : { + originatingApp: currentAppId, + originatingPath: `${location.pathname}${location.search}`, + }; + + if (attributes) { + navigateToPrefilledEditor( + { + id: '', + timeRange, + attributes, + }, + options + ); + } + }, [ + attributes, + currentAppId, + location.pathname, + location.search, + navigateToPrefilledEditor, + timeRange, + viewMode, + ]); + + useEffect(() => { + const getCurrentAppId = async () => { + const appId = await currentAppId$.pipe(first()).toPromise(); + setCurrentAppId(appId); + }; + getCurrentAppId(); + }, [currentAppId$]); + + return ( + + {attributes ? ( + <> + + + +
{attributes.title}
+
+
+ + {viewMode && canUseEditor() ? ( + + {`Open visualization`} + + ) : null} + +
+ + + + + + + ) : null} +
+ ); +}; + +export const LensMarkDownRenderer = React.memo(LensMarkDownRendererComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/translations.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/translations.ts new file mode 100644 index 0000000000000..8b09b88136054 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INSERT_LENS = i18n.translate( + 'xpack.cases.markdownEditor.plugins.lens.insertLensButtonLabel', + { + defaultMessage: 'Insert visualization', + } +); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_draft_comment.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_draft_comment.ts new file mode 100644 index 0000000000000..e615416b2a137 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_draft_comment.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiMarkdownAstNodePosition } from '@elastic/eui'; +import { useCallback, useEffect, useState } from 'react'; +import { first } from 'rxjs/operators'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DRAFT_COMMENT_STORAGE_ID } from './constants'; +import { INSERT_LENS } from './translations'; + +interface DraftComment { + commentId: string; + comment: string; + position: EuiMarkdownAstNodePosition; + title: string; +} + +export const useLensDraftComment = () => { + const { + application: { currentAppId$ }, + embeddable, + storage, + } = useKibana().services; + const [draftComment, setDraftComment] = useState(null); + + useEffect(() => { + const fetchDraftComment = async () => { + const currentAppId = await currentAppId$.pipe(first()).toPromise(); + + if (!currentAppId) { + return; + } + + const incomingEmbeddablePackage = embeddable + ?.getStateTransfer() + .getIncomingEmbeddablePackage(currentAppId); + + if (incomingEmbeddablePackage) { + if (storage.get(DRAFT_COMMENT_STORAGE_ID)) { + try { + setDraftComment(storage.get(DRAFT_COMMENT_STORAGE_ID)); + // eslint-disable-next-line no-empty + } catch (e) {} + } + } + }; + fetchDraftComment(); + }, [currentAppId$, embeddable, storage]); + + const openLensModal = useCallback(({ editorRef }) => { + if (editorRef && editorRef.textarea && editorRef.toolbar) { + const lensPluginButton = editorRef.toolbar?.querySelector(`[aria-label="${INSERT_LENS}"]`); + if (lensPluginButton) { + lensPluginButton.click(); + } + } + }, []); + + const clearDraftComment = useCallback(() => { + storage.remove(DRAFT_COMMENT_STORAGE_ID); + }, [storage]); + + return { draftComment, openLensModal, clearDraftComment }; +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/types.ts b/x-pack/plugins/cases/public/components/markdown_editor/types.ts index ccc3c59c8977e..33249c0025f8e 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/types.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/types.ts @@ -22,7 +22,7 @@ export type TemporaryProcessingPluginsType = [ [ typeof rehype2react, Parameters[0] & { - components: { a: FunctionComponent; timeline: unknown }; + components: { a: FunctionComponent; lens: unknown; timeline: unknown }; } ], ...PluggableList diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts index e98af8bca8bce..b87b9ae6ad09a 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts @@ -13,8 +13,11 @@ import { import { useMemo } from 'react'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { TemporaryProcessingPluginsType } from './types'; +import { KibanaServices } from '../../common/lib/kibana'; +import * as lensMarkdownPlugin from './plugins/lens'; export const usePlugins = () => { + const kibanaConfig = KibanaServices.getConfig(); const timelinePlugins = useTimelineContext()?.editor_plugins; return useMemo(() => { @@ -31,10 +34,18 @@ export const usePlugins = () => { processingPlugins[1][1].components.timeline = timelinePlugins.processingPluginRenderer; } + if (kibanaConfig?.markdownPlugins?.lens) { + uiPlugins.push(lensMarkdownPlugin.plugin); + } + + parsingPlugins.push(lensMarkdownPlugin.parser); + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPlugins[1][1].components.lens = lensMarkdownPlugin.renderer; + return { uiPlugins, parsingPlugins, processingPlugins, }; - }, [timelinePlugins]); + }, [kibanaConfig?.markdownPlugins?.lens, timelinePlugins]); }; diff --git a/x-pack/plugins/cases/public/components/timeline_context/index.tsx b/x-pack/plugins/cases/public/components/timeline_context/index.tsx index 727e4b64628d1..76952e638e198 100644 --- a/x-pack/plugins/cases/public/components/timeline_context/index.tsx +++ b/x-pack/plugins/cases/public/components/timeline_context/index.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { EuiMarkdownEditorUiPlugin, EuiMarkdownAstNodePosition } from '@elastic/eui'; +import { AlertConsumers } from '@kbn/rule-data-utils'; import { Plugin } from 'unified'; /** * @description - manage the plugins, hooks, and ui components needed to enable timeline functionality within the cases plugin @@ -28,6 +29,7 @@ interface TimelineProcessingPluginRendererProps { } export interface CasesTimelineIntegration { + alertConsumers?: AlertConsumers[]; editor_plugins: { parsingPlugin: Plugin; processingPluginRenderer: React.FC< @@ -43,7 +45,11 @@ export interface CasesTimelineIntegration { }; ui?: { renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; - renderTimelineDetailsPanel?: () => JSX.Element; + renderTimelineDetailsPanel?: ({ + alertConsumers, + }: { + alertConsumers?: AlertConsumers[]; + }) => JSX.Element; }; } diff --git a/x-pack/plugins/cases/public/components/user_action_tree/constants.ts b/x-pack/plugins/cases/public/components/user_action_tree/constants.ts new file mode 100644 index 0000000000000..584194be65f50 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_action_tree/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index 86247b503dff7..b7834585e7423 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -23,7 +23,7 @@ import * as i18n from './translations'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../common/lib/kibana'; -import { AddComment, AddCommentRefObject } from '../add_comment'; +import { AddComment } from '../add_comment'; import { ActionConnector, ActionsCommentRequestRt, @@ -55,6 +55,7 @@ import { UserActionTimestamp } from './user_action_timestamp'; import { UserActionUsername } from './user_action_username'; import { UserActionContentToolbar } from './user_action_content_toolbar'; import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -155,27 +156,25 @@ export const UserActionTree = React.memo( subCaseId?: string; }>(); const handlerTimeoutId = useRef(0); - const addCommentRef = useRef(null); const [initLoading, setInitLoading] = useState(true); const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { isLoadingIds, patchComment } = useUpdateComment(); const currentUser = useCurrentUser(); - const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); + const [manageMarkdownEditIds, setManageMarkdownEditIds] = useState([]); + const commentRefs = useRef>({}); + const { draftComment, openLensModal } = useLensDraftComment(); const [loadingAlertData, manualAlertsData] = useFetchAlertData( getManualAlertIdsWithNoRuleId(caseData.comments) ); - const handleManageMarkdownEditId = useCallback( - (id: string) => { - if (!manageMarkdownEditIds.includes(id)) { - setManangeMardownEditIds([...manageMarkdownEditIds, id]); - } else { - setManangeMardownEditIds(manageMarkdownEditIds.filter((myId) => id !== myId)); - } - }, - [manageMarkdownEditIds] - ); + const handleManageMarkdownEditId = useCallback((id: string) => { + setManageMarkdownEditIds((prevManageMarkdownEditIds) => + !prevManageMarkdownEditIds.includes(id) + ? prevManageMarkdownEditIds.concat(id) + : prevManageMarkdownEditIds.filter((myId) => id !== myId) + ); + }, []); const handleSaveComment = useCallback( ({ id, version }: { id: string; version: string }, content: string) => { @@ -220,8 +219,8 @@ export const UserActionTree = React.memo( (quote: string) => { const addCarrots = quote.replace(new RegExp('\r?\n', 'g'), ' \n> '); - if (addCommentRef && addCommentRef.current) { - addCommentRef.current.addQuote(`> ${addCarrots} \n`); + if (commentRefs.current[NEW_ID]) { + commentRefs.current[NEW_ID].addQuote(`> ${addCarrots} \n`); } handleOutlineComment('add-comment'); @@ -240,6 +239,7 @@ export const UserActionTree = React.memo( const MarkdownDescription = useMemo( () => ( (commentRefs.current[DESCRIPTION_ID] = element)} id={DESCRIPTION_ID} content={caseData.description} isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} @@ -255,9 +255,10 @@ export const UserActionTree = React.memo( const MarkdownNewComment = useMemo( () => ( (commentRefs.current[NEW_ID] = element)} onCommentPosted={handleUpdate} onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)} showLoading={false} @@ -357,6 +358,7 @@ export const UserActionTree = React.memo( }), children: ( (commentRefs.current[comment.id] = element)} id={comment.id} content={comment.comment} isEditable={manageMarkdownEditIds.includes(comment.id)} @@ -629,6 +631,30 @@ export const UserActionTree = React.memo( const comments = [...userActions, ...bottomActions]; + useEffect(() => { + if (draftComment?.commentId) { + setManageMarkdownEditIds((prevManageMarkdownEditIds) => { + if ( + ![NEW_ID].includes(draftComment?.commentId) && + !prevManageMarkdownEditIds.includes(draftComment?.commentId) + ) { + return [draftComment?.commentId]; + } + return prevManageMarkdownEditIds; + }); + + if ( + commentRefs.current && + commentRefs.current[draftComment.commentId] && + commentRefs.current[draftComment.commentId].editor?.textarea && + commentRefs.current[draftComment.commentId].editor?.toolbar + ) { + commentRefs.current[draftComment.commentId].setComment(draftComment.comment); + openLensModal({ editorRef: commentRefs.current[draftComment.commentId].editor }); + } + } + }, [draftComment, openLensModal]); + return ( <> diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx index cf0d6e3ea50d1..f7a6932b35856 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx @@ -6,7 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; @@ -25,84 +25,96 @@ interface UserActionMarkdownProps { onChangeEditable: (id: string) => void; onSaveContent: (content: string) => void; } -export const UserActionMarkdown = ({ - id, - content, - isEditable, - onChangeEditable, - onSaveContent, -}: UserActionMarkdownProps) => { - const initialState = { content }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const fieldName = 'content'; - const { submit } = form; +interface UserActionMarkdownRefObject { + setComment: (newComment: string) => void; +} + +export const UserActionMarkdown = forwardRef( + ({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => { + const editorRef = useRef(); + const initialState = { content }; + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + + const fieldName = 'content'; + const { setFieldValue, submit } = form; + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + }, [id, onChangeEditable]); + + const handleSaveAction = useCallback(async () => { + const { isValid, data } = await submit(); + if (isValid) { + onSaveContent(data.content); + } + onChangeEditable(id); + }, [id, onChangeEditable, onSaveContent, submit]); - const handleCancelAction = useCallback(() => { - onChangeEditable(id); - }, [id, onChangeEditable]); + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue] + ); - const handleSaveAction = useCallback(async () => { - const { isValid, data } = await submit(); - if (isValid) { - onSaveContent(data.content); - } - onChangeEditable(id); - }, [id, onChangeEditable, onSaveContent, submit]); + const EditorButtons = useMemo( + () => ( + + + + {i18n.CANCEL} + + + + + {i18n.SAVE} + + + + ), + [handleCancelAction, handleSaveAction] + ); - const renderButtons = useCallback( - ({ cancelAction, saveAction }) => ( - - - - {i18n.CANCEL} - - - - - {i18n.SAVE} - - - - ), - [] - ); + useImperativeHandle(ref, () => ({ + setComment, + editor: editorRef.current, + })); - return isEditable ? ( -
- - - ) : ( - - {content} - - ); -}; + return isEditable ? ( +
+ + + ) : ( + + {content} + + ); + } +); diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 5bfdf9b8b9509..2b4fb40545548 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -17,7 +17,7 @@ import { getRecentCasesLazy, getAllCasesSelectorModalLazy, } from './methods'; -import { ENABLE_CASE_CONNECTOR } from '../common'; +import { CasesUiConfigType, ENABLE_CASE_CONNECTOR } from '../common'; /** * @public @@ -26,7 +26,7 @@ import { ENABLE_CASE_CONNECTOR } from '../common'; export class CasesUiPlugin implements Plugin { private kibanaVersion: string; - constructor(initializerContext: PluginInitializerContext) { + constructor(private readonly initializerContext: PluginInitializerContext) { this.kibanaVersion = initializerContext.env.packageInfo.version; } public setup(core: CoreSetup, plugins: SetupPlugins) { @@ -36,7 +36,8 @@ export class CasesUiPlugin implements Plugin(); + KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config }); return { /** * Get the all cases table diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index 2b31935c3ff97..db2e5d6ab6bff 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -7,11 +7,17 @@ import { CoreStart } from 'kibana/public'; import { ReactElement } from 'react'; + +import { LensPublicStart } from '../../lens/public'; import { SecurityPluginSetup } from '../../security/public'; -import { +import type { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, } from '../../triggers_actions_ui/public'; +import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { Storage } from '../../../../src/plugins/kibana_utils/public'; + import { AllCasesProps } from './components/all_cases'; import { CaseViewProps } from './components/case_view'; import { ConfigureCasesProps } from './components/configure_cases'; @@ -25,6 +31,10 @@ export interface SetupPlugins { } export interface StartPlugins { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + lens: LensPublicStart; + storage: Storage; triggersActionsUi: TriggersActionsStart; } diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index dd1f09da5cb4a..166ae2ae65012 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -16,6 +16,7 @@ import { Logger, SavedObjectsUtils, } from '../../../../../../src/core/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; import { @@ -124,6 +125,7 @@ const addGeneratedAlerts = async ( caseService, userActionService, logger, + lensEmbeddableFactory, authorization, } = clientArgs; @@ -182,6 +184,7 @@ const addGeneratedAlerts = async ( unsecuredSavedObjectsClient, caseService, attachmentService, + lensEmbeddableFactory, }); const { @@ -241,12 +244,14 @@ async function getCombinedCase({ unsecuredSavedObjectsClient, id, logger, + lensEmbeddableFactory, }: { caseService: CasesService; attachmentService: AttachmentService; unsecuredSavedObjectsClient: SavedObjectsClientContract; id: string; logger: Logger; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; }): Promise { const [casePromise, subCasePromise] = await Promise.allSettled([ caseService.getCase({ @@ -276,6 +281,7 @@ async function getCombinedCase({ caseService, attachmentService, unsecuredSavedObjectsClient, + lensEmbeddableFactory, }); } else { throw Boom.badRequest('Sub case found without reference to collection'); @@ -291,6 +297,7 @@ async function getCombinedCase({ caseService, attachmentService, unsecuredSavedObjectsClient, + lensEmbeddableFactory, }); } } @@ -332,6 +339,7 @@ export const addComment = async ( attachmentService, user, logger, + lensEmbeddableFactory, authorization, } = clientArgs; @@ -362,6 +370,7 @@ export const addComment = async ( unsecuredSavedObjectsClient, id: caseId, logger, + lensEmbeddableFactory, }); // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts index 157dd0b410898..da505ed55313c 100644 --- a/x-pack/plugins/cases/server/client/attachments/update.ts +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -9,6 +9,7 @@ import { pick } from 'lodash/fp'; import Boom from '@hapi/boom'; import { SavedObjectsClientContract, Logger } from 'kibana/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { checkEnabledCaseConnectorOrThrow, CommentableCase, createCaseError } from '../../common'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { @@ -46,6 +47,7 @@ interface CombinedCaseParams { unsecuredSavedObjectsClient: SavedObjectsClientContract; caseID: string; logger: Logger; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; subCaseId?: string; } @@ -56,6 +58,7 @@ async function getCommentableCase({ caseID, subCaseId, logger, + lensEmbeddableFactory, }: CombinedCaseParams) { if (subCaseId) { const [caseInfo, subCase] = await Promise.all([ @@ -75,6 +78,7 @@ async function getCommentableCase({ subCase, unsecuredSavedObjectsClient, logger, + lensEmbeddableFactory, }); } else { const caseInfo = await caseService.getCase({ @@ -87,6 +91,7 @@ async function getCommentableCase({ collection: caseInfo, unsecuredSavedObjectsClient, logger, + lensEmbeddableFactory, }); } } @@ -105,6 +110,7 @@ export async function update( caseService, unsecuredSavedObjectsClient, logger, + lensEmbeddableFactory, user, userActionService, authorization, @@ -128,6 +134,7 @@ export async function update( caseID, subCaseId: subCaseID, logger, + lensEmbeddableFactory, }); const myComment = await attachmentService.get({ diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 8fcfbe934c3ad..2fae6996f4aa2 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -25,6 +25,8 @@ import { } from '../services'; import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; +import { LensServerPluginSetup } from '../../../lens/server'; + import { AuthorizationAuditLogger } from '../authorization'; import { CasesClient, createCasesClient } from '.'; @@ -34,6 +36,7 @@ interface CasesClientFactoryArgs { getSpace: GetSpaceFn; featuresPluginStart: FeaturesPluginStart; actionsPluginStart: ActionsPluginStart; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; } /** @@ -108,6 +111,7 @@ export class CasesClientFactory { userActionService: new CaseUserActionService(this.logger), attachmentService: new AttachmentService(this.logger), logger: this.logger, + lensEmbeddableFactory: this.options.lensEmbeddableFactory, authorization: auth, actionsClient: await this.options.actionsPluginStart.getActionsClientWithRequest(request), }); diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index ebf79519da59a..27829d2539c7d 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -18,6 +18,7 @@ import { AttachmentService, } from '../services'; import { ActionsClient } from '../../../actions/server'; +import { LensServerPluginSetup } from '../../../lens/server'; /** * Parameters for initializing a cases client @@ -33,6 +34,7 @@ export interface CasesClientArgs { readonly alertsService: AlertServiceContract; readonly attachmentService: AttachmentService; readonly logger: Logger; + readonly lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; readonly authorization: PublicMethodsOf; readonly actionsClient: PublicMethodsOf; } diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index 03d6e5b8cea63..856d6378d5900 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -10,9 +10,11 @@ import { SavedObject, SavedObjectReference, SavedObjectsClientContract, + SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, Logger, } from 'src/core/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { AssociationType, CASE_SAVED_OBJECT, @@ -29,12 +31,14 @@ import { SUB_CASE_SAVED_OBJECT, SubCaseAttributes, User, + CommentRequestUserType, CaseAttributes, } from '../../../common'; import { flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment } from '..'; import { AttachmentService, CasesService } from '../../services'; import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; +import { getOrUpdateLensReferences } from '../utils'; interface UpdateCommentResp { comment: SavedObjectsUpdateResponse; @@ -53,6 +57,7 @@ interface CommentableCaseParams { caseService: CasesService; attachmentService: AttachmentService; logger: Logger; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; } /** @@ -66,6 +71,7 @@ export class CommentableCase { private readonly caseService: CasesService; private readonly attachmentService: AttachmentService; private readonly logger: Logger; + private readonly lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; constructor({ collection, @@ -74,6 +80,7 @@ export class CommentableCase { caseService, attachmentService, logger, + lensEmbeddableFactory, }: CommentableCaseParams) { this.collection = collection; this.subCase = subCase; @@ -81,6 +88,7 @@ export class CommentableCase { this.caseService = caseService; this.attachmentService = attachmentService; this.logger = logger; + this.lensEmbeddableFactory = lensEmbeddableFactory; } public get status(): CaseStatuses { @@ -188,6 +196,7 @@ export class CommentableCase { caseService: this.caseService, attachmentService: this.attachmentService, logger: this.logger, + lensEmbeddableFactory: this.lensEmbeddableFactory, }); } catch (error) { throw createCaseError({ @@ -212,6 +221,23 @@ export class CommentableCase { }): Promise { try { const { id, version, ...queryRestAttributes } = updateRequest; + const options: SavedObjectsUpdateOptions = { + version, + }; + + if (queryRestAttributes.type === CommentType.user && queryRestAttributes?.comment) { + const currentComment = (await this.attachmentService.get({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + attachmentId: id, + })) as SavedObject; + + const updatedReferences = getOrUpdateLensReferences( + this.lensEmbeddableFactory, + queryRestAttributes.comment, + currentComment + ); + options.references = updatedReferences; + } const [comment, commentableCase] = await Promise.all([ this.attachmentService.update({ @@ -222,7 +248,7 @@ export class CommentableCase { updated_at: updatedAt, updated_by: user, }, - version, + options, }), this.update({ date: updatedAt, user }), ]); @@ -268,6 +294,16 @@ export class CommentableCase { throw Boom.badRequest('The owner field of the comment must match the case'); } + let references = this.buildRefsToCase(); + + if (commentReq.type === CommentType.user && commentReq?.comment) { + const commentStringReferences = getOrUpdateLensReferences( + this.lensEmbeddableFactory, + commentReq.comment + ); + references = [...references, ...commentStringReferences]; + } + const [comment, commentableCase] = await Promise.all([ this.attachmentService.create({ unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, @@ -277,7 +313,7 @@ export class CommentableCase { ...commentReq, ...user, }), - references: this.buildRefsToCase(), + references, id, }), this.update({ date: createdDate, user }), diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 46ba33a74acd6..e45b91a28ceb3 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { SavedObjectsFindResponse } from 'kibana/server'; +import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; +import { lensEmbeddableFactory } from '../../../lens/server/embeddable/lens_embeddable_factory'; import { SECURITY_SOLUTION_OWNER } from '../../common'; import { AssociationType, CaseResponse, CommentAttributes, CommentRequest, + CommentRequestUserType, CommentType, } from '../../common/api'; import { mockCaseComments, mockCases } from '../routes/api/__fixtures__/mock_saved_objects'; @@ -25,6 +27,8 @@ import { transformComments, flattenCommentSavedObjects, flattenCommentSavedObject, + extractLensReferencesFromCommentString, + getOrUpdateLensReferences, } from './utils'; interface CommentReference { @@ -865,4 +869,130 @@ describe('common utils', () => { ).toEqual(2); }); }); + + describe('extractLensReferencesFromCommentString', () => { + it('extracts successfully', () => { + const commentString = [ + '**Test** ', + 'Amazingg!!!', + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))', + '!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":[{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b246","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b248","name":"indexpattern-datasource-layer-layer1"}]},"editMode":false}}', + '!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":[{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b246","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-layer-layer1"}]},"editMode":false}}', + ].join('\n\n'); + + const extractedReferences = extractLensReferencesFromCommentString( + lensEmbeddableFactory, + commentString + ); + + const expectedReferences = [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b246', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'indexpattern-datasource-layer-layer1', + }, + ]; + + expect(expectedReferences.length).toEqual(extractedReferences.length); + expect(expectedReferences).toEqual(expect.arrayContaining(extractedReferences)); + }); + }); + + describe('getOrUpdateLensReferences', () => { + it('update references', () => { + const currentCommentStringReferences = [ + [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b246', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + ], + [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b246', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + ], + ]; + const currentCommentString = [ + '**Test** ', + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))', + `!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":${JSON.stringify( + currentCommentStringReferences[0] + )}},"editMode":false}}`, + `!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":${JSON.stringify( + currentCommentStringReferences[1] + )}},"editMode":false}}`, + ].join('\n\n'); + const nonLensCurrentCommentReferences = [ + { type: 'case', id: '7b4be181-9646-41b8-b12d-faabf1bd9512', name: 'Test case' }, + { + type: 'timeline', + id: '0f847d31-9683-4ebd-92b9-454e3e39aec1', + name: 'Test case timeline', + }, + ]; + const currentCommentReferences = [ + ...currentCommentStringReferences.flat(), + ...nonLensCurrentCommentReferences, + ]; + const newCommentStringReferences = [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b245', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + ]; + const newCommentString = [ + '**Test** ', + 'Awmazingg!!!', + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))', + `!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":${JSON.stringify( + newCommentStringReferences + )}},"editMode":false}}`, + ].join('\n\n'); + + const updatedReferences = getOrUpdateLensReferences(lensEmbeddableFactory, newCommentString, { + references: currentCommentReferences, + attributes: { + comment: currentCommentString, + }, + } as SavedObject); + + const expectedReferences = [ + ...nonLensCurrentCommentReferences, + ...newCommentStringReferences, + ]; + + expect(expectedReferences.length).toEqual(updatedReferences.length); + expect(expectedReferences).toEqual(expect.arrayContaining(updatedReferences)); + }); + }); }); diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index bce37764467df..ba7d56f51eea9 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -4,11 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import Boom from '@hapi/boom'; +import unified from 'unified'; +import type { Node, Parent } from 'unist'; +// installed by @elastic/eui +// eslint-disable-next-line import/no-extraneous-dependencies +import markdown from 'remark-parse'; +import remarkStringify from 'remark-stringify'; -import { SavedObjectsFindResult, SavedObjectsFindResponse, SavedObject } from 'kibana/server'; -import { isEmpty } from 'lodash'; +import { + SavedObjectsFindResult, + SavedObjectsFindResponse, + SavedObject, + SavedObjectReference, +} from 'kibana/server'; +import { filter, flatMap, uniqWith, isEmpty, xorWith } from 'lodash'; +import { TimeRange } from 'src/plugins/data/server'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { AlertInfo } from '.'; +import { LensServerPluginSetup, LensDocShape715 } from '../../../lens/server'; import { AssociationType, @@ -33,6 +48,8 @@ import { User, } from '../../common'; import { UpdateAlertRequest } from '../client/alerts/types'; +import { LENS_ID, LensParser, LensSerializer } from '../../common/utils/markdown_plugins/lens'; +import { TimelineSerializer, TimelineParser } from '../../common/utils/markdown_plugins/timeline'; /** * Default sort field for querying saved objects. @@ -398,3 +415,89 @@ export const getNoneCaseConnector = () => ({ type: ConnectorTypes.none, fields: null, }); + +interface LensMarkdownNode extends EmbeddableStateWithType { + timeRange: TimeRange; + attributes: LensDocShape715 & { references: SavedObjectReference[] }; +} + +export const parseCommentString = (comment: string) => { + const processor = unified().use([[markdown, {}], LensParser, TimelineParser]); + return processor.parse(comment) as Parent; +}; + +export const stringifyComment = (comment: Parent) => + unified() + .use([ + [ + remarkStringify, + { + allowDangerousHtml: true, + handlers: { + /* + because we're using rison in the timeline url we need + to make sure that markdown parser doesn't modify the url + */ + timeline: TimelineSerializer, + lens: LensSerializer, + }, + }, + ], + ]) + .stringify(comment); + +export const getLensVisualizations = (parsedComment: Array) => + filter(parsedComment, { type: LENS_ID }) as LensMarkdownNode[]; + +export const extractLensReferencesFromCommentString = ( + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'], + comment: string +): SavedObjectReference[] => { + const extract = lensEmbeddableFactory()?.extract; + + if (extract) { + const parsedComment = parseCommentString(comment); + const lensVisualizations = getLensVisualizations(parsedComment.children); + const flattenRefs = flatMap( + lensVisualizations, + (lensObject) => extract(lensObject)?.references ?? [] + ); + + const uniqRefs = uniqWith( + flattenRefs, + (refA, refB) => refA.type === refB.type && refA.id === refB.id && refA.name === refB.name + ); + + return uniqRefs; + } + return []; +}; + +export const getOrUpdateLensReferences = ( + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'], + newComment: string, + currentComment?: SavedObject +) => { + if (!currentComment) { + return extractLensReferencesFromCommentString(lensEmbeddableFactory, newComment); + } + + const savedObjectReferences = currentComment.references; + const savedObjectLensReferences = extractLensReferencesFromCommentString( + lensEmbeddableFactory, + currentComment.attributes.comment + ); + + const currentNonLensReferences = xorWith( + savedObjectReferences, + savedObjectLensReferences, + (refA, refB) => refA.type === refB.type && refA.id === refB.id + ); + + const newCommentLensReferences = extractLensReferencesFromCommentString( + lensEmbeddableFactory, + newComment + ); + + return currentNonLensReferences.concat(newCommentLensReferences); +}; diff --git a/x-pack/plugins/cases/server/config.ts b/x-pack/plugins/cases/server/config.ts index 7679a5a389051..317f15283e112 100644 --- a/x-pack/plugins/cases/server/config.ts +++ b/x-pack/plugins/cases/server/config.ts @@ -9,6 +9,9 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), + markdownPlugins: schema.object({ + lens: schema.boolean({ defaultValue: false }), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/cases/server/index.ts b/x-pack/plugins/cases/server/index.ts index 4526ecce28460..5e433b46b80e5 100644 --- a/x-pack/plugins/cases/server/index.ts +++ b/x-pack/plugins/cases/server/index.ts @@ -12,6 +12,9 @@ import { CasePlugin } from './plugin'; export const config: PluginConfigDescriptor = { schema: ConfigSchema, + exposeToBrowser: { + markdownPlugins: true, + }, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.case.enabled', 'xpack.cases.enabled'), ], diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index b1e2f61a595ee..bb1be163585a8 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -18,7 +18,7 @@ import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; import { - caseCommentSavedObjectType, + createCaseCommentSavedObjectType, caseConfigureSavedObjectType, caseConnectorMappingsSavedObjectType, caseSavedObjectType, @@ -32,6 +32,7 @@ import type { CasesRequestHandlerContext } from './types'; import { CasesClientFactory } from './client/factory'; import { SpacesPluginStart } from '../../spaces/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; +import { LensServerPluginSetup } from '../../lens/server'; function createConfig(context: PluginInitializerContext) { return context.config.get(); @@ -40,6 +41,7 @@ function createConfig(context: PluginInitializerContext) { export interface PluginsSetup { security?: SecurityPluginSetup; actions: ActionsPluginSetup; + lens: LensServerPluginSetup; } export interface PluginsStart { @@ -66,6 +68,7 @@ export class CasePlugin { private readonly log: Logger; private clientFactory: CasesClientFactory; private securityPluginSetup?: SecurityPluginSetup; + private lensEmbeddableFactory?: LensServerPluginSetup['lensEmbeddableFactory']; constructor(private readonly initializerContext: PluginInitializerContext) { this.log = this.initializerContext.logger.get(); @@ -80,8 +83,15 @@ export class CasePlugin { } this.securityPluginSetup = plugins.security; + this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory; - core.savedObjects.registerType(caseCommentSavedObjectType); + core.savedObjects.registerType( + createCaseCommentSavedObjectType({ + migrationDeps: { + lensEmbeddableFactory: this.lensEmbeddableFactory, + }, + }) + ); core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); @@ -127,6 +137,7 @@ export class CasePlugin { }, featuresPluginStart: plugins.features, actionsPluginStart: plugins.actions, + lensEmbeddableFactory: this.lensEmbeddableFactory!, }); const client = core.elasticsearch.client; diff --git a/x-pack/plugins/cases/server/saved_object_types/comments.ts b/x-pack/plugins/cases/server/saved_object_types/comments.ts index 876ceb9bc2045..0384a65dcb389 100644 --- a/x-pack/plugins/cases/server/saved_object_types/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/comments.ts @@ -7,11 +7,15 @@ import { SavedObjectsType } from 'src/core/server'; import { CASE_COMMENT_SAVED_OBJECT } from '../../common'; -import { commentsMigrations } from './migrations'; +import { createCommentsMigrations, CreateCommentsMigrationsDeps } from './migrations'; -export const caseCommentSavedObjectType: SavedObjectsType = { +export const createCaseCommentSavedObjectType = ({ + migrationDeps, +}: { + migrationDeps: CreateCommentsMigrationsDeps; +}): SavedObjectsType => ({ name: CASE_COMMENT_SAVED_OBJECT, - hidden: true, + hidden: false, namespaceType: 'single', mappings: { properties: { @@ -105,5 +109,5 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, - migrations: commentsMigrations, -}; + migrations: () => createCommentsMigrations(migrationDeps), +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/index.ts b/x-pack/plugins/cases/server/saved_object_types/index.ts index 1c6bcf6ca710a..2c39a10f61da7 100644 --- a/x-pack/plugins/cases/server/saved_object_types/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/index.ts @@ -8,6 +8,6 @@ export { caseSavedObjectType } from './cases'; export { subCaseSavedObjectType } from './sub_case'; export { caseConfigureSavedObjectType } from './configure'; -export { caseCommentSavedObjectType } from './comments'; +export { createCaseCommentSavedObjectType } from './comments'; export { caseUserActionSavedObjectType } from './user_actions'; export { caseConnectorMappingsSavedObjectType } from './connector_mappings'; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/index.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.test.ts new file mode 100644 index 0000000000000..595ecf290c520 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.test.ts @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createCommentsMigrations } from './index'; +import { getLensVisualizations, parseCommentString } from '../../common'; + +import { savedObjectsServiceMock } from '../../../../../../src/core/server/mocks'; +import { lensEmbeddableFactory } from '../../../../lens/server/embeddable/lens_embeddable_factory'; + +const migrations = createCommentsMigrations({ + lensEmbeddableFactory, +}); + +const contextMock = savedObjectsServiceMock.createMigrationContext(); + +describe('lens embeddable migrations for by value panels', () => { + describe('7.14.0 remove time zone from Lens visualization date histogram', () => { + const lensVisualizationToMigrate = { + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }; + + const expectedLensVisualizationMigrated = { + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }; + + const expectedMigrationCommentResult = `"**Amazing**\n\n!{tooltip[Tessss](https://example.com)}\n\nbrbrbr\n\n[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"attributes\":${JSON.stringify( + expectedLensVisualizationMigrated + )}}}\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"attributes\":{\"title\":\"TEst22\",\"type\":\"lens\",\"visualizationType\":\"lnsMetric\",\"state\":{\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"layer1\":{\"columnOrder\":[\"col2\"],\"columns\":{\"col2\":{\"dataType\":\"number\",\"isBucketed\":false,\"label\":\"Count of records\",\"operationType\":\"count\",\"scale\":\"ratio\",\"sourceField\":\"Records\"}}}}}},\"visualization\":{\"layerId\":\"layer1\",\"accessor\":\"col2\"},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[]},\"references\":[{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-layer-layer1\"}]}}}\n\nbrbrbr" +`; + + const caseComment = { + type: 'cases-comments', + id: '1cefd0d0-e86d-11eb-bae5-3d065cd16a32', + attributes: { + associationType: 'case', + comment: `"**Amazing**\n\n!{tooltip[Tessss](https://example.com)}\n\nbrbrbr\n\n[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"editMode\":false,\"attributes\":${JSON.stringify( + lensVisualizationToMigrate + )}}}\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"editMode\":false,\"attributes\":{\"title\":\"TEst22\",\"type\":\"lens\",\"visualizationType\":\"lnsMetric\",\"state\":{\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"layer1\":{\"columnOrder\":[\"col2\"],\"columns\":{\"col2\":{\"dataType\":\"number\",\"isBucketed\":false,\"label\":\"Count of records\",\"operationType\":\"count\",\"scale\":\"ratio\",\"sourceField\":\"Records\"}}}}}},\"visualization\":{\"layerId\":\"layer1\",\"accessor\":\"col2\"},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[]},\"references\":[{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-layer-layer1\"}]}}}\n\nbrbrbr"`, + type: 'user', + created_at: '2021-07-19T08:41:29.951Z', + created_by: { + email: null, + full_name: null, + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2021-07-19T08:41:47.549Z', + updated_by: { + full_name: null, + email: null, + username: 'elastic', + }, + }, + references: [ + { + name: 'associated-cases', + id: '77d1b230-d35e-11eb-8da6-6f746b9cb499', + type: 'cases', + }, + { + name: 'indexpattern-datasource-current-indexpattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + type: 'index-pattern', + }, + { + name: 'indexpattern-datasource-current-indexpattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + type: 'index-pattern', + }, + ], + migrationVersion: { + 'cases-comments': '7.14.0', + }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-07-19T08:41:47.552Z', + version: 'WzgxMTY4MSw5XQ==', + namespaces: ['default'], + score: 0, + }; + + it('should remove time zone param from date histogram', () => { + expect(migrations['7.14.0']).toBeDefined(); + const result = migrations['7.14.0'](caseComment, contextMock); + + const parsedComment = parseCommentString(result.attributes.comment); + const lensVisualizations = getLensVisualizations(parsedComment.children); + + const layers = Object.values( + lensVisualizations[0].attributes.state.datasourceStates.indexpattern.layers + ); + expect(result.attributes.comment).toEqual(expectedMigrationCommentResult); + expect(layers.length).toBe(1); + const columns = Object.values(layers[0].columns); + expect(columns.length).toBe(3); + expect(columns[0].operationType).toEqual('date_histogram'); + expect((columns[0] as { params: {} }).params).toEqual({ interval: 'auto' }); + expect(columns[1].operationType).toEqual('date_histogram'); + expect((columns[1] as { params: {} }).params).toEqual({ interval: 'auto' }); + expect(columns[2].operationType).toEqual('my_unexpected_operation'); + expect((columns[2] as { params: {} }).params).toEqual({ timeZone: 'do not delete' }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts index 7be87c3abc989..b1792d98cfdb2 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts @@ -7,9 +7,19 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { flow, mapValues } from 'lodash'; +import { LensServerPluginSetup } from '../../../../lens/server'; + +import { + mergeMigrationFunctionMaps, + MigrateFunction, + MigrateFunctionsObject, +} from '../../../../../../src/plugins/kibana_utils/common'; import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, + SavedObjectMigrationFn, + SavedObjectMigrationMap, } from '../../../../../../src/core/server'; import { ConnectorTypes, @@ -17,6 +27,7 @@ import { AssociationType, SECURITY_SOLUTION_OWNER, } from '../../../common'; +import { parseCommentString, stringifyComment } from '../../common'; export { caseMigrations } from './cases'; export { configureMigrations } from './configuration'; @@ -103,44 +114,86 @@ interface SanitizedCommentForSubCases { rule?: { id: string | null; name: string | null }; } -export const commentsMigrations = { - '7.11.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - return { - ...doc, - attributes: { - ...doc.attributes, - type: CommentType.user, - }, - references: doc.references || [], - }; - }, - '7.12.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - let attributes: SanitizedCommentForSubCases & UnsanitizedComment = { - ...doc.attributes, - associationType: AssociationType.case, - }; - - // only add the rule object for alert comments. Prior to 7.12 we only had CommentType.alert, generated alerts are - // introduced in 7.12. - if (doc.attributes.type === CommentType.alert) { - attributes = { ...attributes, rule: { id: null, name: null } }; +const migrateByValueLensVisualizations = ( + migrate: MigrateFunction, + version: string +): SavedObjectMigrationFn => (doc: any) => { + const parsedComment = parseCommentString(doc.attributes.comment); + const migratedComment = parsedComment.children.map((comment) => { + if (comment?.type === 'lens') { + // @ts-expect-error + return migrate(comment); } - return { - ...doc, - attributes, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, + return comment; + }); + + // @ts-expect-error + parsedComment.children = migratedComment; + doc.attributes.comment = stringifyComment(parsedComment); + + return doc; +}; + +export interface CreateCommentsMigrationsDeps { + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; +} + +export const createCommentsMigrations = ( + migrationDeps: CreateCommentsMigrationsDeps +): SavedObjectMigrationMap => { + const embeddableMigrations = mapValues( + migrationDeps.lensEmbeddableFactory().migrations, + migrateByValueLensVisualizations + ) as MigrateFunctionsObject; + + const commentsMigrations = { + '7.11.0': flow( + ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CommentType.user, + }, + references: doc.references || [], + }; + } + ), + '7.12.0': flow( + ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + let attributes: SanitizedCommentForSubCases & UnsanitizedComment = { + ...doc.attributes, + associationType: AssociationType.case, + }; + + // only add the rule object for alert comments. Prior to 7.12 we only had CommentType.alert, generated alerts are + // introduced in 7.12. + if (doc.attributes.type === CommentType.alert) { + attributes = { ...attributes, rule: { id: null, name: null } }; + } + + return { + ...doc, + attributes, + references: doc.references || [], + }; + } + ), + '7.14.0': flow( + ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + } + ), + }; + + return mergeMigrationFunctionMaps(commentsMigrations, embeddableMigrations); }; export const connectorMappingsMigrations = { diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index c2d9b4826fc14..105b6a3125523 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { Logger, SavedObject, SavedObjectReference } from 'kibana/server'; +import { + Logger, + SavedObject, + SavedObjectReference, + SavedObjectsUpdateOptions, +} from 'kibana/server'; import { KueryNode } from '../../../../../../src/plugins/data/common'; import { @@ -38,10 +43,10 @@ interface CreateAttachmentArgs extends ClientArgs { interface UpdateArgs { attachmentId: string; updatedAttributes: AttachmentPatchAttributes; - version?: string; + options?: SavedObjectsUpdateOptions; } -type UpdateAttachmentArgs = UpdateArgs & ClientArgs; +export type UpdateAttachmentArgs = UpdateArgs & ClientArgs; interface BulkUpdateAttachmentArgs extends ClientArgs { comments: UpdateArgs[]; @@ -142,7 +147,7 @@ export class AttachmentService { unsecuredSavedObjectsClient, attachmentId, updatedAttributes, - version, + options, }: UpdateAttachmentArgs) { try { this.log.debug(`Attempting to UPDATE comment ${attachmentId}`); @@ -150,7 +155,7 @@ export class AttachmentService { CASE_COMMENT_SAVED_OBJECT, attachmentId, updatedAttributes, - { version } + options ); } catch (error) { this.log.error(`Error on UPDATE comment ${attachmentId}: ${error}`); @@ -168,7 +173,7 @@ export class AttachmentService { type: CASE_COMMENT_SAVED_OBJECT, id: c.attachmentId, attributes: c.updatedAttributes, - version: c.version, + ...c.options, })) ); } catch (error) { diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index 99622df805ced..1c9373e023366 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../../src/core/tsconfig.json" }, // optionalPlugins from ./kibana.json + { "path": "../lens/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, @@ -24,6 +25,7 @@ { "path": "../triggers_actions_ui/tsconfig.json"}, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/saved_objects/tsconfig.json" } ] } diff --git a/x-pack/plugins/cross_cluster_replication/kibana.json b/x-pack/plugins/cross_cluster_replication/kibana.json index f130d0173cc89..0a594cf1cc2ac 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.json +++ b/x-pack/plugins/cross_cluster_replication/kibana.json @@ -3,6 +3,10 @@ "version": "kibana", "server": true, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": [ "home", "licensing", @@ -12,13 +16,7 @@ "indexManagement", "features" ], - "optionalPlugins": [ - "usageCollection" - ], + "optionalPlugins": ["usageCollection"], "configPath": ["xpack", "ccr"], - "requiredBundles": [ - "kibanaReact", - "esUiShared", - "data" - ] + "requiredBundles": ["kibanaReact", "esUiShared", "data"] } diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index da83ded471d0b..d678921e9ac7b 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -3,6 +3,10 @@ "id": "dataEnhanced", "version": "8.0.0", "kibanaVersion": "kibana", + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "configPath": ["xpack", "data_enhanced"], "requiredPlugins": ["bfetch", "data", "features", "management", "share", "taskManager"], "optionalPlugins": ["kibanaUtils", "usageCollection", "security"], diff --git a/x-pack/plugins/drilldowns/url_drilldown/kibana.json b/x-pack/plugins/drilldowns/url_drilldown/kibana.json index 9bdd13fbfea26..a4552d201f263 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/kibana.json +++ b/x-pack/plugins/drilldowns/url_drilldown/kibana.json @@ -3,6 +3,10 @@ "version": "kibana", "server": false, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "requiredPlugins": ["embeddable", "uiActions", "uiActionsEnhanced"], "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/x-pack/plugins/embeddable_enhanced/kibana.json b/x-pack/plugins/embeddable_enhanced/kibana.json index 8d49e3e26eb7b..09416ce18aecb 100644 --- a/x-pack/plugins/embeddable_enhanced/kibana.json +++ b/x-pack/plugins/embeddable_enhanced/kibana.json @@ -3,5 +3,9 @@ "version": "kibana", "server": false, "ui": true, + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, "requiredPlugins": ["embeddable", "kibanaReact", "uiActions", "uiActionsEnhanced"] } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_requests_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_requests_table.test.tsx index 80c72235f7a4a..c9a540b9bf72b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_requests_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_requests_table.test.tsx @@ -43,6 +43,9 @@ const values: { domains: CrawlerDomain[]; crawlRequests: CrawlRequest[] } = { rule: CrawlerRules.regex, pattern: '.*', }, + deduplicationEnabled: false, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }, ], crawlRequests: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.scss new file mode 100644 index 0000000000000..6190a0beb91bc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.scss @@ -0,0 +1,14 @@ +.deduplicationPanel { + .selectableWrapper { + padding: $euiSize; + border-radius: $euiSize *.675; + border: $euiBorderThin solid $euiColorLightestShade; + } + + .showAllFieldsPopoverToggle { + .euiButtonEmpty__content { + padding-left: $euiSizeM; + padding-right: $euiSizeM; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.test.tsx new file mode 100644 index 0000000000000..9c076c5550a34 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.test.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { act } from 'react-dom/test-utils'; + +import { + EuiButton, + EuiButtonEmpty, + EuiContextMenuItem, + EuiPopover, + EuiSelectable, + EuiSelectableList, + EuiSelectableSearch, + EuiSwitch, +} from '@elastic/eui'; + +import { mountWithIntl, rerender } from '../../../../../test_helpers'; + +import { DataPanel } from '../../../data_panel'; + +import { DeduplicationPanel } from './deduplication_panel'; + +const MOCK_ACTIONS = { + submitDeduplicationUpdate: jest.fn(), +}; + +const MOCK_VALUES = { + domain: { + deduplicationEnabled: true, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], + }, +}; + +describe('DeduplicationPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + setMockValues(MOCK_VALUES); + }); + + it('renders an empty component if no domain', () => { + setMockValues({ + ...MOCK_VALUES, + domain: null, + }); + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('contains a button to reset to defaults', () => { + const wrapper = shallow(); + + wrapper.find(DataPanel).dive().find(EuiButton).simulate('click'); + + expect(MOCK_ACTIONS.submitDeduplicationUpdate).toHaveBeenCalledWith(MOCK_VALUES.domain, { + fields: [], + }); + }); + + it('contains a switch to enable and disable deduplication', () => { + setMockValues({ + ...MOCK_VALUES, + domain: { + ...MOCK_VALUES.domain, + deduplicationEnabled: false, + }, + }); + const wrapper = shallow(); + + wrapper.find(EuiSwitch).simulate('change'); + + expect(MOCK_ACTIONS.submitDeduplicationUpdate).toHaveBeenNthCalledWith( + 1, + { + ...MOCK_VALUES.domain, + deduplicationEnabled: false, + }, + { + enabled: true, + } + ); + + setMockValues({ + ...MOCK_VALUES, + domain: { + ...MOCK_VALUES.domain, + deduplicationEnabled: true, + }, + }); + rerender(wrapper); + + wrapper.find(EuiSwitch).simulate('change'); + + expect(MOCK_ACTIONS.submitDeduplicationUpdate).toHaveBeenNthCalledWith( + 2, + { + ...MOCK_VALUES.domain, + deduplicationEnabled: true, + }, + { + enabled: false, + fields: [], + } + ); + }); + + it('contains a popover to switch between displaying all fields or only selected ones', () => { + const fullRender = mountWithIntl(); + + expect(fullRender.find(EuiButtonEmpty).text()).toEqual('All fields'); + expect(fullRender.find(EuiPopover).prop('isOpen')).toEqual(false); + + // Open the popover + fullRender.find(EuiButtonEmpty).simulate('click'); + rerender(fullRender); + + expect(fullRender.find(EuiPopover).prop('isOpen')).toEqual(true); + + // Click "Show selected fields" + fullRender.find(EuiContextMenuItem).at(1).simulate('click'); + rerender(fullRender); + + expect(fullRender.find(EuiButtonEmpty).text()).toEqual('Selected fields'); + expect(fullRender.find(EuiPopover).prop('isOpen')).toEqual(false); + + // Open the popover and click "show all fields" + fullRender.find(EuiButtonEmpty).simulate('click'); + fullRender.find(EuiContextMenuItem).at(0).simulate('click'); + rerender(fullRender); + + expect(fullRender.find(EuiButtonEmpty).text()).toEqual('All fields'); + expect(fullRender.find(EuiPopover).prop('isOpen')).toEqual(false); + + // Open the popover then simulate closing the popover + fullRender.find(EuiButtonEmpty).simulate('click'); + act(() => { + fullRender.find(EuiPopover).prop('closePopover')(); + }); + rerender(fullRender); + + expect(fullRender.find(EuiPopover).prop('isOpen')).toEqual(false); + }); + + it('contains a selectable to toggle fields for deduplication', () => { + const wrapper = shallow(); + + wrapper + .find(EuiSelectable) + .simulate('change', [{ label: 'title' }, { label: 'description', checked: 'on' }]); + + expect(MOCK_ACTIONS.submitDeduplicationUpdate).toHaveBeenCalledWith(MOCK_VALUES.domain, { + fields: ['description'], + }); + + const fullRender = mountWithIntl(); + + expect(fullRender.find(EuiSelectableSearch)).toHaveLength(1); + expect(fullRender.find(EuiSelectableList)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx new file mode 100644 index 0000000000000..a25583f91763e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/deduplication_panel.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPopover, + EuiSelectable, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; + +import { EuiSelectableLIOption } from '@elastic/eui/src/components/selectable/selectable_option'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DOCS_PREFIX } from '../../../../routes'; +import { DataPanel } from '../../../data_panel'; +import { CrawlerSingleDomainLogic } from '../../crawler_single_domain_logic'; + +import { getCheckedOptionLabels, getSelectableOptions } from './utils'; + +import './deduplication_panel.scss'; + +export const DeduplicationPanel: React.FC = () => { + const { domain } = useValues(CrawlerSingleDomainLogic); + const { submitDeduplicationUpdate } = useActions(CrawlerSingleDomainLogic); + + const [showAllFields, setShowAllFields] = useState(true); + const [showAllFieldsPopover, setShowAllFieldsPopover] = useState(false); + + if (!domain) { + return null; + } + + const { deduplicationEnabled, deduplicationFields } = domain; + + const selectableOptions = getSelectableOptions(domain, showAllFields); + + return ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.title', { + defaultMessage: 'Duplicate document handling', + })} + + } + action={ + submitDeduplicationUpdate(domain, { fields: [] })} + disabled={deduplicationFields.length === 0} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.resetToDefaultsButtonLabel', + { + defaultMessage: 'Reset to defaults', + } + )} + + } + subtitle={ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.learnMoreMessage', + { + defaultMessage: 'Learn more about content hashing', + } + )} + + ), + }} + /> + } + > + + deduplicationEnabled + ? submitDeduplicationUpdate(domain, { enabled: false, fields: [] }) + : submitDeduplicationUpdate(domain, { enabled: true }) + } + /> + + + +
+ + submitDeduplicationUpdate(domain, { + fields: getCheckedOptionLabels(options as Array>), + }) + } + searchable + searchProps={{ + disabled: !deduplicationEnabled, + append: ( + setShowAllFieldsPopover(!showAllFieldsPopover)} + className="showAllFieldsPopoverToggle" + disabled={!deduplicationEnabled} + > + {showAllFields + ? i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.allFieldsLabel', + { + defaultMessage: 'All fields', + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.selectedFieldsLabel', + { + defaultMessage: 'Selected fields', + } + )} + + } + isOpen={showAllFieldsPopover} + closePopover={() => setShowAllFieldsPopover(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + { + setShowAllFields(true); + setShowAllFieldsPopover(false); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.deduplicationPanel.showAllFieldsButtonLabel', + { + defaultMessage: 'Show all fields', + } + )} + , + { + setShowAllFields(false); + setShowAllFieldsPopover(false); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlerStatusIndicator.showSelectedFieldsButtonLabel', + { + defaultMessage: 'Show only selected fields', + } + )} + , + ]} + /> + + ), + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/index.ts new file mode 100644 index 0000000000000..23545e91a7a69 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DeduplicationPanel } from './deduplication_panel'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/utils.test.ts new file mode 100644 index 0000000000000..58d8e1effa159 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/utils.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSelectableLIOption } from '@elastic/eui/src/components/selectable/selectable_option'; + +import { CrawlerDomain } from '../../types'; + +import { getCheckedOptionLabels, getSelectableOptions } from './utils'; + +describe('getCheckedOptionLabels', () => { + it('returns the labels of selected options', () => { + const options = [{ label: 'title' }, { label: 'description', checked: 'on' }] as Array< + EuiSelectableLIOption + >; + + expect(getCheckedOptionLabels(options)).toEqual(['description']); + }); +}); + +describe('getSelectableOptions', () => { + it('returns all available fields when we want all fields', () => { + expect( + getSelectableOptions( + { + availableDeduplicationFields: ['title', 'description'], + deduplicationFields: ['title'], + deduplicationEnabled: true, + } as CrawlerDomain, + true + ) + ).toEqual([ + { label: 'title', checked: 'on' }, + { label: 'description', checked: undefined }, + ]); + }); + + it('can returns only selected fields', () => { + expect( + getSelectableOptions( + { + availableDeduplicationFields: ['title', 'description'], + deduplicationFields: ['title'], + deduplicationEnabled: true, + } as CrawlerDomain, + false + ) + ).toEqual([{ label: 'title', checked: 'on' }]); + }); + + it('disables all options when deduplication is disabled', () => { + expect( + getSelectableOptions( + { + availableDeduplicationFields: ['title', 'description'], + deduplicationFields: ['title'], + deduplicationEnabled: false, + } as CrawlerDomain, + true + ) + ).toEqual([ + { label: 'title', checked: 'on', disabled: true }, + { label: 'description', checked: undefined, disabled: true }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/utils.ts new file mode 100644 index 0000000000000..f0ef7ece0c6a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/deduplication_panel/utils.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiSelectableLIOption } from '@elastic/eui/src/components/selectable/selectable_option'; + +import { CrawlerDomain } from '../../types'; + +export const getSelectableOptions = ( + domain: CrawlerDomain, + showAllFields: boolean +): Array> => { + const { availableDeduplicationFields, deduplicationFields, deduplicationEnabled } = domain; + + let selectableOptions: Array>; + + if (showAllFields) { + selectableOptions = availableDeduplicationFields.map((field) => ({ + label: field, + checked: deduplicationFields.includes(field) ? 'on' : undefined, + })); + } else { + selectableOptions = availableDeduplicationFields + .filter((field) => deduplicationFields.includes(field)) + .map((field) => ({ label: field, checked: 'on' })); + } + + if (!deduplicationEnabled) { + selectableOptions = selectableOptions.map((option) => ({ ...option, disabled: true })); + } + + return selectableOptions; +}; + +export const getCheckedOptionLabels = (options: Array>): string[] => { + return options.filter((option) => option.checked).map((option) => option.label); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/delete_domain_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/delete_domain_panel.tsx index 084d9693fe279..6b8377775021c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/delete_domain_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/delete_domain_panel.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useActions, useValues } from 'kea'; -import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiButton, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -27,6 +27,14 @@ export const DeleteDomainPanel: React.FC = ({}) => { return ( <> + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.deleteDomainPanel.title', { + defaultMessage: 'Delete domain', + })} +

+
+

{ @@ -23,7 +25,7 @@ describe('EntryPointsTable', () => { { id: '1', value: '/whatever' }, { id: '2', value: '/foo' }, ]; - const domain = { + const domain: CrawlerDomain = { createdOn: '2018-01-01T00:00:00.000Z', documentCount: 10, id: '6113e1407a2f2e6f42489794', @@ -31,6 +33,9 @@ describe('EntryPointsTable', () => { crawlRules: [], entryPoints, sitemaps: [], + deduplicationEnabled: true, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }; beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx index 8d7aa83cd2ec6..8bfc5cdc45e4e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/sitemaps_table.test.tsx @@ -34,6 +34,9 @@ describe('SitemapsTable', () => { crawlRules: [], entryPoints: [], sitemaps, + deduplicationEnabled: true, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }; beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx index 8e8ed0d4c9258..97c7a3e47ae59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -47,6 +47,9 @@ const domains: CrawlerDomainFromServer[] = [ rule: CrawlerRules.regex, pattern: '.*', }, + deduplication_enabled: false, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], }, { id: 'y', @@ -57,6 +60,9 @@ const domains: CrawlerDomainFromServer[] = [ sitemaps: [], entry_points: [], crawl_rules: [], + deduplication_enabled: false, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts index 86f6e14631329..97a050152a543 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts @@ -52,6 +52,9 @@ const MOCK_SERVER_CRAWLER_DATA: CrawlerDataFromServer = { sitemaps: [], entry_points: [], crawl_rules: [], + deduplication_enabled: false, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], }, ], }; @@ -112,6 +115,9 @@ describe('CrawlerOverviewLogic', () => { entryPoints: [], crawlRules: [], defaultCrawlRule: DEFAULT_CRAWL_RULE, + deduplicationEnabled: false, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }, ], }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.test.tsx index 903068e28c39a..76612ee913c48 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.test.tsx @@ -13,15 +13,13 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiCode } from '@elastic/eui'; - import { getPageHeaderActions } from '../../../test_helpers'; import { CrawlerStatusBanner } from './components/crawler_status_banner'; import { CrawlerStatusIndicator } from './components/crawler_status_indicator/crawler_status_indicator'; +import { DeduplicationPanel } from './components/deduplication_panel'; import { DeleteDomainPanel } from './components/delete_domain_panel'; import { ManageCrawlsPopover } from './components/manage_crawls_popover/manage_crawls_popover'; -import { CrawlerOverview } from './crawler_overview'; import { CrawlerSingleDomain } from './crawler_single_domain'; const MOCK_VALUES = { @@ -53,7 +51,6 @@ describe('CrawlerSingleDomain', () => { const wrapper = shallow(); expect(wrapper.find(DeleteDomainPanel)).toHaveLength(1); - expect(wrapper.find(EuiCode).render().text()).toContain('https://elastic.co'); expect(wrapper.prop('pageHeader').pageTitle).toEqual('https://elastic.co'); }); @@ -71,20 +68,32 @@ describe('CrawlerSingleDomain', () => { }); it('contains a crawler status banner', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(CrawlerStatusBanner)).toHaveLength(1); }); it('contains a crawler status indicator', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(getPageHeaderActions(wrapper).find(CrawlerStatusIndicator)).toHaveLength(1); }); it('contains a popover to manage crawls', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(getPageHeaderActions(wrapper).find(ManageCrawlsPopover)).toHaveLength(1); }); + + it('contains a panel to manage deduplication settings', () => { + const wrapper = shallow(); + + expect(wrapper.find(DeduplicationPanel)).toHaveLength(1); + }); + + it('contains a panel to delete the domain', () => { + const wrapper = shallow(); + + expect(wrapper.find(DeleteDomainPanel)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx index b93fb8592cff8..a4b2a9709cd62 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx @@ -11,9 +11,7 @@ import { useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { EuiCode, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { EngineLogic, getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; @@ -21,6 +19,7 @@ import { AppSearchPageTemplate } from '../layout'; import { CrawlRulesTable } from './components/crawl_rules_table'; import { CrawlerStatusBanner } from './components/crawler_status_banner'; import { CrawlerStatusIndicator } from './components/crawler_status_indicator/crawler_status_indicator'; +import { DeduplicationPanel } from './components/deduplication_panel'; import { DeleteDomainPanel } from './components/delete_domain_panel'; import { EntryPointsTable } from './components/entry_points_table'; import { ManageCrawlsPopover } from './components/manage_crawls_popover/manage_crawls_popover'; @@ -76,20 +75,9 @@ export const CrawlerSingleDomain: React.FC = () => { )} - -

- {i18n.translate( - 'xpack.enterpriseSearch.appSearch.crawler.singleDomain.deleteDomainTitle', - { - defaultMessage: 'Delete domain', - } - )} -

- - - + - {JSON.stringify(domain, null, 2)} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts index 492bd363a5f2d..bf0add6df5cfe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.test.ts @@ -216,5 +216,62 @@ describe('CrawlerSingleDomainLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('error'); }); }); + + describe('submitDeduplicationUpdate', () => { + it('updates logic with data that has been converted from server to client', async () => { + jest.spyOn(CrawlerSingleDomainLogic.actions, 'onReceiveDomainData'); + http.put.mockReturnValueOnce( + Promise.resolve({ + id: '507f1f77bcf86cd799439011', + name: 'https://elastic.co', + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + deduplication_enabled: true, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], + }) + ); + + CrawlerSingleDomainLogic.actions.submitDeduplicationUpdate( + { id: '507f1f77bcf86cd799439011' } as CrawlerDomain, + { fields: ['title'], enabled: true } + ); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/crawler/domains/507f1f77bcf86cd799439011', + { + body: JSON.stringify({ deduplication_enabled: true, deduplication_fields: ['title'] }), + } + ); + expect(CrawlerSingleDomainLogic.actions.onReceiveDomainData).toHaveBeenCalledWith({ + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'https://elastic.co', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + deduplicationEnabled: true, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], + }); + }); + + it('displays any errors to the user', async () => { + http.put.mockReturnValueOnce(Promise.reject('error')); + + CrawlerSingleDomainLogic.actions.submitDeduplicationUpdate( + { id: '507f1f77bcf86cd799439011' } as CrawlerDomain, + { fields: ['title'], enabled: true } + ); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts index 78912f736926d..e9c74c864b1b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain_logic.ts @@ -29,6 +29,10 @@ interface CrawlerSingleDomainActions { updateCrawlRules(crawlRules: CrawlRule[]): { crawlRules: CrawlRule[] }; updateEntryPoints(entryPoints: EntryPoint[]): { entryPoints: EntryPoint[] }; updateSitemaps(entryPoints: Sitemap[]): { sitemaps: Sitemap[] }; + submitDeduplicationUpdate( + domain: CrawlerDomain, + payload: { fields?: string[]; enabled?: boolean } + ): { domain: CrawlerDomain; fields: string[]; enabled: boolean }; } export const CrawlerSingleDomainLogic = kea< @@ -42,6 +46,7 @@ export const CrawlerSingleDomainLogic = kea< updateCrawlRules: (crawlRules) => ({ crawlRules }), updateEntryPoints: (entryPoints) => ({ entryPoints }), updateSitemaps: (sitemaps) => ({ sitemaps }), + submitDeduplicationUpdate: (domain, { fields, enabled }) => ({ domain, fields, enabled }), }, reducers: { dataLoading: [ @@ -88,6 +93,30 @@ export const CrawlerSingleDomainLogic = kea< const domainData = crawlerDomainServerToClient(response); + actions.onReceiveDomainData(domainData); + } catch (e) { + flashAPIErrors(e); + } + }, + submitDeduplicationUpdate: async ({ domain, fields, enabled }) => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const payload = { + deduplication_enabled: enabled, + deduplication_fields: fields, + }; + + try { + const response = await http.put( + `/api/app_search/engines/${engineName}/crawler/domains/${domain.id}`, + { + body: JSON.stringify(payload), + } + ); + + const domainData = crawlerDomainServerToClient(response); + actions.onReceiveDomainData(domainData); } catch (e) { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts index 1b46e21dbcb72..932af7a6ac93b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts @@ -98,6 +98,9 @@ export interface CrawlerDomain { defaultCrawlRule?: CrawlRule; entryPoints: EntryPoint[]; sitemaps: Sitemap[]; + deduplicationEnabled: boolean; + deduplicationFields: string[]; + availableDeduplicationFields: string[]; } export interface CrawlerDomainFromServer { @@ -110,6 +113,9 @@ export interface CrawlerDomainFromServer { default_crawl_rule?: CrawlRule; entry_points: EntryPoint[]; sitemaps: Sitemap[]; + deduplication_enabled: boolean; + deduplication_fields: string[]; + available_deduplication_fields: string[]; } export interface CrawlerData { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts index e356fae46f30e..1844932bac926 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts @@ -16,6 +16,7 @@ import { CrawlerStatus, CrawlerData, CrawlRequest, + CrawlerDomain, } from './types'; import { @@ -39,7 +40,7 @@ describe('crawlerDomainServerToClient', () => { const id = '507f1f77bcf86cd799439011'; const name = 'moviedatabase.com'; - const defaultServerPayload = { + const defaultServerPayload: CrawlerDomainFromServer = { id, name, created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', @@ -47,9 +48,12 @@ describe('crawlerDomainServerToClient', () => { sitemaps: [], entry_points: [], crawl_rules: [], + deduplication_enabled: false, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], }; - const defaultClientPayload = { + const defaultClientPayload: CrawlerDomain = { id, createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', url: name, @@ -57,6 +61,9 @@ describe('crawlerDomainServerToClient', () => { sitemaps: [], entryPoints: [], crawlRules: [], + deduplicationEnabled: false, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }; expect(crawlerDomainServerToClient(defaultServerPayload)).toStrictEqual(defaultClientPayload); @@ -124,6 +131,9 @@ describe('crawlerDataServerToClient', () => { entry_points: [], crawl_rules: [], default_crawl_rule: DEFAULT_CRAWL_RULE, + deduplication_enabled: false, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], }, { id: 'y', @@ -134,6 +144,9 @@ describe('crawlerDataServerToClient', () => { sitemaps: [], entry_points: [], crawl_rules: [], + deduplication_enabled: false, + deduplication_fields: ['title'], + available_deduplication_fields: ['title', 'description'], }, ]; @@ -154,6 +167,9 @@ describe('crawlerDataServerToClient', () => { entryPoints: [], crawlRules: [], defaultCrawlRule: DEFAULT_CRAWL_RULE, + deduplicationEnabled: false, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }, { id: 'y', @@ -164,6 +180,9 @@ describe('crawlerDataServerToClient', () => { sitemaps: [], entryPoints: [], crawlRules: [], + deduplicationEnabled: false, + deduplicationFields: ['title'], + availableDeduplicationFields: ['title', 'description'], }, ]); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts index a25025dc08522..1f54db12a0217 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts @@ -29,6 +29,9 @@ export function crawlerDomainServerToClient(payload: CrawlerDomainFromServer): C crawl_rules: crawlRules, default_crawl_rule: defaultCrawlRule, entry_points: entryPoints, + deduplication_enabled: deduplicationEnabled, + deduplication_fields: deduplicationFields, + available_deduplication_fields: availableDeduplicationFields, } = payload; const clientPayload: CrawlerDomain = { @@ -39,6 +42,9 @@ export function crawlerDomainServerToClient(payload: CrawlerDomainFromServer): C crawlRules, sitemaps, entryPoints, + deduplicationEnabled, + deduplicationFields, + availableDeduplicationFields, }; if (lastCrawl) { diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/enzyme_rerender.ts b/x-pack/plugins/enterprise_search/public/applications/test_helpers/enzyme_rerender.ts index 70703b7017667..68b8791a0d087 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/enzyme_rerender.ts +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/enzyme_rerender.ts @@ -5,13 +5,13 @@ * 2.0. */ -import { ShallowWrapper } from 'enzyme'; +import { CommonWrapper } from 'enzyme'; /** * Quick and easy helper for re-rendering a React component in Enzyme * after (e.g.) updating Kea values */ -export const rerender = (wrapper: ShallowWrapper) => { +export const rerender = (wrapper: CommonWrapper) => { wrapper.setProps({}); // Re-renders wrapper.update(); // Just in case }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 98d50c5fb5cea..f575ddb19ebdc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -11,7 +11,7 @@ import { useActions, useValues } from 'kea'; import { i18n } from '@kbn/i18n'; -import { setSuccessMessage } from '../../../../../shared/flash_messages'; +import { flashSuccessToast } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; import { @@ -90,7 +90,7 @@ export const AddSource: React.FC = (props) => { const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); - setSuccessMessage(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); + flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); }; const header = ; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index a75e494aa2b1c..0aa7cbcf5f1c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -14,7 +14,7 @@ import { HttpFetchQuery } from 'src/core/public'; import { flashAPIErrors, - setSuccessMessage, + flashSuccessToast, clearFlashMessages, setErrorMessage, } from '../../../../../shared/flash_messages'; @@ -491,7 +491,7 @@ export const AddSourceLogic = kea { const { http } = mockHttpValues; const { navigateToUrl } = mockKibanaValues; - const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; const { mount } = new LogicMounter(DisplaySettingsLogic); const { searchResultConfig, exampleDocuments } = exampleResult; @@ -110,7 +110,7 @@ describe('DisplaySettingsLogic', () => { serverProps.searchResultConfig ); - expect(setSuccessMessage).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); }); it('handles empty color', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts index 556507d891dcb..28d10b1566b6c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts @@ -11,7 +11,7 @@ import { cloneDeep, isEqual, differenceBy } from 'lodash'; import { DropResult } from '@elastic/eui'; import { - setSuccessMessage, + flashSuccessToast, clearFlashMessages, flashAPIErrors, } from '../../../../../shared/flash_messages'; @@ -405,7 +405,7 @@ export const DisplaySettingsLogic = kea< } }, setServerResponseData: () => { - setSuccessMessage(SUCCESS_MESSAGE); + flashSuccessToast(SUCCESS_MESSAGE); }, toggleFieldEditorModal: () => { clearFlashMessages(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index d642900aea169..142e50d52c9db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -43,7 +43,7 @@ describe('SchemaLogic', () => { const { clearFlashMessages, flashAPIErrors, - setSuccessMessage, + flashSuccessToast, setErrorMessage, } = mockFlashMessageHelpers; const { mount } = new LogicMounter(SchemaLogic); @@ -371,7 +371,7 @@ describe('SchemaLogic', () => { } ); await nextTick(); - expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_FIELD_ADDED_MESSAGE); + expect(flashSuccessToast).toHaveBeenCalledWith(SCHEMA_FIELD_ADDED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -430,7 +430,7 @@ describe('SchemaLogic', () => { } ); await nextTick(); - expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_UPDATED_MESSAGE); + expect(flashSuccessToast).toHaveBeenCalledWith(SCHEMA_UPDATED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts index f43be974102b2..114d63a3ce142 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { ADD, UPDATE } from '../../../../../shared/constants/operations'; import { flashAPIErrors, - setSuccessMessage, + flashSuccessToast, setErrorMessage, clearFlashMessages, } from '../../../../../shared/flash_messages'; @@ -346,7 +346,7 @@ export const SchemaLogic = kea>({ body: JSON.stringify({ ...updatedSchema }), }); actions.onSchemaSetSuccess(response); - setSuccessMessage(successMessage); + flashSuccessToast(successMessage); } catch (e) { window.scrollTo(0, 0); if (isAdding) { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts index 81d2803690161..adeddb08dcb79 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts @@ -29,8 +29,7 @@ describe('SourceLogic', () => { const { clearFlashMessages, flashAPIErrors, - setSuccessMessage, - setQueuedSuccessMessage, + flashSuccessToast, setErrorMessage, } = mockFlashMessageHelpers; const { navigateToUrl } = mockKibanaValues; @@ -79,7 +78,7 @@ describe('SourceLogic', () => { ...contentSource, name: NAME, }); - expect(setSuccessMessage).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); }); it('setSearchResults', () => { @@ -391,7 +390,7 @@ describe('SourceLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); expect(http.delete).toHaveBeenCalledWith('/api/workplace_search/org/sources/123'); await promise; - expect(setQueuedSuccessMessage).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 4d145bf798160..6040f319357d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -12,9 +12,8 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_META } from '../../../shared/constants'; import { flashAPIErrors, - setSuccessMessage, + flashSuccessToast, setErrorMessage, - setQueuedSuccessMessage, clearFlashMessages, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; @@ -239,7 +238,8 @@ export const SourceLogic = kea>({ try { const response = await HttpLogic.values.http.delete(route); - setQueuedSuccessMessage( + KibanaLogic.values.navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + flashSuccessToast( i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceRemoved', { @@ -248,7 +248,6 @@ export const SourceLogic = kea>({ } ) ); - KibanaLogic.values.navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); } catch (e) { flashAPIErrors(e); } finally { @@ -256,7 +255,7 @@ export const SourceLogic = kea>({ } }, onUpdateSourceName: (name: string) => { - setSuccessMessage( + flashSuccessToast( i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceNameChanged', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index 74d3faca5994b..bc18fade742aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -23,7 +23,7 @@ import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_l describe('SourcesLogic', () => { const { http } = mockHttpValues; - const { flashAPIErrors, setQueuedSuccessMessage } = mockFlashMessageHelpers; + const { flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; const { mount, unmount } = new LogicMounter(SourcesLogic); const contentSource = contentSources[0]; @@ -126,7 +126,7 @@ describe('SourcesLogic', () => { additionalConfiguration: false, serviceType: 'custom', }); - expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully connected source. '); + expect(flashSuccessToast).toHaveBeenCalledWith('Successfully connected source. '); }); it('unconfigured', () => { @@ -138,7 +138,7 @@ describe('SourcesLogic', () => { additionalConfiguration: true, serviceType: 'custom', }); - expect(setQueuedSuccessMessage).toHaveBeenCalledWith( + expect(flashSuccessToast).toHaveBeenCalledWith( 'Successfully connected source. This source requires additional configuration.' ); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 9de2b447619a6..14c79b75dff8e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -10,7 +10,7 @@ import { cloneDeep, findIndex } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages'; +import { flashAPIErrors, flashSuccessToast } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { AppLogic } from '../../app_logic'; import { Connector, ContentSourceDetails, ContentSourceStatus, SourceDataItem } from '../../types'; @@ -222,7 +222,7 @@ export const SourcesLogic = kea>( } ); - setQueuedSuccessMessage( + flashSuccessToast( [ successfullyConnectedMessage, additionalConfiguration ? additionalConfigurationMessage : '', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts index 2e5a0b3d9b939..6184dada8f111 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts @@ -27,8 +27,7 @@ describe('GroupLogic', () => { const { clearFlashMessages, flashAPIErrors, - setSuccessMessage, - setQueuedSuccessMessage, + flashSuccessToast, setQueuedErrorMessage, } = mockFlashMessageHelpers; @@ -224,9 +223,7 @@ describe('GroupLogic', () => { await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); - expect(setQueuedSuccessMessage).toHaveBeenCalledWith( - 'Group "group" was successfully deleted.' - ); + expect(flashSuccessToast).toHaveBeenCalledWith('Group "group" was successfully deleted.'); }); it('handles error', async () => { @@ -255,7 +252,7 @@ describe('GroupLogic', () => { await nextTick(); expect(onGroupNameChangedSpy).toHaveBeenCalledWith(group); - expect(setSuccessMessage).toHaveBeenCalledWith( + expect(flashSuccessToast).toHaveBeenCalledWith( 'Successfully renamed this group to "group".' ); }); @@ -286,7 +283,7 @@ describe('GroupLogic', () => { await nextTick(); expect(onGroupSourcesSavedSpy).toHaveBeenCalledWith(group); - expect(setSuccessMessage).toHaveBeenCalledWith( + expect(flashSuccessToast).toHaveBeenCalledWith( 'Successfully updated shared content sources.' ); }); @@ -323,7 +320,7 @@ describe('GroupLogic', () => { }); await nextTick(); - expect(setSuccessMessage).toHaveBeenCalledWith( + expect(flashSuccessToast).toHaveBeenCalledWith( 'Successfully updated shared source prioritization.' ); expect(onGroupPrioritiesChangedSpy).toHaveBeenCalledWith(group); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts index 7f3e1d9f0b82d..f8ec50b309725 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts @@ -13,8 +13,7 @@ import { i18n } from '@kbn/i18n'; import { clearFlashMessages, flashAPIErrors, - setSuccessMessage, - setQueuedSuccessMessage, + flashSuccessToast, setQueuedErrorMessage, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; @@ -206,7 +205,7 @@ export const GroupLogic = kea>({ } ); - setQueuedSuccessMessage(GROUP_DELETED_MESSAGE); + flashSuccessToast(GROUP_DELETED_MESSAGE); KibanaLogic.values.navigateToUrl(GROUPS_PATH); } catch (e) { flashAPIErrors(e); @@ -231,7 +230,7 @@ export const GroupLogic = kea>({ values: { groupName: response.name }, } ); - setSuccessMessage(GROUP_RENAMED_MESSAGE); + flashSuccessToast(GROUP_RENAMED_MESSAGE); } catch (e) { flashAPIErrors(e); } @@ -256,7 +255,7 @@ export const GroupLogic = kea>({ defaultMessage: 'Successfully updated shared content sources.', } ); - setSuccessMessage(GROUP_SOURCES_UPDATED_MESSAGE); + flashSuccessToast(GROUP_SOURCES_UPDATED_MESSAGE); } catch (e) { flashAPIErrors(e); } @@ -289,7 +288,7 @@ export const GroupLogic = kea>({ } ); - setSuccessMessage(GROUP_PRIORITIZATION_UPDATED_MESSAGE); + flashSuccessToast(GROUP_PRIORITIZATION_UPDATED_MESSAGE); actions.onGroupPrioritiesChanged(response); } catch (e) { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts index a036cdda3d68e..36061bc18196b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts @@ -15,7 +15,7 @@ import { DEFAULT_META } from '../../../shared/constants'; import { clearFlashMessages, flashAPIErrors, - setSuccessMessage, + flashSuccessToast, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { ContentSource, Group, User } from '../../types'; @@ -328,7 +328,7 @@ export const GroupsLogic = kea>({ } ); - setSuccessMessage(SUCCESS_MESSAGE); + flashSuccessToast(SUCCESS_MESSAGE); actions.setNewGroup(response); } catch (e) { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index 6e7104964cdb7..29b448bc0684a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -10,7 +10,7 @@ import { kea, MakeLogicType } from 'kea'; import { clearFlashMessages, flashAPIErrors, - setSuccessMessage, + flashSuccessToast, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { @@ -397,7 +397,7 @@ export const RoleMappingsLogic = kea> try { const response = await http.patch(route, { body }); actions.setSourceRestrictionsUpdated(response); - setSuccessMessage(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE); + flashSuccessToast(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE); AppLogic.actions.setSourceRestriction(isEnabled); } catch (e) { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx index 0f96b76130b4f..876891161b28b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx @@ -31,6 +31,7 @@ describe('BrandingSection', () => { imageType: 'logo' as 'logo', description: 'logo test', helpText: 'this is a logo', + buttonLoading: false, stageImage, saveImage, resetImage, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx index cc90dc3c9d048..b153aed607f77 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx @@ -47,6 +47,7 @@ interface Props { helpText: string; image?: string | null; stagedImage?: string | null; + buttonLoading: boolean; stageImage(image: string | null): void; saveImage(): void; resetImage(): void; @@ -58,6 +59,7 @@ export const BrandingSection: React.FC = ({ helpText, image, stagedImage, + buttonLoading, stageImage, saveImage, resetImage, @@ -133,7 +135,12 @@ export const BrandingSection: React.FC = ({ - + {SAVE_BUTTON_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx index be4be08f54ebd..47ed762e54feb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx @@ -43,9 +43,16 @@ export const Customize: React.FC = () => { resetOrgLogo, resetOrgIcon, } = useActions(SettingsLogic); - const { dataLoading, orgNameInputValue, icon, stagedIcon, logo, stagedLogo } = useValues( - SettingsLogic - ); + const { + dataLoading, + orgNameInputValue, + icon, + stagedIcon, + logo, + stagedLogo, + iconButtonLoading, + logoButtonLoading, + } = useValues(SettingsLogic); const handleSubmit = (e: FormEvent) => { e.preventDefault(); @@ -92,6 +99,7 @@ export const Customize: React.FC = () => { helpText={LOGO_HELP_TEXT} image={logo} stagedImage={stagedLogo} + buttonLoading={logoButtonLoading} stageImage={setStagedLogo} saveImage={updateOrgLogo} resetImage={resetOrgLogo} @@ -103,6 +111,7 @@ export const Customize: React.FC = () => { helpText={ICON_HELP_TEXT} image={icon} stagedImage={stagedIcon} + buttonLoading={iconButtonLoading} stageImage={setStagedIcon} saveImage={updateOrgIcon} resetImage={resetOrgIcon} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts index 005f2f016d561..e56b1df1ab873 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -22,12 +22,7 @@ import { SettingsLogic } from './settings_logic'; describe('SettingsLogic', () => { const { http } = mockHttpValues; const { navigateToUrl } = mockKibanaValues; - const { - clearFlashMessages, - flashAPIErrors, - flashSuccessToast, - setQueuedSuccessMessage, - } = mockFlashMessageHelpers; + const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; const { mount } = new LogicMounter(SettingsLogic); const ORG_NAME = 'myOrg'; const defaultValues = { @@ -39,6 +34,8 @@ describe('SettingsLogic', () => { stagedIcon: null, logo: null, stagedLogo: null, + logoButtonLoading: false, + iconButtonLoading: false, }; const serverProps = { organizationName: ORG_NAME, oauthApplication, logo: null, icon: null }; @@ -307,7 +304,7 @@ describe('SettingsLogic', () => { await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith('/settings/connectors'); - expect(setQueuedSuccessMessage).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); }); it('handles error', async () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts index 65a2cdf8c3f30..886f81129ee17 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -11,7 +11,6 @@ import { i18n } from '@kbn/i18n'; import { clearFlashMessages, - setQueuedSuccessMessage, flashSuccessToast, flashAPIErrors, } from '../../../shared/flash_messages'; @@ -62,6 +61,7 @@ interface SettingsActions { updateOrgIcon(): void; resetOrgLogo(): void; resetOrgIcon(): void; + resetButtonLoading(): void; deleteSourceConfig( serviceType: string, name: string @@ -80,6 +80,8 @@ interface SettingsValues { icon: string | null; stagedLogo: string | null; stagedIcon: string | null; + logoButtonLoading: boolean; + iconButtonLoading: boolean; } const imageRoute = '/api/workplace_search/org/settings/upload_images'; @@ -105,6 +107,7 @@ export const SettingsLogic = kea> updateOrgIcon: () => true, resetOrgLogo: () => true, resetOrgIcon: () => true, + resetButtonLoading: () => true, updateOauthApplication: () => true, deleteSourceConfig: (serviceType: string, name: string) => ({ serviceType, @@ -174,6 +177,22 @@ export const SettingsLogic = kea> setIcon: () => null, }, ], + logoButtonLoading: [ + false, + { + updateOrgLogo: () => true, + setLogo: () => false, + resetButtonLoading: () => false, + }, + ], + iconButtonLoading: [ + false, + { + updateOrgIcon: () => true, + setIcon: () => false, + resetButtonLoading: () => false, + }, + ], }, listeners: ({ actions, values }) => ({ initializeSettings: async () => { @@ -199,6 +218,7 @@ export const SettingsLogic = kea> } }, updateOrgName: async () => { + clearFlashMessages(); const { http } = HttpLogic.values; const route = '/api/workplace_search/org/settings/customize'; const { orgNameInputValue: name } = values; @@ -214,6 +234,7 @@ export const SettingsLogic = kea> } }, updateOrgLogo: async () => { + clearFlashMessages(); const { http } = HttpLogic.values; const { stagedLogo: logo } = values; const body = JSON.stringify({ logo }); @@ -223,10 +244,12 @@ export const SettingsLogic = kea> actions.setLogo(response.logo); flashSuccessToast(ORG_UPDATED_MESSAGE); } catch (e) { + actions.resetButtonLoading(); flashAPIErrors(e); } }, updateOrgIcon: async () => { + clearFlashMessages(); const { http } = HttpLogic.values; const { stagedIcon: icon } = values; const body = JSON.stringify({ icon }); @@ -236,6 +259,7 @@ export const SettingsLogic = kea> actions.setIcon(response.icon); flashSuccessToast(ORG_UPDATED_MESSAGE); } catch (e) { + actions.resetButtonLoading(); flashAPIErrors(e); } }, @@ -265,7 +289,7 @@ export const SettingsLogic = kea> try { await http.delete(route); KibanaLogic.values.navigateToUrl(ORG_SETTINGS_CONNECTORS_PATH); - setQueuedSuccessMessage( + flashSuccessToast( i18n.translate('xpack.enterpriseSearch.workplaceSearch.settings.configRemoved.message', { defaultMessage: 'Successfully removed configuration for {name}.', values: { name }, @@ -278,6 +302,12 @@ export const SettingsLogic = kea> resetSettingsState: () => { clearFlashMessages(); }, + setStagedLogo: () => { + clearFlashMessages(); + }, + setStagedIcon: () => { + clearFlashMessages(); + }, resetOrgLogo: () => { actions.updateOrgLogo(); }, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts index 38cae6d5d7f7c..d50d7b7cee225 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -266,7 +266,7 @@ describe('crawler routes', () => { }); }); - it('validates correctly with required params', () => { + it('validates correctly with crawl rules', () => { const request = { params: { name: 'some-engine', id: '1234' }, body: { @@ -281,9 +281,24 @@ describe('crawler routes', () => { mockRouter.shouldValidate(request); }); - it('fails otherwise', () => { - const request = { params: {}, body: {} }; - mockRouter.shouldThrow(request); + it('validates correctly with deduplication enabled', () => { + const request = { + params: { name: 'some-engine', id: '1234' }, + body: { + deduplication_enabled: true, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('validates correctly with deduplication fields', () => { + const request = { + params: { name: 'some-engine', id: '1234' }, + body: { + deduplication_fields: ['title', 'description'], + }, + }; + mockRouter.shouldValidate(request); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts index 79664d45dbbd8..cf90ffdea412a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -136,12 +136,16 @@ export function registerCrawlerRoutes({ id: schema.string(), }), body: schema.object({ - crawl_rules: schema.arrayOf( - schema.object({ - order: schema.number(), - id: schema.string(), - }) + crawl_rules: schema.maybe( + schema.arrayOf( + schema.object({ + order: schema.number(), + id: schema.string(), + }) + ) ), + deduplication_enabled: schema.maybe(schema.boolean()), + deduplication_fields: schema.maybe(schema.arrayOf(schema.string())), }), }, }, diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts index aa8651f74bec5..83b633a61ba59 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts @@ -9,6 +9,8 @@ import { schema } from '@kbn/config-schema'; import { RouteDependencies } from '../../plugin'; +const MAX_IMAGE_BYTES = 2000000; + export function registerOrgSettingsRoute({ router, enterpriseSearchRequestHandler, @@ -56,6 +58,11 @@ export function registerOrgSettingsUploadImagesRoute({ icon: schema.maybe(schema.nullable(schema.string())), }), }, + options: { + body: { + maxBytes: MAX_IMAGE_BYTES, + }, + }, }, enterpriseSearchRequestHandler.createRequest({ path: '/ws/org/settings/upload_images', diff --git a/x-pack/plugins/event_log/kibana.json b/x-pack/plugins/event_log/kibana.json index 0231bb6234471..5223549a2e4fb 100644 --- a/x-pack/plugins/event_log/kibana.json +++ b/x-pack/plugins/event_log/kibana.json @@ -2,6 +2,10 @@ "id": "eventLog", "version": "0.0.1", "kibanaVersion": "kibana", + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "configPath": ["xpack", "eventLog"], "optionalPlugins": ["spaces"], "server": true, diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index 173a2e9d184ef..a3a48c04370d8 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -265,6 +265,7 @@ export class ClusterClientAdapter { + logger.warn(`Failure to install package [${pkgName}]: [${err.toString()}]`); await handleInstallPackageFailure({ savedObjectsClient, error: err, diff --git a/x-pack/plugins/grokdebugger/kibana.json b/x-pack/plugins/grokdebugger/kibana.json index 5f288e0cf3bdb..692aa16329d54 100644 --- a/x-pack/plugins/grokdebugger/kibana.json +++ b/x-pack/plugins/grokdebugger/kibana.json @@ -2,16 +2,13 @@ "id": "grokdebugger", "version": "8.0.0", "kibanaVersion": "kibana", - "requiredPlugins": [ - "licensing", - "home", - "devTools" - ], + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, + "requiredPlugins": ["licensing", "home", "devTools"], "server": true, "ui": true, "configPath": ["xpack", "grokdebugger"], - "requiredBundles": [ - "kibanaReact", - "esUiShared" - ] + "requiredBundles": ["kibanaReact", "esUiShared"] } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.helpers.tsx index b99d828b8bbd0..ff9980c1d2777 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.helpers.tsx @@ -29,7 +29,6 @@ const getTestBedConfig = (initialEntries: string[]): TestBedConfig => ({ }, defaultProps: { getUrlForApp: () => {}, - navigateToApp: () => {}, }, }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts index 18af96dd2804b..e2c8a82af7d8f 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { getDefaultHotPhasePolicy, POLICY_NAME } from '../edit_policy/constants'; +import { getDefaultHotPhasePolicy } from '../edit_policy/constants'; import { setupEnvironment } from '../helpers'; import { @@ -63,7 +63,7 @@ describe('', () => { }); test('when there are policies', async () => { - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy(POLICY_NAME)]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy()]); await act(async () => { testBed = await setup(['/']); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index f6e62b3199b07..90fff3a187355 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -105,11 +105,11 @@ export const DELETE_PHASE_POLICY: PolicyFromES = { name: POLICY_NAME, }; -export const getDefaultHotPhasePolicy = (policyName: string): PolicyFromES => ({ +export const getDefaultHotPhasePolicy = (policyName?: string): PolicyFromES => ({ version: 1, modifiedDate: Date.now().toString(), policy: { - name: policyName, + name: policyName ?? POLICY_NAME, phases: { hot: { min_age: '0ms', @@ -122,7 +122,7 @@ export const getDefaultHotPhasePolicy = (policyName: string): PolicyFromES => ({ }, }, }, - name: policyName, + name: policyName ?? POLICY_NAME, }); export const POLICY_WITH_NODE_ATTR_AND_OFF_ALLOCATION: PolicyFromES = { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/delete_phase.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/delete_phase.test.ts index 6ba41860eb855..f8c74ea91890e 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/delete_phase.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/delete_phase.test.ts @@ -40,7 +40,7 @@ describe(' delete phase', () => { }); test('is hidden when disabled', async () => { - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy()]); await act(async () => { testBed = await setupDeleteTestBed(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts new file mode 100644 index 0000000000000..521d5d4da8cef --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { TestBed } from '@kbn/test/jest'; +import { setupEnvironment } from '../../helpers'; +import { initTestBed } from '../init_test_bed'; +import { getDefaultHotPhasePolicy, POLICY_NAME } from '../constants'; + +describe(' edit warning', () => { + let testBed: TestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + server.restore(); + }); + + beforeEach(async () => { + httpRequestsMockHelpers.setDefaultResponses(); + + await act(async () => { + testBed = await initTestBed(); + }); + + const { component } = testBed; + component.update(); + }); + + test('no edit warning for a new policy', async () => { + httpRequestsMockHelpers.setLoadPolicies([]); + await act(async () => { + testBed = await initTestBed(); + }); + const { exists, component } = testBed; + component.update(); + expect(exists('editWarning')).toBe(false); + }); + + test('an edit warning is shown for an existing policy', async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy(POLICY_NAME)]); + await act(async () => { + testBed = await initTestBed(); + }); + const { exists, component } = testBed; + component.update(); + expect(exists('editWarning')).toBe(true); + }); + + test('no indices link if no indices', async () => { + httpRequestsMockHelpers.setLoadPolicies([ + { ...getDefaultHotPhasePolicy(POLICY_NAME), indices: [] }, + ]); + await act(async () => { + testBed = await initTestBed(); + }); + const { exists, component } = testBed; + component.update(); + expect(exists('linkedIndicesLink')).toBe(false); + }); + + test('no index templates link if no index templates', async () => { + httpRequestsMockHelpers.setLoadPolicies([ + { ...getDefaultHotPhasePolicy(POLICY_NAME), indexTemplates: [] }, + ]); + await act(async () => { + testBed = await initTestBed(); + }); + const { exists, component } = testBed; + component.update(); + expect(exists('linkedIndexTemplatesLink')).toBe(false); + }); + + test('index templates link has number of indices', async () => { + httpRequestsMockHelpers.setLoadPolicies([ + { + ...getDefaultHotPhasePolicy(POLICY_NAME), + indices: ['index1', 'index2', 'index3'], + }, + ]); + await act(async () => { + testBed = await initTestBed(); + }); + const { component, find } = testBed; + component.update(); + expect(find('linkedIndicesLink').text()).toBe('3 linked indices'); + }); + + test('index templates link has number of index templates', async () => { + httpRequestsMockHelpers.setLoadPolicies([ + { + ...getDefaultHotPhasePolicy(POLICY_NAME), + indexTemplates: ['template1', 'template2', 'template3'], + }, + ]); + await act(async () => { + testBed = await initTestBed(); + }); + const { component, find } = testBed; + component.update(); + expect(find('linkedIndexTemplatesLink').text()).toBe('3 linked index templates'); + }); + + test('index templates link opens the flyout', async () => { + httpRequestsMockHelpers.setLoadPolicies([ + { + ...getDefaultHotPhasePolicy(POLICY_NAME), + indexTemplates: ['template1'], + }, + ]); + await act(async () => { + testBed = await initTestBed(); + }); + const { component, find, exists } = testBed; + component.update(); + expect(exists('indexTemplatesFlyoutHeader')).toBe(false); + find('linkedIndexTemplatesLink').simulate('click'); + expect(exists('indexTemplatesFlyoutHeader')).toBe(true); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.helpers.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.helpers.ts new file mode 100644 index 0000000000000..d74e2877c9fcd --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.helpers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createFormSetValueAction, + createMinAgeActions, + createTogglePhaseAction, + createRequestFlyoutActions, + createFormToggleAction, +} from '../../helpers'; +import { initTestBed } from '../init_test_bed'; + +type SetupReturn = ReturnType; + +export type RequestFlyoutTestBed = SetupReturn extends Promise ? U : SetupReturn; + +export const setupRequestFlyoutTestBed = async (isNewPolicy?: boolean) => { + const testBed = isNewPolicy + ? await initTestBed({ testBedConfig: { memoryRouter: { initialEntries: ['/policies/edit'] } } }) + : await initTestBed(); + + return { + ...testBed, + actions: { + togglePhase: createTogglePhaseAction(testBed), + setPolicyName: createFormSetValueAction(testBed, 'policyNameField'), + toggleSaveAsNewPolicy: createFormToggleAction(testBed, 'saveAsNewSwitch'), + warm: { + ...createMinAgeActions(testBed, 'warm'), + }, + ...createRequestFlyoutActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.test.ts index 02a700519cb05..86bf36984b9fd 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/request_flyout.test.ts @@ -6,12 +6,12 @@ */ import { act } from 'react-dom/test-utils'; -import { TestBed } from '@kbn/test/jest'; import { setupEnvironment } from '../../helpers'; -import { initTestBed } from '../init_test_bed'; +import { setupRequestFlyoutTestBed, RequestFlyoutTestBed } from './request_flyout.helpers'; +import { getDefaultHotPhasePolicy } from '../constants'; describe(' request flyout', () => { - let testBed: TestBed; + let testBed: RequestFlyoutTestBed; const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { @@ -27,7 +27,7 @@ describe(' request flyout', () => { httpRequestsMockHelpers.setDefaultResponses(); await act(async () => { - testBed = await initTestBed(); + testBed = await setupRequestFlyoutTestBed(); }); const { component } = testBed; @@ -35,27 +35,54 @@ describe(' request flyout', () => { }); test('renders a json in flyout for a default policy', async () => { - const { find, component } = testBed; - await act(async () => { - find('requestButton').simulate('click'); - }); - component.update(); + const { actions } = testBed; + await actions.openRequestFlyout(); - const json = component.find(`code`).text(); + const json = actions.getRequestJson(); const expected = `PUT _ilm/policy/my_policy\n${JSON.stringify( { policy: { - phases: { - hot: { - min_age: '0ms', - actions: { - rollover: { - max_age: '30d', - max_primary_shard_size: '50gb', - }, - }, - }, - }, + phases: { ...getDefaultHotPhasePolicy().policy.phases }, + }, + }, + null, + 2 + )}`; + expect(json).toBe(expected); + }); + + test('renders an error callout if policy form is invalid', async () => { + const { actions } = testBed; + // toggle warm phase but don't set phase timing to create an invalid policy + await actions.togglePhase('warm'); + await actions.openRequestFlyout(); + expect(actions.hasInvalidPolicyAlert()).toBe(true); + expect(actions.hasRequestJson()).toBe(false); + await actions.closeRequestFlyout(); + + // set phase timing to "fix" the invalid policy + await actions.warm.setMinAgeValue('10'); + await actions.openRequestFlyout(); + expect(actions.hasInvalidPolicyAlert()).toBe(false); + expect(actions.hasRequestJson()).toBe(true); + }); + + test('renders a json with default policy name when only policy name is missing', async () => { + const { actions } = testBed; + // delete the name of the the policy which is currently valid + await actions.toggleSaveAsNewPolicy(); + await actions.setPolicyName(''); + await actions.openRequestFlyout(); + + // the json still works, no "invalid policy" alert + expect(actions.hasInvalidPolicyAlert()).toBe(false); + expect(actions.hasRequestJson()).toBe(true); + + const json = actions.getRequestJson(); + const expected = `PUT _ilm/policy/\n${JSON.stringify( + { + policy: { + phases: { ...getDefaultHotPhasePolicy().policy.phases }, }, }, null, @@ -63,4 +90,50 @@ describe(' request flyout', () => { )}`; expect(json).toBe(expected); }); + + test('renders the correct json and name for a new policy', async () => { + await act(async () => { + testBed = await setupRequestFlyoutTestBed(true); + }); + + const { component, actions } = testBed; + component.update(); + + await actions.openRequestFlyout(); + const newPolicyJson = { + policy: { + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + min_age: '0ms', + }, + }, + }, + }; + + // the json renders the default when no policy name is provided + let json = actions.getRequestJson(); + let expected = `PUT _ilm/policy/\n${JSON.stringify(newPolicyJson, null, 2)}`; + + expect(json).toBe(expected); + + await actions.closeRequestFlyout(); + await actions.setPolicyName('test_policy'); + + await actions.openRequestFlyout(); + + // the json now renders the provided policy name + json = actions.getRequestJson(); + expected = `PUT _ilm/policy/test_policy\n${JSON.stringify(newPolicyJson, null, 2)}`; + + expect(json).toBe(expected); + }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts index 66f42d5482fdb..f6b8276938daf 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts @@ -145,7 +145,7 @@ describe(' searchable snapshots', () => { describe('existing policy', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy()]); httpRequestsMockHelpers.setListNodes({ isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, @@ -182,7 +182,7 @@ describe(' searchable snapshots', () => { describe('on non-enterprise license', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy()]); httpRequestsMockHelpers.setListNodes({ isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts index 6e164cc06681c..c315bde7e37d8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts @@ -486,7 +486,7 @@ describe(' serialization', () => { describe('deserialization', () => { beforeEach(async () => { - const policyToEdit = getDefaultHotPhasePolicy('my_policy'); + const policyToEdit = getDefaultHotPhasePolicy(); policyToEdit.policy.phases.frozen = { min_age: '1234m', actions: { searchable_snapshot: { snapshot_repository: 'myRepo' } }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/index.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/index.ts index acfaee3c236e9..528e818e8a7da 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/index.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/index.ts @@ -29,3 +29,4 @@ export { createFrozenPhaseActions, createDeletePhaseActions, } from './phases'; +export { createRequestFlyoutActions } from './request_flyout_actions'; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/request_flyout_actions.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/request_flyout_actions.ts new file mode 100644 index 0000000000000..87e66ea71e0e4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/actions/request_flyout_actions.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { TestBed } from '@kbn/test/jest'; + +const jsonSelector = 'policyRequestJson'; + +export const createRequestFlyoutActions = (testBed: TestBed) => { + const { find, component, exists } = testBed; + const openRequestFlyout = async () => { + await act(async () => { + find('requestButton').simulate('click'); + }); + component.update(); + }; + const closeRequestFlyout = async () => { + await act(async () => { + find('policyRequestClose').simulate('click'); + }); + component.update(); + }; + return { + openRequestFlyout, + closeRequestFlyout, + hasRequestJson: () => exists(jsonSelector), + getRequestJson: () => find(jsonSelector).text(), + hasInvalidPolicyAlert: () => exists('policyRequestInvalidAlert'), + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts index ccb57cb1067e2..7f37d4c6ccbf0 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts @@ -70,7 +70,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; const setDefaultResponses = () => { - setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + setLoadPolicies([getDefaultHotPhasePolicy()]); setLoadSnapshotPolicies([]); setListSnapshotRepos({ repositories: ['abc'] }); setListNodes({ diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json index 21e7e7888acb9..bccb3cd78e78d 100644 --- a/x-pack/plugins/index_lifecycle_management/kibana.json +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -3,23 +3,12 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": [ - "licensing", - "management", - "features", - "share" - ], - "optionalPlugins": [ - "cloud", - "usageCollection", - "indexManagement", - "home" - ], + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, + "requiredPlugins": ["licensing", "management", "features", "share"], + "optionalPlugins": ["cloud", "usageCollection", "indexManagement", "home"], "configPath": ["xpack", "ilm"], - "requiredBundles": [ - "indexManagement", - "kibanaReact", - "esUiShared", - "home" - ] + "requiredBundles": ["indexManagement", "kibanaReact", "esUiShared", "home"] } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx index abfda9fce4ea4..457ed5540278f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx @@ -18,15 +18,19 @@ import { EuiLink, EuiTitle, } from '@elastic/eui'; -import { PolicyFromES } from '../../../common/types'; import { useKibana } from '../../shared_imports'; import { getTemplateDetailsLink } from '../../../../index_management/public/'; interface Props { - policy: PolicyFromES; + policyName: string; + indexTemplates: string[]; close: () => void; } -export const IndexTemplatesFlyout: FunctionComponent = ({ policy, close }) => { +export const IndexTemplatesFlyout: FunctionComponent = ({ + policyName, + indexTemplates, + close, +}) => { const { services: { getUrlForApp }, } = useKibana(); @@ -43,7 +47,7 @@ export const IndexTemplatesFlyout: FunctionComponent = ({ policy, close } @@ -51,7 +55,7 @@ export const IndexTemplatesFlyout: FunctionComponent = ({ policy, close } { - const { navigateToApp, getUrlForApp } = application; + const { getUrlForApp } = application; render( - + diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx new file mode 100644 index 0000000000000..b78deb8c87bc4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEditPolicyContext } from '../edit_policy_context'; +import { getIndicesListPath } from '../../../services/navigation'; +import { useKibana } from '../../../../shared_imports'; +import { IndexTemplatesFlyout } from '../../../components/index_templates_flyout'; + +export const EditWarning: FunctionComponent = () => { + const { isNewPolicy, indices, indexTemplates, policyName } = useEditPolicyContext(); + const { + services: { getUrlForApp }, + } = useKibana(); + + const [isIndexTemplatesFlyoutShown, setIsIndexTemplatesShown] = useState(false); + + if (isNewPolicy) { + return null; + } + const indicesLink = + indices.length > 0 ? ( + + + + ) : null; + + const indexTemplatesLink = + indexTemplates.length > 0 ? ( + setIsIndexTemplatesShown(!isIndexTemplatesFlyoutShown)} + > + + + ) : null; + const dependenciesLinks = indicesLink ? ( + <> + {indicesLink} + {indexTemplatesLink ? ( + + ) : null} + + ) : ( + indexTemplatesLink + ); + return ( + <> + {isIndexTemplatesFlyoutShown && ( + setIsIndexTemplatesShown(false)} + /> + )} + +

+ + + + {dependenciesLinks ? ( + + ) : null} + +

+
+ + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index ccc553c58e899..95ebfb6f031ef 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -14,4 +14,5 @@ export { Timeline } from './timeline'; export { FormErrorsCallout } from './form_errors_callout'; export { PhaseFooter } from './phase_footer'; export { InfinityIcon } from './infinity_icon'; +export { EditWarning } from './edit_warning'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx index 93547fdebffe5..f510090323e1f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx @@ -26,6 +26,7 @@ import { SerializedPolicy } from '../../../../../common/types'; import { useFormContext, useFormData } from '../../../../shared_imports'; +import { i18nTexts } from '../i18n_texts'; import { FormInternal } from '../types'; interface Props { @@ -55,17 +56,22 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ policyName, c */ const [policy, setPolicy] = useState(undefined); - const { validate: validateForm } = useFormContext(); + const { validate: validateForm, getErrors } = useFormContext(); const [, getFormData] = useFormData(); const updatePolicy = useCallback(async () => { setPolicy(undefined); - if (await validateForm()) { + const isFormValid = await validateForm(); + const errorMessages = getErrors(); + const isOnlyMissingPolicyName = + errorMessages.length === 1 && + errorMessages[0] === i18nTexts.editPolicy.errors.policyNameRequiredMessage; + if (isFormValid || isOnlyMissingPolicyName) { setPolicy(prettifyFormJson(getFormData())); } else { setPolicy(null); } - }, [setPolicy, getFormData, validateForm]); + }, [setPolicy, getFormData, validateForm, getErrors]); useEffect(() => { updatePolicy(); @@ -79,6 +85,7 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ policyName, c case null: content = ( = ({ policyName, c

- + {request} @@ -150,7 +157,12 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ policyName, c {content} - + license.hasAtLeast(MIN_SEARCHABLE_SNAPSHOT_LICENSE), }, + indices: existingPolicy && existingPolicy.indices ? existingPolicy.indices : [], + indexTemplates: + existingPolicy && existingPolicy.indexTemplates ? existingPolicy.indexTemplates : [], }} > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 371be58920d07..5e5146ea5f720 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -21,7 +21,6 @@ import { EuiHorizontalRule, EuiSpacer, EuiSwitch, - EuiText, EuiPageHeader, } from '@elastic/eui'; @@ -39,6 +38,7 @@ import { WarmPhase, Timeline, FormErrorsCallout, + EditWarning, } from './components'; import { createPolicyNameValidations, @@ -70,7 +70,7 @@ export const EditPolicy: React.FunctionComponent = () => { services: { cloud }, } = useKibana(); - const [saveAsNew, setSaveAsNew] = useState(false); + const [isClonedPolicy, setIsClonedPolicy] = useState(false); const originalPolicyName: string = isNewPolicy ? '' : policyName!; const isAllowedByLicense = license.canUseSearchableSnapshot(); const isCloudEnabled = Boolean(cloud?.isCloudEnabled); @@ -103,16 +103,18 @@ export const EditPolicy: React.FunctionComponent = () => { }); const [formData] = useFormData({ form, watch: policyNamePath }); - const currentPolicyName = get(formData, policyNamePath); + const getPolicyName = () => { + return isNewPolicy || isClonedPolicy ? get(formData, policyNamePath) : originalPolicyName; + }; const policyNameValidations = useMemo( () => createPolicyNameValidations({ originalPolicyName, policies: existingPolicies, - saveAsNewPolicy: saveAsNew, + isClonedPolicy, }), - [originalPolicyName, existingPolicies, saveAsNew] + [originalPolicyName, existingPolicies, isClonedPolicy] ); const history = useHistory(); @@ -131,8 +133,11 @@ export const EditPolicy: React.FunctionComponent = () => { ); } else { const success = await savePolicy( - { ...policy, name: saveAsNew || isNewPolicy ? currentPolicyName : originalPolicyName }, - isNewPolicy || saveAsNew + { + ...policy, + name: getPolicyName(), + }, + isNewPolicy || isClonedPolicy ); if (success) { backToPolicyList(); @@ -179,32 +184,16 @@ export const EditPolicy: React.FunctionComponent = () => {
{isNewPolicy ? null : ( - -

- - - - .{' '} - -

-
+ { - setSaveAsNew(e.target.checked); + setIsClonedPolicy(e.target.checked); }} label={ @@ -219,7 +208,7 @@ export const EditPolicy: React.FunctionComponent = () => {
)} - {saveAsNew || isNewPolicy ? ( + {isClonedPolicy || isNewPolicy ? ( path={policyNamePath} config={{ @@ -289,7 +278,7 @@ export const EditPolicy: React.FunctionComponent = () => { disabled={form.isValid === false || form.isSubmitting} onClick={submit} > - {saveAsNew ? ( + {isClonedPolicy ? ( { {isShowingPolicyJsonFlyout ? ( ) : ( )} @@ -333,7 +322,7 @@ export const EditPolicy: React.FunctionComponent = () => { {isShowingPolicyJsonFlyout ? ( setIsShowingPolicyJsonFlyout(false)} /> ) : null} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx index 9414f27c72ea9..48d017920fb3f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx @@ -17,6 +17,8 @@ export interface EditPolicyContextValue { canUseSearchableSnapshot: () => boolean; }; policyName?: string; + indices: string[]; + indexTemplates: string[]; } const EditPolicyContext = createContext(null as any); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index 892e40f80f4b8..db43faf6cef17 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -105,11 +105,11 @@ export const integerValidator: ValidationFunc = (a export const createPolicyNameValidations = ({ policies, - saveAsNewPolicy, + isClonedPolicy, originalPolicyName, }: { policies: PolicyFromES[]; - saveAsNewPolicy: boolean; + isClonedPolicy: boolean; originalPolicyName?: string; }): Array> => { return [ @@ -141,7 +141,7 @@ export const createPolicyNameValidations = ({ { validator: (arg) => { const policyName = arg.value; - if (saveAsNewPolicy && policyName === originalPolicyName) { + if (isClonedPolicy && policyName === originalPolicyName) { return { message: i18nTexts.editPolicy.errors.policyNameMustBeDifferentErrorMessage, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/list_action_handler.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/list_action_handler.tsx index c03bdddc5aefd..c7d2183ec9481 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/list_action_handler.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/list_action_handler.tsx @@ -19,7 +19,8 @@ export const ListActionHandler: React.FunctionComponent = ({ updatePolici if (listAction?.actionType === 'viewIndexTemplates') { return ( { setListAction(null); }} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx index 4d75b43530fd7..d6d030c3ec733 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx @@ -15,10 +15,9 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useHistory } from 'react-router-dom'; import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table'; import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { getIndexListUri } from '../../../../../../index_management/public'; import { PolicyFromES } from '../../../../../common/types'; import { useKibana } from '../../../../shared_imports'; -import { getPolicyEditPath } from '../../../services/navigation'; +import { getIndicesListPath, getPolicyEditPath } from '../../../services/navigation'; import { trackUiMetric } from '../../../services/ui_metric'; import { UIM_EDIT_CLICK } from '../../../constants'; @@ -53,7 +52,7 @@ interface Props { export const PolicyTable: React.FunctionComponent = ({ policies }) => { const history = useHistory(); const { - services: { navigateToApp }, + services: { getUrlForApp }, } = useKibana(); const { setListAction } = usePolicyListContext(); @@ -115,19 +114,7 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { render: (value: string[], policy: PolicyFromES) => { return value && value.length > 0 ? ( - - navigateToApp('management', { - path: `/data/index_management${getIndexListUri( - `ilm.policy:"${policy.name}"`, - true - )}`, - }) - } - > - {value.length} - + {value.length} ) : ( '0' diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.ts index 352ed5802b5ed..53b27161f9c57 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.ts @@ -5,6 +5,9 @@ * 2.0. */ +import { ApplicationStart } from 'kibana/public'; +import { getIndexListUri } from '../../../../index_management/public'; + export const ROUTES = { list: '/policies', edit: '/policies/edit/:policyName?', @@ -22,3 +25,11 @@ export const getPolicyCreatePath = () => { export const getPoliciesListPath = () => { return ROUTES.list; }; + +export const getIndicesListPath = ( + policyName: string, + getUrlForApp: ApplicationStart['getUrlForApp'] +) => + getUrlForApp('management', { + path: `/data/index_management${getIndexListUri(`ilm.policy="${policyName}"`, true)}`, + }); diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index c54f5620a2859..0339d124e1279 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -39,6 +39,5 @@ export interface AppServicesContext { breadcrumbService: BreadcrumbService; license: ILicense; cloud?: CloudSetup; - navigateToApp: ApplicationStart['navigateToApp']; getUrlForApp: ApplicationStart['getUrlForApp']; } diff --git a/x-pack/plugins/index_management/kibana.json b/x-pack/plugins/index_management/kibana.json index cd29e7b9ee1cd..456ce830f6b57 100644 --- a/x-pack/plugins/index_management/kibana.json +++ b/x-pack/plugins/index_management/kibana.json @@ -1,14 +1,14 @@ { "id": "indexManagement", + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "version": "kibana", "server": true, "ui": true, "requiredPlugins": ["home", "management", "features", "share"], "optionalPlugins": ["security", "usageCollection", "fleet"], "configPath": ["xpack", "index_management"], - "requiredBundles": [ - "kibanaReact", - "esUiShared", - "runtimeFields" - ] + "requiredBundles": ["kibanaReact", "esUiShared", "runtimeFields"] } diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index fc48bd8009cf2..f99f7a96158c3 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { estypes } from '@elastic/elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { ALERT_EVALUATION_THRESHOLD, @@ -650,13 +650,8 @@ export const getUngroupedESQuery = ( }; }; -type SupportedESQueryTypes = 'term' | 'match' | 'match_phrase' | 'range'; -type Filter = { - [key in SupportedESQueryTypes]?: object; -}; - const buildFiltersForCriteria = (criteria: CountCriteria) => { - let filters: Filter[] = []; + let filters: estypes.QueryDslQueryContainer[] = []; criteria.forEach((criterion) => { const criterionQuery = buildCriterionQuery(criterion); @@ -667,7 +662,7 @@ const buildFiltersForCriteria = (criteria: CountCriteria) => { return filters; }; -const buildCriterionQuery = (criterion: Criterion): Filter | undefined => { +const buildCriterionQuery = (criterion: Criterion): estypes.QueryDslQueryContainer | undefined => { const { field, value, comparator } = criterion; const queryType = getQueryMappingForComparator(comparator); @@ -691,7 +686,7 @@ const buildCriterionQuery = (criterion: Criterion): Filter | undefined => { case 'match_phrase': { return { match_phrase: { - [field]: value, + [field]: String(value), }, }; } diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts index 0fef7dedfff0b..8b05d7c44e3f5 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts @@ -37,7 +37,7 @@ export const createLogEntryCategoryExamplesQuery = ( match: { message: { query: categoryQuery, - operator: 'AND', + operator: 'and', }, }, }, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts index ee339c9c6eb7e..d903225facd57 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts @@ -62,7 +62,7 @@ export const createLogEntryExamplesQuery = ( match: { message: { query: categoryQuery, - operator: 'AND' as const, + operator: 'and' as const, }, }, }, diff --git a/x-pack/plugins/ingest_pipelines/kibana.json b/x-pack/plugins/ingest_pipelines/kibana.json index 7c54d18fbd382..800d92b5c9748 100644 --- a/x-pack/plugins/ingest_pipelines/kibana.json +++ b/x-pack/plugins/ingest_pipelines/kibana.json @@ -3,6 +3,10 @@ "version": "8.0.0", "server": true, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": ["management", "features", "share"], "optionalPlugins": ["security", "usageCollection"], "configPath": ["xpack", "ingest_pipelines"], diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/dot_expander.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/dot_expander.test.tsx new file mode 100644 index 0000000000000..75468f31b1a54 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/dot_expander.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +const DOT_EXPANDER_TYPE = 'dot_expander'; + +describe('Processor: Dot Expander', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + testBed.component.update(); + + // Open flyout to add new processor + testBed.actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await testBed.actions.addProcessorType(DOT_EXPANDER_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is a required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('prevents form submission if field does not contain a . for the dot notation', async () => { + const { + actions: { saveNewProcessor }, + form, + component, + } = testBed; + + // Add invalid "field" value (required) + form.setInputValue('fieldNameField.input', 'missingTheDot'); + + // Save the processor with invalid field + await saveNewProcessor(); + + // Move ahead the debounce time which will then execute any validations + await act(async () => { + jest.runAllTimers(); + }); + component.update(); + + // Expect form error as "field" does not contain '.' + expect(form.getErrorsMessages()).toEqual([ + 'A field value requires at least one dot character.', + ]); + }); + test('saves with default parameter values', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field.with.dot'); + + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, DOT_EXPANDER_TYPE); + expect(processors[0][DOT_EXPANDER_TYPE]).toEqual({ + field: 'field.with.dot', + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field.notation'); + + // Set optional parameters + form.setInputValue('pathField.input', 'somepath'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, DOT_EXPANDER_TYPE); + expect(processors[0][DOT_EXPANDER_TYPE]).toEqual({ + field: 'field.notation', + path: 'somepath', + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 9101e64278dc6..65d9b8f306058 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -146,6 +146,7 @@ type TestSubject = | 'fieldNameField.input' | 'messageField.input' | 'mockCodeEditor' + | 'pathField.input' | 'tagField.input' | 'typeSelectorField' | 'dateRoundingField' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx index 4bbc242cf0ef8..c66633dfd23d5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx @@ -54,7 +54,12 @@ export const DotExpander: FunctionComponent = () => { ]} /> - + ); }; diff --git a/x-pack/plugins/lens/common/embeddable_factory/index.ts b/x-pack/plugins/lens/common/embeddable_factory/index.ts new file mode 100644 index 0000000000000..1eaa1dddfdf08 --- /dev/null +++ b/x-pack/plugins/lens/common/embeddable_factory/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableRecord, Serializable } from '@kbn/utility-types'; +import { SavedObjectReference } from 'src/core/types'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; +import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; + +export type LensEmbeddablePersistableState = EmbeddableStateWithType & { + attributes: SerializableRecord; +}; + +export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { + const typedState = state as LensEmbeddablePersistableState; + + if ('attributes' in typedState && typedState.attributes !== undefined) { + typedState.attributes.references = (references as unknown) as Serializable[]; + } + + return typedState; +}; + +export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { + let references: SavedObjectReference[] = []; + const typedState = state as LensEmbeddablePersistableState; + + if ('attributes' in typedState && typedState.attributes !== undefined) { + references = (typedState.attributes.references as unknown) as SavedObjectReference[]; + } + + return { state, references }; +}; diff --git a/x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_legend.ts b/x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_legend.ts index 0f553c6cae1f0..aae80be70e050 100644 --- a/x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_legend.ts +++ b/x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_legend.ts @@ -19,6 +19,14 @@ export interface HeatmapLegendConfig { * Position of the legend relative to the chart */ position: Position; + /** + * Defines the number of lines per legend item + */ + maxLines?: number; + /** + * Defines if the legend items should be truncated + */ + shouldTruncate?: boolean; } export type HeatmapLegendConfigResult = HeatmapLegendConfig & { @@ -54,6 +62,19 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< defaultMessage: 'Specifies the legend position.', }), }, + maxLines: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.legend.maxLines.help', { + defaultMessage: 'Specifies the number of lines per legend item.', + }), + }, + shouldTruncate: { + types: ['boolean'], + default: true, + help: i18n.translate('xpack.lens.heatmapChart.legend.shouldTruncate.help', { + defaultMessage: 'Specifies whether or not the legend items should be truncated.', + }), + }, }, fn(input, args) { return { diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts index b298f1d8b3a80..7d228f04c25e7 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts @@ -74,6 +74,14 @@ export const pie: ExpressionFunctionDefinition< types: ['boolean'], help: '', }, + legendMaxLines: { + types: ['number'], + help: '', + }, + truncateLegend: { + types: ['boolean'], + help: '', + }, legendPosition: { types: ['string'], options: [Position.Top, Position.Right, Position.Bottom, Position.Left], diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts index 213651134d98a..8712675740f1c 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts @@ -17,6 +17,8 @@ export interface SharedPieLayerState { legendPosition?: 'left' | 'right' | 'top' | 'bottom'; nestedLegend?: boolean; percentDecimals?: number; + legendMaxLines?: number; + truncateLegend?: boolean; } export type PieLayerState = SharedPieLayerState & { diff --git a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts index c0a5c4bf1e1ec..9f299d9e3d74f 100644 --- a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts +++ b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts @@ -22,10 +22,11 @@ jest.mock('../../../../../../src/plugins/data/common/query/timefilter/get_time', }; }); -import { timeScale, TimeScaleArgs } from './time_scale'; +import { getTimeScale, TimeScaleArgs } from './time_scale'; describe('time_scale', () => { let timeScaleWrapped: (input: Datatable, args: TimeScaleArgs) => Promise; + const timeScale = getTimeScale(() => 'UTC'); const emptyTable: Datatable = { type: 'datatable', diff --git a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.ts b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.ts index fc2023ca4d599..711b770fb140f 100644 --- a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.ts +++ b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.ts @@ -16,7 +16,10 @@ import { parseInterval, calculateBounds, } from '../../../../../../src/plugins/data/common'; -import { buildResultColumns } from '../../../../../../src/plugins/expressions/common'; +import { + buildResultColumns, + ExecutionContext, +} from '../../../../../../src/plugins/expressions/common'; import type { TimeScaleUnit } from './types'; export interface TimeScaleArgs { @@ -34,12 +37,14 @@ const unitInMs: Record = { d: 1000 * 60 * 60 * 24, }; -export const timeScale: ExpressionFunctionDefinition< +export const getTimeScale = ( + getTimezone: (context: ExecutionContext) => string | Promise +): ExpressionFunctionDefinition< 'lens_time_scale', Datatable, TimeScaleArgs, Promise -> = { +> => ({ name: 'lens_time_scale', type: 'datatable', help: '', @@ -73,7 +78,8 @@ export const timeScale: ExpressionFunctionDefinition< inputTypes: ['datatable'], async fn( input, - { dateColumnId, inputColumnId, outputColumnId, outputColumnName, targetUnit }: TimeScaleArgs + { dateColumnId, inputColumnId, outputColumnId, outputColumnName, targetUnit }: TimeScaleArgs, + context ) { const dateColumnDefinition = input.columns.find((column) => column.id === dateColumnId); @@ -101,7 +107,9 @@ export const timeScale: ExpressionFunctionDefinition< } const targetUnitInMs = unitInMs[targetUnit]; - const timeInfo = getDateHistogramMetaDataByDatatableColumn(dateColumnDefinition); + const timeInfo = getDateHistogramMetaDataByDatatableColumn(dateColumnDefinition, { + timeZone: await getTimezone(context), + }); const intervalDuration = timeInfo?.interval && parseInterval(timeInfo.interval); if (!timeInfo || !intervalDuration) { @@ -148,4 +156,4 @@ export const timeScale: ExpressionFunctionDefinition< return result; }, -}; +}); diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts index e228039b53ef6..fdf8d06b59424 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/legend_config.ts @@ -37,6 +37,14 @@ export interface LegendConfig { * Number of columns when legend is set inside chart */ floatingColumns?: number; + /** + * Maximum number of lines per legend item + */ + maxLines?: number; + /** + * Flag whether the legend items are truncated or not + */ + shouldTruncate?: boolean; } export type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; @@ -100,6 +108,19 @@ export const legendConfig: ExpressionFunctionDefinition< defaultMessage: 'Specifies the number of columns when legend is displayed inside chart.', }), }, + maxLines: { + types: ['number'], + help: i18n.translate('xpack.lens.xyChart.maxLines.help', { + defaultMessage: 'Specifies the number of lines per legend item.', + }), + }, + shouldTruncate: { + types: ['boolean'], + default: true, + help: i18n.translate('xpack.lens.xyChart.shouldTruncate.help', { + defaultMessage: 'Specifies whether the legend items will be truncated or not', + }), + }, }, fn: function fn(input: unknown, args: LegendConfig) { return { diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 5a783bc4180d3..6bbc1284a0f1e 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -147,6 +147,7 @@ export async function mountApp( if (stateTransfer && props?.input) { const { input, isCopied } = props; stateTransfer.navigateToWithEmbeddablePackage(embeddableEditorIncomingState?.originatingApp, { + path: embeddableEditorIncomingState?.originatingPath, state: { embeddableId: isCopied ? undefined : embeddableEditorIncomingState.embeddableId, type: LENS_EMBEDDABLE_TYPE, @@ -155,7 +156,9 @@ export async function mountApp( }, }); } else { - coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp); + coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp, { + path: embeddableEditorIncomingState?.originatingPath, + }); } }; const initialContext = diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts index 4cc074b5e830c..dcb72455e0ee9 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts @@ -5,11 +5,10 @@ * 2.0. */ -import type { Capabilities, HttpSetup, SavedObjectReference } from 'kibana/public'; +import type { Capabilities, HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter/target/common'; -import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { IndexPatternsContract, TimefilterContract } from '../../../../../src/plugins/data/public'; import { ReactExpressionRendererType } from '../../../../../src/plugins/expressions/public'; @@ -23,6 +22,7 @@ import { Document } from '../persistence/saved_object_store'; import { LensAttributeService } from '../lens_attribute_service'; import { DOC_TYPE } from '../../common'; import { ErrorMessage } from '../editor_frame_service/types'; +import { extract, inject } from '../../common/embeddable_factory'; export interface LensEmbeddableStartServices { timefilter: TimefilterContract; @@ -112,14 +112,6 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { ); } - extract(state: EmbeddableStateWithType) { - let references: SavedObjectReference[] = []; - const typedState = (state as unknown) as LensEmbeddableInput; - - if ('attributes' in typedState && typedState.attributes !== undefined) { - references = typedState.attributes.references; - } - - return { state, references }; - } + extract = extract; + inject = inject; } diff --git a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx index 15e9963ff5740..e3da4bfe7fe72 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx @@ -321,6 +321,12 @@ export const HeatmapComponent: FC = ({ showLegend={args.legend.isVisible} legendPosition={args.legend.position} debugState={window._echDebugStateFlag ?? false} + theme={{ + ...chartTheme, + legend: { + labelOptions: { maxLines: args.legend.shouldTruncate ? args.legend?.maxLines ?? 1 : 0 }, + }, + }} /> { + setState({ + ...state, + legend: { ...state.legend, maxLines: val }, + }); + }} + shouldTruncate={state?.legend.shouldTruncate ?? true} + onTruncateLegendChange={() => { + const current = state.legend.shouldTruncate ?? true; + setState({ + ...state, + legend: { ...state.legend, shouldTruncate: !current }, + }); + }} /> diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index bceeeebb5e140..5e7ee1b8b097b 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -32,6 +32,8 @@ function exampleState(): HeatmapVisualizationState { isVisible: true, position: Position.Right, type: LEGEND_FUNCTION, + maxLines: 1, + shouldTruncate: true, }, gridConfig: { type: HEATMAP_GRID_FUNCTION, @@ -63,6 +65,8 @@ describe('heatmap', () => { isVisible: true, position: Position.Right, type: LEGEND_FUNCTION, + maxLines: 1, + shouldTruncate: true, }, gridConfig: { type: HEATMAP_GRID_FUNCTION, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 5405cff6ed1db..62e3138f397da 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -70,6 +70,8 @@ function getInitialState(): Omit getTimeZone(core.uiSettings))); expressions.registerFunction(counterRate); expressions.registerFunction(renameColumns); expressions.registerFunction(formatColumn); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 618cca418a8e7..fffbf0cba34d7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -77,7 +77,7 @@ export { } from '../../common/expressions'; export { FormatColumnArgs, supportedFormats, formatColumn } from '../../common/expressions'; export { getSuffixFormatter, unitSuffixesLong } from '../../common/suffix_formatter'; -export { timeScale, TimeScaleArgs } from '../../common/expressions'; +export { getTimeScale, TimeScaleArgs } from '../../common/expressions'; export { renameColumns } from '../../common/expressions'; export function getIndexPatternDatasource({ diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index adef7188d12d0..93f16c49061e4 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -62,6 +62,8 @@ describe('PieVisualization component', () => { numberDisplay: 'hidden', categoryDisplay: 'default', legendDisplay: 'default', + legendMaxLines: 1, + truncateLegend: true, nestedLegend: false, percentDecimals: 3, hideLabels: false, @@ -106,6 +108,20 @@ describe('PieVisualization component', () => { expect(component.find(Settings).prop('showLegend')).toEqual(false); }); + test('it sets the correct lines per legend item', () => { + const component = shallow(); + expect(component.find(Settings).prop('theme')).toEqual({ + background: { + color: undefined, + }, + legend: { + labelOptions: { + maxLines: 1, + }, + }, + }); + }); + test('it calls the color function with the right series layers', () => { const defaultArgs = getDefaultArgs(); const component = shallow( diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index d25726951ea8f..41b96ff4324ae 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -75,6 +75,8 @@ export function PieComponent( legendPosition, nestedLegend, percentDecimals, + legendMaxLines, + truncateLegend, hideLabels, palette, } = props.args; @@ -297,6 +299,9 @@ export function PieComponent( ...chartTheme.background, color: undefined, // removes background for embeddables }, + legend: { + labelOptions: { maxLines: truncateLegend ? legendMaxLines ?? 1 : 0 }, + }, }} baseTheme={chartBaseTheme} /> diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index affc74d8b70cd..5a57371eb6459 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -494,6 +494,8 @@ describe('suggestions', () => { categoryDisplay: 'inside', legendDisplay: 'show', percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, nestedLegend: true, }, ], @@ -516,6 +518,8 @@ describe('suggestions', () => { categoryDisplay: 'inside', legendDisplay: 'show', percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, nestedLegend: true, }, ], @@ -684,6 +688,8 @@ describe('suggestions', () => { categoryDisplay: 'inside', legendDisplay: 'show', percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, nestedLegend: true, }, ], @@ -705,6 +711,8 @@ describe('suggestions', () => { categoryDisplay: 'default', // This is changed legendDisplay: 'show', percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, nestedLegend: true, }, ], diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 7ee26383cebbf..fd754906ceb02 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -56,6 +56,8 @@ function expressionHelper( legendDisplay: [layer.legendDisplay], legendPosition: [layer.legendPosition || 'right'], percentDecimals: [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS], + legendMaxLines: [layer.legendMaxLines ?? 1], + truncateLegend: [layer.truncateLegend ?? true], nestedLegend: [!!layer.nestedLegend], ...(state.palette ? { diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 5da69e47f861c..685a8392dcfd3 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -220,6 +220,21 @@ export function PieToolbar(props: VisualizationToolbarProps { + const current = layer.truncateLegend ?? true; + setState({ + ...state, + layers: [{ ...layer, truncateLegend: !current }], + }); + }} + maxLines={layer?.legendMaxLines} + onMaxLinesChange={(val) => { + setState({ + ...state, + layers: [{ ...layer, legendMaxLines: val }], + }); + }} /> ); diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index e0a4848974237..6e8b7d35b0cb9 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -130,7 +130,14 @@ export interface LensPublicStart { * * @experimental */ - navigateToPrefilledEditor: (input: LensEmbeddableInput, openInNewTab?: boolean) => void; + navigateToPrefilledEditor: ( + input: LensEmbeddableInput | undefined, + options?: { + openInNewTab?: boolean; + originatingApp?: string; + originatingPath?: string; + } + ) => void; /** * Method which returns true if the user has permission to use Lens as defined by application capabilities. */ @@ -336,20 +343,24 @@ export class LensPlugin { return { EmbeddableComponent: getEmbeddableComponent(core, startDependencies), SaveModalComponent: getSaveModalComponent(core, startDependencies, this.attributeService!), - navigateToPrefilledEditor: (input: LensEmbeddableInput, openInNewTab?: boolean) => { + navigateToPrefilledEditor: ( + input, + { openInNewTab = false, originatingApp = '', originatingPath } = {} + ) => { // for openInNewTab, we set the time range in url via getEditPath below - if (input.timeRange && !openInNewTab) { + if (input?.timeRange && !openInNewTab) { startDependencies.data.query.timefilter.timefilter.setTime(input.timeRange); } const transfer = new EmbeddableStateTransfer( core.application.navigateToApp, core.application.currentAppId$ ); - transfer.navigateToEditor('lens', { + transfer.navigateToEditor(APP_ID, { openInNewTab, - path: getEditPath(undefined, openInNewTab ? input.timeRange : undefined), + path: getEditPath(undefined, (openInNewTab && input?.timeRange) || undefined), state: { - originatingApp: '', + originatingApp, + originatingPath, valueInput: input, }, }); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx index e2fd630702b6b..95739c294b320 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx @@ -7,7 +7,11 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; -import { LegendSettingsPopover, LegendSettingsPopoverProps } from './legend_settings_popover'; +import { + LegendSettingsPopover, + LegendSettingsPopoverProps, + MaxLinesInput, +} from './legend_settings_popover'; describe('Legend Settings', () => { const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ @@ -50,6 +54,41 @@ describe('Legend Settings', () => { expect(props.onDisplayChange).toHaveBeenCalled(); }); + it('should have default the max lines input to 1 when no value is given', () => { + const component = shallow(); + expect(component.find(MaxLinesInput).prop('value')).toEqual(1); + }); + + it('should have the `Truncate legend text` switch enabled by default', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-truncate-switch"]').prop('checked') + ).toEqual(true); + }); + + it('should set the truncate switch state when truncate prop value is false', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-truncate-switch"]').prop('checked') + ).toEqual(false); + }); + + it('should have disabled the max lines input when truncate is set to false', () => { + const component = shallow(); + expect(component.find(MaxLinesInput).prop('isDisabled')).toEqual(true); + }); + + it('should have called the onTruncateLegendChange function on truncate switch change', () => { + const nestedProps = { + ...props, + shouldTruncate: true, + onTruncateLegendChange: jest.fn(), + }; + const component = shallow(); + component.find('[data-test-subj="lens-legend-truncate-switch"]').simulate('change'); + expect(nestedProps.onTruncateLegendChange).toHaveBeenCalled(); + }); + it('should enable the Nested Legend Switch when renderNestedLegendSwitch prop is true', () => { const component = shallow(); expect(component.find('[data-test-subj="lens-legend-nested-switch"]')).toHaveLength(1); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index 0ec7c11f6fdc1..ba5e93c3f8952 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -7,12 +7,19 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { + EuiFormRow, + EuiButtonGroup, + EuiSwitch, + EuiSwitchEvent, + EuiFieldNumber, +} from '@elastic/eui'; import { Position, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; import { ToolbarPopover } from '../shared_components'; import { LegendLocationSettings } from './legend_location_settings'; import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; import { TooltipWrapper } from './tooltip_wrapper'; +import { useDebouncedValue } from './debounced_value'; export interface LegendSettingsPopoverProps { /** @@ -64,9 +71,25 @@ export interface LegendSettingsPopoverProps { */ floatingColumns?: number; /** - * Callback on horizontal alignment option change + * Callback on alignment option change */ onFloatingColumnsChange?: (value: number) => void; + /** + * Sets the number of lines per legend item + */ + maxLines?: number; + /** + * Callback on max lines option change + */ + onMaxLinesChange?: (value: number) => void; + /** + * Defines if the legend items will be truncated or not + */ + shouldTruncate?: boolean; + /** + * Callback on nested switch status change + */ + onTruncateLegendChange?: (event: EuiSwitchEvent) => void; /** * If true, nested legend switch is rendered */ @@ -97,6 +120,38 @@ export interface LegendSettingsPopoverProps { groupPosition?: ToolbarButtonProps['groupPosition']; } +const DEFAULT_TRUNCATE_LINES = 1; +const MAX_TRUNCATE_LINES = 5; +const MIN_TRUNCATE_LINES = 1; + +export const MaxLinesInput = ({ + value, + setValue, + isDisabled, +}: { + value: number; + setValue: (value: number) => void; + isDisabled: boolean; +}) => { + const { inputValue, handleInputChange } = useDebouncedValue({ value, onChange: setValue }); + return ( + { + const val = Number(e.target.value); + // we want to automatically change the values to the limits + // if the user enters a value that is outside the limits + handleInputChange(Math.min(MAX_TRUNCATE_LINES, Math.max(val, MIN_TRUNCATE_LINES))); + }} + /> + ); +}; + export const LegendSettingsPopover: React.FunctionComponent = ({ legendOptions, mode, @@ -117,6 +172,10 @@ export const LegendSettingsPopover: React.FunctionComponent {}, renderValueInLegendSwitch, groupPosition = 'right', + maxLines, + onMaxLinesChange = () => {}, + shouldTruncate, + onTruncateLegendChange = () => {}, }) => { return ( + + + + + + + + + + {renderNestedLegendSwitch && ( { + setState({ + ...state, + legend: { ...state.legend, maxLines: val }, + }); + }} + shouldTruncate={state?.legend.shouldTruncate ?? true} + onTruncateLegendChange={() => { + const current = state?.legend.shouldTruncate ?? true; + setState({ + ...state, + legend: { ...state.legend, shouldTruncate: !current }, + }); + }} onPositionChange={(id) => { setState({ ...state, diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts index 14a9713d8461e..86a3a600b58ab 100644 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts @@ -19,6 +19,7 @@ import { LensDocShapePre712, VisStatePre715, } from '../migrations/types'; +import { extract, inject } from '../../common/embeddable_factory'; export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { @@ -50,5 +51,7 @@ export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { } as unknown) as SerializableRecord; }, }, + extract, + inject, }; }; diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index 8f8e6131b0728..882b8b938717b 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -5,11 +5,10 @@ * 2.0. */ -import type { CoreSetup, CoreStart, KibanaRequest } from 'kibana/server'; +import type { CoreSetup } from 'kibana/server'; import { pie, xyChart, - timeScale, counterRate, metricChart, yAxisConfig, @@ -21,40 +20,21 @@ import { datatableColumn, tickLabelsConfig, axisTitlesVisibilityConfig, + getTimeScale, getDatatable, } from '../../common/expressions'; -import type { PluginStartContract } from '../plugin'; -import type { - ExecutionContext, - ExpressionsServerSetup, -} from '../../../../../src/plugins/expressions/server'; +import { getFormatFactory, getTimeZoneFactory } from './utils'; -const getUiSettings = (coreStart: CoreStart, kibanaRequest: KibanaRequest) => - coreStart.uiSettings.asScopedToClient(coreStart.savedObjects.getScopedClient(kibanaRequest)); +import type { PluginStartContract } from '../plugin'; +import type { ExpressionsServerSetup } from '../../../../../src/plugins/expressions/server'; export const setupExpressions = ( core: CoreSetup, expressions: ExpressionsServerSetup ) => { - const getFormatFactory = async (context: ExecutionContext) => { - const [coreStart, { fieldFormats: fieldFormatsStart }] = await core.getStartServices(); - const kibanaRequest = context.getKibanaRequest?.(); - - if (!kibanaRequest) { - throw new Error('"lens_datatable" expression function requires a KibanaRequest to execute'); - } - - const fieldFormats = await fieldFormatsStart.fieldFormatServiceFactory( - getUiSettings(coreStart, kibanaRequest) - ); - - return fieldFormats.deserialize; - }; - [ pie, xyChart, - timeScale, counterRate, metricChart, yAxisConfig, @@ -66,6 +46,7 @@ export const setupExpressions = ( datatableColumn, tickLabelsConfig, axisTitlesVisibilityConfig, - getDatatable(getFormatFactory), + getDatatable(getFormatFactory(core)), + getTimeScale(getTimeZoneFactory(core)), ].forEach((expressionFn) => expressions.registerFunction(expressionFn)); }; diff --git a/x-pack/plugins/lens/server/expressions/utils.ts b/x-pack/plugins/lens/server/expressions/utils.ts new file mode 100644 index 0000000000000..bfe51e4b960ef --- /dev/null +++ b/x-pack/plugins/lens/server/expressions/utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart } from 'kibana/server'; +import { ExecutionContext } from '../../../../../src/plugins/expressions'; +import { PluginStartContract } from '../plugin'; + +const getUiSettings = (coreStart: CoreStart, context: ExecutionContext) => { + const kibanaRequest = context.getKibanaRequest?.(); + + if (!kibanaRequest) { + throw new Error('expression function cannot be executed without a KibanaRequest'); + } + + return coreStart.uiSettings.asScopedToClient( + coreStart.savedObjects.getScopedClient(kibanaRequest) + ); +}; + +/** @internal **/ +export const getFormatFactory = (core: CoreSetup) => async ( + context: ExecutionContext +) => { + const [coreStart, { fieldFormats: fieldFormatsStart }] = await core.getStartServices(); + + const fieldFormats = await fieldFormatsStart.fieldFormatServiceFactory( + getUiSettings(coreStart, context) + ); + + return fieldFormats.deserialize; +}; + +/** @internal **/ +export const getTimeZoneFactory = (core: CoreSetup) => async ( + context: ExecutionContext +) => { + const [coreStart] = await core.getStartServices(); + const uiSettings = await getUiSettings(coreStart, context); + const timezone = await uiSettings.get('dateFormat:tz'); + + /** if `Browser`, hardcode it to 'UTC' so the export has data that makes sense **/ + return timezone === 'Browser' ? 'UTC' : timezone; +}; diff --git a/x-pack/plugins/lens/server/index.ts b/x-pack/plugins/lens/server/index.ts index b61282c9e26e5..f8a9b2452de41 100644 --- a/x-pack/plugins/lens/server/index.ts +++ b/x-pack/plugins/lens/server/index.ts @@ -8,7 +8,9 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { LensServerPlugin } from './plugin'; +export type { LensServerPluginSetup } from './plugin'; export * from './plugin'; +export * from './migrations/types'; import { configSchema, ConfigSchema } from '../config'; diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index f0ee801ece89b..e242fc8e4c5d6 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -36,7 +36,11 @@ export interface PluginStartContract { data: DataPluginStart; } -export class LensServerPlugin implements Plugin<{}, {}, {}, {}> { +export interface LensServerPluginSetup { + lensEmbeddableFactory: typeof lensEmbeddableFactory; +} + +export class LensServerPlugin implements Plugin { private readonly kibanaIndexConfig: Observable<{ kibana: { index: string } }>; private readonly telemetryLogger: Logger; @@ -63,8 +67,11 @@ export class LensServerPlugin implements Plugin<{}, {}, {}, {}> { plugins.taskManager ); } + plugins.embeddable.registerEmbeddableFactory(lensEmbeddableFactory()); - return {}; + return { + lensEmbeddableFactory, + }; } start(core: CoreStart, plugins: PluginStartContract) { diff --git a/x-pack/plugins/license_api_guard/kibana.json b/x-pack/plugins/license_api_guard/kibana.json index 0fdf7ffed8988..1b870810ccbed 100644 --- a/x-pack/plugins/license_api_guard/kibana.json +++ b/x-pack/plugins/license_api_guard/kibana.json @@ -2,6 +2,10 @@ "id": "licenseApiGuard", "version": "0.0.1", "kibanaVersion": "kibana", + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "configPath": ["xpack", "licenseApiGuard"], "server": true, "ui": false diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index be2e21c7eb41e..a06bfbb9409fc 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -3,13 +3,13 @@ "version": "kibana", "server": true, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": ["home", "licensing", "management", "features"], "optionalPlugins": ["telemetry"], "configPath": ["xpack", "license_management"], "extraPublicDirs": ["common/constants"], - "requiredBundles": [ - "telemetryManagementSection", - "esUiShared", - "kibanaReact" - ] + "requiredBundles": ["telemetryManagementSection", "esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/lists/kibana.json b/x-pack/plugins/lists/kibana.json index ae7b3e7679e0b..17a900b3f6fdc 100644 --- a/x-pack/plugins/lists/kibana.json +++ b/x-pack/plugins/lists/kibana.json @@ -2,6 +2,10 @@ "configPath": ["xpack", "lists"], "extraPublicDirs": ["common"], "id": "lists", + "owner": { + "name": "Security detections response", + "githubTeam": "security-detections-response" + }, "kibanaVersion": "kibana", "requiredPlugins": [], "optionalPlugins": ["spaces", "security"], diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts index a272bc52c857b..db667951381b0 100644 --- a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts @@ -4,14 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import { isEmpty, isObject } from 'lodash/fp'; import type { Type } from '@kbn/securitysolution-io-ts-list-types'; -export type QueryFilterType = [ - { term: Record }, - { terms: Record } | { bool: {} } -]; +export type QueryFilterType = estypes.QueryDslQueryContainer[]; /** * Given a type, value, and listId, this will return a valid query. If the type is @@ -166,6 +163,7 @@ export const getShouldQuery = ({ { bool: { minimum_should_match: 1, + // @ts-expect-error unknown is not assignable to estypes.QueryDslQueryContainer should, }, }, diff --git a/x-pack/plugins/logstash/kibana.json b/x-pack/plugins/logstash/kibana.json index 0d14312a154e0..2ff4aac9ba55b 100644 --- a/x-pack/plugins/logstash/kibana.json +++ b/x-pack/plugins/logstash/kibana.json @@ -1,18 +1,14 @@ { "id": "logstash", + "owner": { + "name": "Logstash", + "githubTeam": "logstash" + }, "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["xpack", "logstash"], - "requiredPlugins": [ - "licensing", - "management", - "features" - ], - "optionalPlugins": [ - "home", - "monitoring", - "security" - ], + "requiredPlugins": ["licensing", "management", "features"], + "optionalPlugins": ["home", "monitoring", "security"], "server": true, "ui": true, "requiredBundles": ["home"] diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 1cccfaa7748b1..41639d667c386 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -1,11 +1,12 @@ { "id": "maps", + "owner": { + "name": "GIS", + "githubTeam": "kibana-gis" + }, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": [ - "xpack", - "maps" - ], + "configPath": ["xpack", "maps"], "requiredPlugins": [ "licensing", "features", @@ -18,7 +19,6 @@ "dashboard", "embeddable", "mapsEms", - "usageCollection", "savedObjects", "share", "presentationUtil" @@ -27,7 +27,8 @@ "home", "savedObjectsTagging", "charts", - "security" + "security", + "usageCollection" ], "ui": true, "server": true, @@ -37,6 +38,7 @@ "requiredBundles": [ "kibanaReact", "kibanaUtils", + "usageCollection", "home", "mapsEms" ] diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx index ce900cb1e9d65..994b36ff3934e 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/feature_draw_controls/feature_edit_tools/feature_edit_tools.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiButtonIcon, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { TrackApplicationView } from '../../../../../../../../src/plugins/usage_collection/public'; import { DRAW_SHAPE } from '../../../../../common/constants'; import { VectorCircleIcon } from '../../icons/vector_circle_icon'; import { VectorLineIcon } from '../../icons/vector_line_icon'; @@ -36,108 +37,110 @@ export function FeatureEditTools(props: Props) { const deleteSelected = props.drawShape === DRAW_SHAPE.DELETE; return ( - - {props.pointsOnly ? null : ( - <> - props.setDrawShape(DRAW_SHAPE.LINE)} - iconType={VectorLineIcon} - aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawLineLabel', { - defaultMessage: 'Draw line', - })} - title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawLineTitle', { - defaultMessage: 'Draw line', - })} - aria-pressed={drawLineSelected} - isSelected={drawLineSelected} - display={drawLineSelected ? 'fill' : 'empty'} - /> + + + {props.pointsOnly ? null : ( + <> + props.setDrawShape(DRAW_SHAPE.LINE)} + iconType={VectorLineIcon} + aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawLineLabel', { + defaultMessage: 'Draw line', + })} + title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawLineTitle', { + defaultMessage: 'Draw line', + })} + aria-pressed={drawLineSelected} + isSelected={drawLineSelected} + display={drawLineSelected ? 'fill' : 'empty'} + /> - props.setDrawShape(DRAW_SHAPE.POLYGON)} - iconType="node" - aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPolygonLabel', { - defaultMessage: 'Draw polygon', - })} - title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPolygonTitle', { - defaultMessage: 'Draw polygon', - })} - aria-pressed={drawPolygonSelected} - isSelected={drawPolygonSelected} - display={drawPolygonSelected ? 'fill' : 'empty'} - /> - props.setDrawShape(DRAW_SHAPE.DISTANCE)} - iconType={VectorCircleIcon} - aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawCircleLabel', { - defaultMessage: 'Draw circle', - })} - title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawCircleTitle', { - defaultMessage: 'Draw circle', - })} - aria-pressed={drawCircleSelected} - isSelected={drawCircleSelected} - display={drawCircleSelected ? 'fill' : 'empty'} - /> - props.setDrawShape(DRAW_SHAPE.BOUNDS)} - iconType={VectorSquareIcon} - aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawBBoxLabel', { - defaultMessage: 'Draw bounding box', - })} - title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawBBoxTitle', { - defaultMessage: 'Draw bounding box', - })} - aria-pressed={drawBBoxSelected} - isSelected={drawBBoxSelected} - display={drawBBoxSelected ? 'fill' : 'empty'} - /> - - )} - props.setDrawShape(DRAW_SHAPE.POINT)} - iconType="dot" - aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPointLabel', { - defaultMessage: 'Draw point', - })} - title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPointTitle', { - defaultMessage: 'Draw point', - })} - aria-pressed={drawPointSelected} - isSelected={drawPointSelected} - display={drawPointSelected ? 'fill' : 'empty'} - /> - props.setDrawShape(DRAW_SHAPE.DELETE)} - iconType="trash" - aria-label={i18n.translate( - 'xpack.maps.toolbarOverlay.featureDraw.deletePointOrShapeLabel', - { - defaultMessage: 'Delete point or shape', - } + props.setDrawShape(DRAW_SHAPE.POLYGON)} + iconType="node" + aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPolygonLabel', { + defaultMessage: 'Draw polygon', + })} + title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPolygonTitle', { + defaultMessage: 'Draw polygon', + })} + aria-pressed={drawPolygonSelected} + isSelected={drawPolygonSelected} + display={drawPolygonSelected ? 'fill' : 'empty'} + /> + props.setDrawShape(DRAW_SHAPE.DISTANCE)} + iconType={VectorCircleIcon} + aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawCircleLabel', { + defaultMessage: 'Draw circle', + })} + title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawCircleTitle', { + defaultMessage: 'Draw circle', + })} + aria-pressed={drawCircleSelected} + isSelected={drawCircleSelected} + display={drawCircleSelected ? 'fill' : 'empty'} + /> + props.setDrawShape(DRAW_SHAPE.BOUNDS)} + iconType={VectorSquareIcon} + aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawBBoxLabel', { + defaultMessage: 'Draw bounding box', + })} + title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawBBoxTitle', { + defaultMessage: 'Draw bounding box', + })} + aria-pressed={drawBBoxSelected} + isSelected={drawBBoxSelected} + display={drawBBoxSelected ? 'fill' : 'empty'} + /> + )} - title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.deletePointOrShapeTitle', { - defaultMessage: 'Delete point or shape', - })} - aria-pressed={deleteSelected} - isSelected={deleteSelected} - display={deleteSelected ? 'fill' : 'empty'} - /> - + props.setDrawShape(DRAW_SHAPE.POINT)} + iconType="dot" + aria-label={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPointLabel', { + defaultMessage: 'Draw point', + })} + title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.drawPointTitle', { + defaultMessage: 'Draw point', + })} + aria-pressed={drawPointSelected} + isSelected={drawPointSelected} + display={drawPointSelected ? 'fill' : 'empty'} + /> + props.setDrawShape(DRAW_SHAPE.DELETE)} + iconType="trash" + aria-label={i18n.translate( + 'xpack.maps.toolbarOverlay.featureDraw.deletePointOrShapeLabel', + { + defaultMessage: 'Delete point or shape', + } + )} + title={i18n.translate('xpack.maps.toolbarOverlay.featureDraw.deletePointOrShapeTitle', { + defaultMessage: 'Delete point or shape', + })} + aria-pressed={deleteSelected} + isSelected={deleteSelected} + display={deleteSelected ? 'fill' : 'empty'} + /> + + ); } diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts index 3e5e2d54422d6..abc333ab5e069 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts @@ -26,7 +26,7 @@ interface LazyLoadedMapModules { ) => Embeddable; getIndexPatternService: () => IndexPatternsContract; getMapsCapabilities: () => any; - renderApp: (params: AppMountParameters) => Promise<() => void>; + renderApp: (params: AppMountParameters, AppUsageTracker: React.FC) => Promise<() => void>; createSecurityLayerDescriptors: ( indexPatternId: string, indexPatternTitle: string diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 7ee84eb8b67e2..3253078c8c11b 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -5,11 +5,13 @@ * 2.0. */ +import React from 'react'; import type { Setup as InspectorSetupContract } from 'src/plugins/inspector/public'; import type { UiActionsStart } from 'src/plugins/ui_actions/public'; import type { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import type { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import type { DashboardStart } from 'src/plugins/dashboard/public'; +import type { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import type { AppMountParameters, CoreSetup, @@ -81,6 +83,7 @@ export interface MapsPluginSetupDependencies { mapsEms: MapsEmsPluginSetup; share: SharePluginSetup; licensing: LicensingPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface MapsPluginStartDependencies { @@ -168,8 +171,10 @@ export class MapsPlugin euiIconType: APP_ICON_SOLUTION, category: DEFAULT_APP_CATEGORIES.kibana, async mount(params: AppMountParameters) { + const UsageTracker = + plugins.usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment; const { renderApp } = await lazyLoadMapModules(); - return renderApp(params); + return renderApp(params, UsageTracker); }, }); } diff --git a/x-pack/plugins/maps/public/render_app.tsx b/x-pack/plugins/maps/public/render_app.tsx index 4d1dff9303b0c..c3f13e70fbd5c 100644 --- a/x-pack/plugins/maps/public/render_app.tsx +++ b/x-pack/plugins/maps/public/render_app.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import _ from 'lodash'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Router, Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom'; @@ -62,12 +61,10 @@ function setAppChrome() { }); } -export async function renderApp({ - element, - history, - onAppLeave, - setHeaderActionMenu, -}: AppMountParameters) { +export async function renderApp( + { element, history, onAppLeave, setHeaderActionMenu }: AppMountParameters, + AppUsageTracker: React.FC +) { goToSpecifiedPath = (path) => history.push(path); kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, @@ -107,29 +104,31 @@ export async function renderApp({ const I18nContext = getCoreI18n().Context; render( - - - - - - // Redirect other routes to list, or if hash-containing, their non-hash equivalents - { - if (hash) { - // Remove leading hash - const newPath = hash.substr(1); - return ; - } else if (pathname === '/' || pathname === '') { - return ; - } else { - return ; - } - }} - /> - - - , + + + + + + + // Redirect other routes to list, or if hash-containing, their non-hash equivalents + { + if (hash) { + // Remove leading hash + const newPath = hash.substr(1); + return ; + } else if (pathname === '/' || pathname === '') { + return ; + } else { + return ; + } + }} + /> + + + + , element ); diff --git a/x-pack/plugins/metrics_entities/kibana.json b/x-pack/plugins/metrics_entities/kibana.json index 17484c2c243ce..9d3a4f7f66a8d 100644 --- a/x-pack/plugins/metrics_entities/kibana.json +++ b/x-pack/plugins/metrics_entities/kibana.json @@ -1,5 +1,9 @@ { "id": "metricsEntities", + "owner": { + "name": "Security solution", + "githubTeam": "security-solution" + }, "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "metricsEntities"], diff --git a/x-pack/plugins/ml/common/constants/alerts.ts b/x-pack/plugins/ml/common/constants/alerts.ts index 2192b2b504b59..1b373b2ec435b 100644 --- a/x-pack/plugins/ml/common/constants/alerts.ts +++ b/x-pack/plugins/ml/common/constants/alerts.ts @@ -54,12 +54,12 @@ export const HEALTH_CHECK_NAMES: Record = { + 'cluster:admin/xpack/ml/job/delete': JOB_ACTION.DELETE, + 'cluster:admin/xpack/ml/job/reset': JOB_ACTION.RESET, + 'cluster:admin/xpack/ml/job/model_snapshots/revert': JOB_ACTION.REVERT, +}; + +export const JOB_ACTION_TASKS = Object.keys(JOB_ACTION_TASK); diff --git a/x-pack/plugins/ml/common/constants/jobs_list.ts b/x-pack/plugins/ml/common/constants/jobs_list.ts index 7672731d2a8e5..4667177890623 100644 --- a/x-pack/plugins/ml/common/constants/jobs_list.ts +++ b/x-pack/plugins/ml/common/constants/jobs_list.ts @@ -8,4 +8,5 @@ export const DEFAULT_REFRESH_INTERVAL_MS = 30000; export const MINIMUM_REFRESH_INTERVAL_MS = 1000; export const DELETING_JOBS_REFRESH_INTERVAL_MS = 2000; +export const RESETTING_JOBS_REFRESH_INTERVAL_MS = 1000; export const PROGRESS_JOBS_REFRESH_INTERVAL_MS = 2000; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts index 2ef1d824180ad..dcf18b98e00a0 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts @@ -10,8 +10,11 @@ import { estypes } from '@elastic/elasticsearch'; export type JobId = string; export type BucketSpan = string; +// temporary Job override, waiting for es client to have correct types export type Job = estypes.MlJob; +export type MlJobBlocked = estypes.MlJobBlocked; + export type AnalysisConfig = estypes.MlAnalysisConfig; export type Detector = estypes.MlDetector; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts index 37dad58bfbd45..1a70c27faaf29 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -7,10 +7,11 @@ import { Moment } from 'moment'; -import { CombinedJob, CombinedJobWithStats } from './combined_job'; -import { MlAnomalyDetectionAlertRule } from '../alerts'; -export { Datafeed } from './datafeed'; -export { DatafeedStats } from './datafeed_stats'; +import type { CombinedJob, CombinedJobWithStats } from './combined_job'; +import type { MlAnomalyDetectionAlertRule } from '../alerts'; +import type { MlJobBlocked } from './job'; +export type { Datafeed } from './datafeed'; +export type { DatafeedStats } from './datafeed_stats'; export interface MlSummaryJob { id: string; @@ -31,7 +32,7 @@ export interface MlSummaryJob { auditMessage?: Partial; isSingleMetricViewerJob: boolean; isNotSingleMetricViewerJobMessage?: string; - deleting?: boolean; + blocked?: MlJobBlocked; latestTimestampSortValue?: number; earliestStartTimestampMs?: number; awaitingNodeAssignment: boolean; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index ef8d35e52a951..306c42301e43a 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -40,6 +40,7 @@ export const adminMlCapabilities = { canDeleteJob: false, canOpenJob: false, canCloseJob: false, + canResetJob: false, canUpdateJob: false, canForecastJob: false, canCreateDatafeed: false, diff --git a/x-pack/plugins/ml/common/types/job_service.ts b/x-pack/plugins/ml/common/types/job_service.ts index e846635ee5380..a3e1571070ffd 100644 --- a/x-pack/plugins/ml/common/types/job_service.ts +++ b/x-pack/plugins/ml/common/types/job_service.ts @@ -48,3 +48,11 @@ export interface BulkCreateResults { datafeed: { success: boolean; error?: ErrorType }; }; } + +export interface ResetJobsResponse { + [jobId: string]: { + reset: boolean; + task?: string; + error?: ErrorType; + }; +} diff --git a/x-pack/plugins/ml/common/util/alerts.test.ts b/x-pack/plugins/ml/common/util/alerts.test.ts index 84205e6806133..65eca44e245a1 100644 --- a/x-pack/plugins/ml/common/util/alerts.test.ts +++ b/x-pack/plugins/ml/common/util/alerts.test.ts @@ -95,6 +95,9 @@ describe('getResultJobsHealthRuleConfig', () => { enabled: true, timeInterval: null, }, + errorMessages: { + enabled: true, + }, }); }); test('returns config with overridden values based on provided configuration', () => { @@ -119,6 +122,9 @@ describe('getResultJobsHealthRuleConfig', () => { enabled: true, timeInterval: null, }, + errorMessages: { + enabled: true, + }, }); }); }); diff --git a/x-pack/plugins/ml/common/util/alerts.ts b/x-pack/plugins/ml/common/util/alerts.ts index 7328c2a4dcc71..6abc5333a1f73 100644 --- a/x-pack/plugins/ml/common/util/alerts.ts +++ b/x-pack/plugins/ml/common/util/alerts.ts @@ -54,7 +54,7 @@ export function getTopNBuckets(job: Job): number { return Math.ceil(narrowBucketLength / bucketSpan.asSeconds()); } -const implementedTests = ['datafeed', 'mml', 'delayedData'] as JobsHealthTests[]; +const implementedTests = ['datafeed', 'mml', 'delayedData', 'errorMessages'] as JobsHealthTests[]; /** * Returns tests configuration combined with default values. diff --git a/x-pack/plugins/ml/common/util/errors/types.ts b/x-pack/plugins/ml/common/util/errors/types.ts index 3110f09e441cd..23d46ad4a8589 100644 --- a/x-pack/plugins/ml/common/util/errors/types.ts +++ b/x-pack/plugins/ml/common/util/errors/types.ts @@ -73,5 +73,5 @@ export function isMLResponseError(error: any): error is MLResponseError { } export function isBoomError(error: any): error is Boom.Boom { - return error.isBoom === true; + return error?.isBoom === true; } diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts index f6446b454a877..a5f433bcc3752 100644 --- a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts @@ -21,7 +21,8 @@ export function registerJobsHealthAlertingRule( triggersActionsUi.ruleTypeRegistry.register({ id: ML_ALERT_TYPES.AD_JOBS_HEALTH, description: i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.description', { - defaultMessage: 'Alert when anomaly detection jobs experience operational issues.', + defaultMessage: + 'Alert when anomaly detection jobs experience operational issues. Enable suitable alerts for critically important jobs.', }), iconClass: 'bell', documentationUrl(docLinks) { @@ -90,14 +91,15 @@ export function registerJobsHealthAlertingRule( \\{\\{context.message\\}\\} \\{\\{#context.results\\}\\} Job ID: \\{\\{job_id\\}\\} - \\{\\{#datafeed_id\\}\\}Datafeed ID: \\{\\{datafeed_id\\}\\} \\{\\{/datafeed_id\\}\\} - \\{\\{#datafeed_state\\}\\}Datafeed state: \\{\\{datafeed_state\\}\\} \\{\\{/datafeed_state\\}\\} - \\{\\{#memory_status\\}\\}Memory status: \\{\\{memory_status\\}\\} \\{\\{/memory_status\\}\\} - \\{\\{#log_time\\}\\}Memory logging time: \\{\\{log_time\\}\\} \\{\\{/log_time\\}\\} - \\{\\{#failed_category_count\\}\\}Failed category count: \\{\\{failed_category_count\\}\\} \\{\\{/failed_category_count\\}\\} - \\{\\{#annotation\\}\\}Annotation: \\{\\{annotation\\}\\} \\{\\{/annotation\\}\\} - \\{\\{#missed_docs_count\\}\\}Number of missed documents: \\{\\{missed_docs_count\\}\\} \\{\\{/missed_docs_count\\}\\} - \\{\\{#end_timestamp\\}\\}Latest finalized bucket with missing docs: \\{\\{end_timestamp\\}\\} \\{\\{/end_timestamp\\}\\} + \\{\\{#datafeed_id\\}\\}Datafeed ID: \\{\\{datafeed_id\\}\\} + \\{\\{/datafeed_id\\}\\} \\{\\{#datafeed_state\\}\\}Datafeed state: \\{\\{datafeed_state\\}\\} + \\{\\{/datafeed_state\\}\\} \\{\\{#memory_status\\}\\}Memory status: \\{\\{memory_status\\}\\} + \\{\\{/memory_status\\}\\} \\{\\{#log_time\\}\\}Memory logging time: \\{\\{log_time\\}\\} + \\{\\{/log_time\\}\\} \\{\\{#failed_category_count\\}\\}Failed category count: \\{\\{failed_category_count\\}\\} + \\{\\{/failed_category_count\\}\\} \\{\\{#annotation\\}\\}Annotation: \\{\\{annotation\\}\\} + \\{\\{/annotation\\}\\} \\{\\{#missed_docs_count\\}\\}Number of missed documents: \\{\\{missed_docs_count\\}\\} + \\{\\{/missed_docs_count\\}\\} \\{\\{#end_timestamp\\}\\}Latest finalized bucket with missing docs: \\{\\{end_timestamp\\}\\} + \\{\\{/end_timestamp\\}\\} \\{\\{#errors\\}\\}Error message: \\{\\{message\\}\\} \\{\\{/errors\\}\\} \\{\\{/context.results\\}\\} `, } diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx index ca13307384187..bd4b805baa186 100644 --- a/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/export_jobs_flyout.tsx @@ -358,7 +358,7 @@ const FlyoutButton: FC<{ isDisabled: boolean; onClick(): void }> = ({ isDisabled iconType="exportAction" onClick={onClick} isDisabled={isDisabled} - data-test-subj="mlJobWizardButtonPreviewJobJson" + data-test-subj="mlJobsExportButton" > diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx index 01e3e667f0560..68db42cdbf0eb 100644 --- a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx @@ -505,7 +505,7 @@ const FlyoutButton: FC<{ isDisabled: boolean; onClick(): void }> = ({ isDisabled iconType="importAction" onClick={onClick} isDisabled={isDisabled} - data-test-subj="mlJobWizardButtonPreviewJobJson" + data-test-subj="mlJobsImportButton" > diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx index 8ed967895e553..5047020999b9a 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_sync/job_spaces_sync_flyout.tsx @@ -92,7 +92,7 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { return ( <> - +

@@ -118,7 +118,12 @@ export const JobSpacesSyncFlyout: FC = ({ onClose }) => { - + = ({ onClose }) => { - + = ({ syncI const title = ( <> - +

= ({ syncI const title = ( <> - +

= ({ syncItems const title = ( <> - +

= ({ syncItem const title = ( <> - +

= ({ jobType }) => { } color="warning" iconType="alert" + data-test-subj="mlJobSyncRequiredWarning" >
) => void; +type ShowFunc = (jobs: MlSummaryJob[]) => void; interface Props { setShowFunction(showFunc: ShowFunc): void; @@ -49,18 +50,18 @@ export const DeleteJobModal: FC = ({ setShowFunction, unsetShowFunction, }; }, []); - function showModal(jobs: any[]) { + const showModal = useCallback((jobs: MlSummaryJob[]) => { setJobIds(jobs.map(({ id }) => id)); setModalVisible(true); setDeleting(false); - } + }, []); - function closeModal() { + const closeModal = useCallback(() => { setModalVisible(false); setCanDelete(false); - } + }, []); - function deleteJob() { + const deleteJob = useCallback(() => { setDeleting(true); deleteJobs(jobIds.map((id) => ({ id }))); @@ -68,7 +69,7 @@ export const DeleteJobModal: FC = ({ setShowFunction, unsetShowFunction, closeModal(); refreshJobs(); }, DELETING_JOBS_REFRESH_INTERVAL_MS); - } + }, [jobIds, refreshJobs]); if (modalVisible === false || jobIds.length === 0) { return null; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js index 82adc8df8f344..2bee4cd171637 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js @@ -8,14 +8,24 @@ import { checkPermission } from '../../../../capabilities/check_capabilities'; import { mlNodesAvailable } from '../../../../ml_nodes_check/check_ml_nodes'; import { getIndexPatternNames } from '../../../../util/index_utils'; +import { JOB_ACTION } from '../../../../../../common/constants/job_actions'; -import { stopDatafeeds, cloneJob, closeJobs, isStartable, isStoppable, isClosable } from '../utils'; +import { + stopDatafeeds, + cloneJob, + closeJobs, + isStartable, + isStoppable, + isClosable, + isResettable, +} from '../utils'; import { getToastNotifications } from '../../../../util/dependency_cache'; import { i18n } from '@kbn/i18n'; export function actionsMenuContent( showEditJobFlyout, showDeleteJobModal, + showResetJobModal, showStartDatafeedModal, refreshJobs, showCreateAlertFlyout @@ -26,6 +36,7 @@ export function actionsMenuContent( const canUpdateDatafeed = checkPermission('canUpdateDatafeed'); const canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); const canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + const canResetJob = checkPermission('canResetJob') && mlNodesAvailable(); const canCreateMlAlerts = checkPermission('canCreateMlAlerts'); return [ @@ -37,7 +48,7 @@ export function actionsMenuContent( defaultMessage: 'Start datafeed', }), icon: 'play', - enabled: (item) => item.deleting !== true && canStartStopDatafeed, + enabled: (item) => isJobBlocked(item) === false && canStartStopDatafeed, available: (item) => isStartable([item]), onClick: (item) => { showStartDatafeedModal([item]); @@ -53,7 +64,7 @@ export function actionsMenuContent( defaultMessage: 'Stop datafeed', }), icon: 'stop', - enabled: (item) => item.deleting !== true && canStartStopDatafeed, + enabled: (item) => isJobBlocked(item) === false && canStartStopDatafeed, available: (item) => isStoppable([item]), onClick: (item) => { stopDatafeeds([item], refreshJobs); @@ -69,7 +80,7 @@ export function actionsMenuContent( defaultMessage: 'Create alert rule', }), icon: 'bell', - enabled: (item) => item.deleting !== true, + enabled: (item) => isJobBlocked(item) === false, available: () => canCreateMlAlerts, onClick: (item) => { showCreateAlertFlyout([item.id]); @@ -85,7 +96,7 @@ export function actionsMenuContent( defaultMessage: 'Close job', }), icon: 'cross', - enabled: (item) => item.deleting !== true && canCloseJob, + enabled: (item) => isJobBlocked(item) === false && canCloseJob, available: (item) => isClosable([item]), onClick: (item) => { closeJobs([item], refreshJobs); @@ -93,6 +104,22 @@ export function actionsMenuContent( }, 'data-test-subj': 'mlActionButtonCloseJob', }, + { + name: i18n.translate('xpack.ml.jobsList.managementActions.resetJobLabel', { + defaultMessage: 'Reset job', + }), + description: i18n.translate('xpack.ml.jobsList.managementActions.resetJobDescription', { + defaultMessage: 'Reset job', + }), + icon: 'refresh', + enabled: (item) => isResetEnabled(item) && canResetJob, + available: (item) => isResettable([item]), + onClick: (item) => { + showResetJobModal([item]); + closeMenu(true); + }, + 'data-test-subj': 'mlActionButtonResetJob', + }, { name: i18n.translate('xpack.ml.jobsList.managementActions.cloneJobLabel', { defaultMessage: 'Clone job', @@ -106,7 +133,7 @@ export function actionsMenuContent( // the indexPattern the job was created for. An indexPattern could either have been deleted // since the the job was created or the current user doesn't have the required permissions to // access the indexPattern. - return item.deleting !== true && canCreateJob; + return isJobBlocked(item) === false && canCreateJob; }, onClick: (item) => { const indexPatternNames = getIndexPatternNames(); @@ -136,7 +163,7 @@ export function actionsMenuContent( defaultMessage: 'Edit job', }), icon: 'pencil', - enabled: (item) => item.deleting !== true && canUpdateJob && canUpdateDatafeed, + enabled: (item) => isJobBlocked(item) === false && canUpdateJob && canUpdateDatafeed, onClick: (item) => { showEditJobFlyout(item); closeMenu(); @@ -162,6 +189,17 @@ export function actionsMenuContent( ]; } +function isResetEnabled(item) { + if (item.blocked === undefined || item.blocked.reason === JOB_ACTION.RESET) { + return true; + } + return false; +} + +function isJobBlocked(item) { + return item.blocked !== undefined; +} + function closeMenu(now = false) { if (now) { document.querySelector('.euiTable').click(); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index c09b4afd03443..b1741cc83dc3b 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -48,7 +48,7 @@ export function ResultLinks({ jobs }) { }, }) : undefined; - const jobActionsDisabled = jobs.length === 1 && jobs[0].deleting === true; + const jobActionsDisabled = jobs.length === 1 && jobs[0].blocked !== undefined; const { createLinkWithUserDefaults } = useCreateADLinks(); const timeSeriesExplorerLink = useMemo( () => createLinkWithUserDefaults('timeseriesexplorer', jobs), diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index cc6db66dc1cfd..f1258f377f528 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -104,7 +104,7 @@ export class JobsList extends Component { render() { const { loading, isManagementTable, spacesApi } = this.props; const selectionControls = { - selectable: (job) => job.deleting !== true, + selectable: (job) => job.blocked === undefined, selectableMessage: (selectable, rowItem) => selectable === false ? i18n.translate('xpack.ml.jobsList.cannotSelectRowForJobMessage', { @@ -140,7 +140,7 @@ export class JobsList extends Component { render: (item) => ( this.toggleRow(item)} - isDisabled={item.deleting === true} + isDisabled={item.blocked !== undefined} iconType={this.state.itemIdToExpandedRowMap[item.id] ? 'arrowDown' : 'arrowRight'} aria-label={ this.state.itemIdToExpandedRowMap[item.id] @@ -337,6 +337,7 @@ export class JobsList extends Component { actions: actionsMenuContent( this.props.showEditJobFlyout, this.props.showDeleteJobModal, + this.props.showResetJobModal, this.props.showStartDatafeedModal, this.props.refreshJobs, this.props.showCreateAlertFlyout diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 4fdf1c4d3ab11..f0017ada611e0 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -27,6 +27,7 @@ import { JobDetails } from '../job_details'; import { JobFilterBar } from '../job_filter_bar'; import { EditJobFlyout } from '../edit_job_flyout'; import { DeleteJobModal } from '../delete_job_modal'; +import { ResetJobModal } from '../reset_job_modal'; import { StartDatafeedModal } from '../start_datafeed_modal'; import { MultiJobActions } from '../multi_job_actions'; import { NewJobButton } from '../new_job_button'; @@ -41,7 +42,7 @@ import { RefreshJobsListButton } from '../refresh_jobs_list_button'; import { DELETING_JOBS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants/jobs_list'; import { JobListMlAnomalyAlertFlyout } from '../../../../../alerting/ml_alerting_flyout'; -let deletingJobsRefreshTimeout = null; +let blockingJobsRefreshTimeout = null; const filterJobsDebounce = debounce((jobsSummaryList, filterClauses, callback) => { const ss = filterJobs(jobsSummaryList, filterClauses); @@ -62,7 +63,7 @@ export class JobsListView extends Component { selectedJobs: [], itemIdToExpandedRowMap: {}, filterClauses: [], - deletingJobIds: [], + blockingJobIds: [], jobsAwaitingNodeCount: 0, }; @@ -70,6 +71,7 @@ export class JobsListView extends Component { this.showEditJobFlyout = () => {}; this.showDeleteJobModal = () => {}; + this.showResetJobModal = () => {}; this.showStartDatafeedModal = () => {}; this.showCreateAlertFlyout = () => {}; // work around to keep track of whether the component is mounted @@ -105,7 +107,7 @@ export class JobsListView extends Component { componentWillUnmount() { if (this.props.isManagementTable === undefined) { - deletingJobsRefreshTimeout = null; + blockingJobsRefreshTimeout = null; } this._isMounted = false; } @@ -209,6 +211,13 @@ export class JobsListView extends Component { this.showDeleteJobModal = () => {}; }; + setShowResetJobModalFunction = (func) => { + this.showResetJobModal = func; + }; + unsetShowResetJobModalFunction = () => { + this.showResetJobModal = () => {}; + }; + setShowStartDatafeedModalFunction = (func) => { this.showStartDatafeedModal = func; }; @@ -353,17 +362,17 @@ export class JobsListView extends Component { }); jobs.forEach((job) => { - if (job.deleting && this.state.itemIdToExpandedRowMap[job.id]) { + if (job.blocked !== undefined && this.state.itemIdToExpandedRowMap[job.id]) { this.toggleRow(job.id); } }); this.isDoneRefreshing(); - if (jobsSummaryList.some((j) => j.deleting === true)) { + if (jobsSummaryList.some((j) => j.blocked !== undefined)) { // if there are some jobs in a deleting state, start polling for // deleting jobs so we can update the jobs list once the // deleting tasks are over - this.checkDeletingJobTasks(forceRefresh); + this.checkBlockingJobTasks(forceRefresh); } } catch (error) { console.error(error); @@ -372,18 +381,18 @@ export class JobsListView extends Component { } } - async checkDeletingJobTasks(forceRefresh = false) { + async checkBlockingJobTasks(forceRefresh = false) { if (this._isMounted === false) { return; } - const { jobIds: taskJobIds } = await ml.jobs.deletingJobTasks(); - + const { jobs } = await ml.jobs.blockingJobTasks(); + const blockingJobIds = Object.keys(jobs); const taskListHasChanged = - isEqual(taskJobIds.sort(), this.state.deletingJobIds.sort()) === false; + isEqual(blockingJobIds.sort(), this.state.blockingJobIds.sort()) === false; this.setState({ - deletingJobIds: taskJobIds, + blockingJobIds, }); // only reload the jobs list if the contents of the task list has changed @@ -392,10 +401,10 @@ export class JobsListView extends Component { this.refreshJobSummaryList(); } - if (taskJobIds.length > 0 && deletingJobsRefreshTimeout === null) { - deletingJobsRefreshTimeout = setTimeout(() => { - deletingJobsRefreshTimeout = null; - this.checkDeletingJobTasks(); + if (blockingJobIds.length > 0 && blockingJobsRefreshTimeout === null) { + blockingJobsRefreshTimeout = setTimeout(() => { + blockingJobsRefreshTimeout = null; + this.checkBlockingJobTasks(); }, DELETING_JOBS_REFRESH_INTERVAL_MS); } } @@ -515,6 +524,7 @@ export class JobsListView extends Component { allJobIds={jobIds} showStartDatafeedModal={this.showStartDatafeedModal} showDeleteJobModal={this.showDeleteJobModal} + showResetJobModal={this.showResetJobModal} showCreateAlertFlyout={this.showCreateAlertFlyout} refreshJobs={() => this.refreshJobSummaryList(true)} /> @@ -531,6 +541,7 @@ export class JobsListView extends Component { selectJobChange={this.selectJobChange} showEditJobFlyout={this.showEditJobFlyout} showDeleteJobModal={this.showDeleteJobModal} + showResetJobModal={this.showResetJobModal} showStartDatafeedModal={this.showStartDatafeedModal} refreshJobs={() => this.refreshJobSummaryList(true)} jobsViewState={this.props.jobsViewState} @@ -550,6 +561,11 @@ export class JobsListView extends Component { unsetShowFunction={this.unsetShowDeleteJobModalFunction} refreshJobs={() => this.refreshJobSummaryList(true)} /> + this.refreshJobSummaryList(true)} + /> j.deleting); + const anyJobsBlocked = this.props.jobs.some((j) => j.blocked !== undefined); const button = ( @@ -103,6 +111,27 @@ class MultiJobActionsMenuUI extends Component { ); } + if (isResettable(this.props.jobs)) { + items.push( + { + this.props.showResetJobModal(this.props.jobs); + this.closePopover(); + }} + data-test-subj="mlADJobListMultiSelectResetJobActionButton" + > + + + ); + } + if (isStoppable(this.props.jobs)) { items.push( @@ -81,6 +82,7 @@ MultiJobActions.propTypes = { allJobIds: PropTypes.array.isRequired, showStartDatafeedModal: PropTypes.func.isRequired, showDeleteJobModal: PropTypes.func.isRequired, + showResetJobModal: PropTypes.func.isRequired, refreshJobs: PropTypes.func.isRequired, showCreateAlertFlyout: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/index.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/index.ts new file mode 100644 index 0000000000000..71d46a17425e8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ResetJobModal } from './reset_job_modal'; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/open_jobs_warning_callout.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/open_jobs_warning_callout.tsx new file mode 100644 index 0000000000000..39e86cdf1f4ac --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/open_jobs_warning_callout.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import type { MlSummaryJob } from '../../../../../../common/types/anomaly_detection_jobs'; +import { JOB_STATE } from '../../../../../../common/constants/states'; + +interface Props { + jobs: MlSummaryJob[]; +} + +export const OpenJobsWarningCallout: FC = ({ jobs }) => { + const openJobsCount = useMemo(() => jobs.filter((j) => j.jobState !== JOB_STATE.CLOSED).length, [ + jobs, + ]); + + if (openJobsCount === 0) { + return null; + } + + return ( + <> + + } + color="warning" + > + +
+ +
+ + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/reset_job_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/reset_job_modal.tsx new file mode 100644 index 0000000000000..32a7c9b497e5c --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/reset_job_modal/reset_job_modal.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useEffect, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiSpacer, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButtonEmpty, + EuiButton, + EuiText, +} from '@elastic/eui'; + +import { resetJobs } from '../utils'; +import type { MlSummaryJob } from '../../../../../../common/types/anomaly_detection_jobs'; +import { RESETTING_JOBS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants/jobs_list'; +import { OpenJobsWarningCallout } from './open_jobs_warning_callout'; + +type ShowFunc = (jobs: MlSummaryJob[]) => void; + +interface Props { + setShowFunction(showFunc: ShowFunc): void; + unsetShowFunction(): void; + refreshJobs(): void; +} + +export const ResetJobModal: FC = ({ setShowFunction, unsetShowFunction, refreshJobs }) => { + const [resetting, setResetting] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [jobIds, setJobIds] = useState([]); + const [jobs, setJobs] = useState([]); + + useEffect(() => { + if (typeof setShowFunction === 'function') { + setShowFunction(showModal); + } + return () => { + if (typeof unsetShowFunction === 'function') { + unsetShowFunction(); + } + }; + }, []); + + const showModal = useCallback((tempJobs: MlSummaryJob[]) => { + setJobIds(tempJobs.map(({ id }) => id)); + setJobs(tempJobs); + setModalVisible(true); + setResetting(false); + }, []); + + const closeModal = useCallback(() => { + setModalVisible(false); + }, []); + + const resetJob = useCallback(async () => { + setResetting(true); + await resetJobs(jobIds); + closeModal(); + setTimeout(() => { + refreshJobs(); + }, RESETTING_JOBS_REFRESH_INTERVAL_MS); + }, [jobIds, refreshJobs]); + + if (modalVisible === false || jobIds.length === 0) { + return null; + } + + return ( + + + + + + + + <> + + + + + + + <> + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts index 76e1e87312a4a..49df7f3cbb00f 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts @@ -8,4 +8,5 @@ import { CombinedJobWithStats } from '../../../../../common/types/anomaly_detection_jobs'; export function deleteJobs(jobs: Array<{ id: string }>, callback?: () => void): Promise; +export function resetJobs(jobIds: string[], callback?: () => void): Promise; export function loadFullJob(jobId: string): Promise; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index f004fb6bad49d..414d920237e8c 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -17,6 +17,7 @@ import { getToastNotifications } from '../../../util/dependency_cache'; import { ml } from '../../../services/ml_api_service'; import { stringMatch } from '../../../util/string_utils'; import { JOB_STATE, DATAFEED_STATE } from '../../../../../common/constants/states'; +import { JOB_ACTION } from '../../../../../common/constants/job_actions'; import { parseInterval } from '../../../../../common/util/parse_interval'; import { mlCalendarService } from '../../../services/calendar_service'; import { isPopulatedObject } from '../../../../../common/util/object_utils'; @@ -76,6 +77,12 @@ export function isClosable(jobs) { ); } +export function isResettable(jobs) { + return jobs.some( + (j) => j.jobState === JOB_STATE.CLOSED || j.blocked?.reason === JOB_ACTION.RESET + ); +} + export function forceStartDatafeeds(jobs, start, end, finish = () => {}) { const datafeedIds = jobs.filter((j) => j.hasDatafeed).map((j) => j.datafeedId); mlJobService @@ -165,6 +172,13 @@ function showResults(resp, action) { actionTextPT = i18n.translate('xpack.ml.jobsList.closedActionStatusText', { defaultMessage: 'closed', }); + } else if (action === JOB_ACTION.RESET) { + actionText = i18n.translate('xpack.ml.jobsList.resetActionStatusText', { + defaultMessage: 'reset', + }); + actionTextPT = i18n.translate('xpack.ml.jobsList.resetActionStatusText', { + defaultMessage: 'reset', + }); } const toastNotifications = getToastNotifications(); @@ -283,6 +297,24 @@ export function closeJobs(jobs, finish = () => {}) { }); } +export function resetJobs(jobIds, finish = () => {}) { + mlJobService + .resetJobs(jobIds) + .then((resp) => { + showResults(resp, JOB_ACTION.RESET); + finish(); + }) + .catch((error) => { + getToastNotificationService().displayErrorToast( + error, + i18n.translate('xpack.ml.jobsList.resetJobErrorMessage', { + defaultMessage: 'Jobs failed to reset', + }) + ); + finish(); + }); +} + export function deleteJobs(jobs, finish = () => {}) { const jobIds = jobs.map((j) => j.id); mlJobService diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 476ba381dd92e..e6cfe52933617 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -287,6 +287,7 @@ export class JobCreator { if (enable) { this._job_config.results_index_name = this._job_config.job_id; } else { + // @ts-expect-error The operand of a 'delete' operator must be optional delete this._job_config.results_index_name; } } diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 064c6a038994d..a1528b91d5abb 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -258,7 +258,10 @@ export const JobsListPage: FC<{ {spacesEnabled && ( <> - setShowSyncFlyout(true)}> + setShowSyncFlyout(true)} + data-test-subj="mlStackMgmtSyncButton" + > {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { defaultMessage: 'Synchronize saved objects', })} diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 8560cdd73153b..4a6f7dbbcc3ff 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -386,6 +386,10 @@ class JobService { return ml.jobs.closeJobs(jIds); } + resetJobs(jIds) { + return ml.jobs.resetJobs(jIds); + } + validateDetector(detector) { return new Promise((resolve, reject) => { if (detector) { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index 81a86e5a7f980..7a75e1a2bdbc0 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -5,8 +5,9 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { Observable } from 'rxjs'; -import { HttpStart } from 'kibana/public'; +import type { HttpStart } from 'kibana/public'; import { HttpService } from '../http_service'; import { annotations } from './annotations'; @@ -16,16 +17,19 @@ import { resultsApiProvider } from './results'; import { jobsApiProvider } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; import { savedObjectsApiProvider } from './saved_objects'; -import { +import type { MlServerDefaults, MlServerLimits, MlNodeCount, } from '../../../../common/types/ml_server_info'; -import { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; -import { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; -import { BucketSpanEstimatorData } from '../../../../common/types/job_service'; -import { +import type { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; +import type { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; +import type { + BucketSpanEstimatorData, + ResetJobsResponse, +} from '../../../../common/types/job_service'; +import type { Job, JobStats, Datafeed, @@ -35,8 +39,8 @@ import { ModelSnapshot, IndicesOptions, } from '../../../../common/types/anomaly_detection_jobs'; -import { FieldHistogramRequestConfig } from '../../datavisualizer/index_based/common/request'; -import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; +import type { FieldHistogramRequestConfig } from '../../datavisualizer/index_based/common/request'; +import type { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; import { getHttp } from '../../util/dependency_cache'; import type { RuntimeMappings } from '../../../../common/types/fields'; @@ -151,14 +155,14 @@ export function mlApiServicesProvider(httpService: HttpService) { }, deleteJob({ jobId }: { jobId: string }) { - return httpService.http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}`, method: 'DELETE', }); }, forceDeleteJob({ jobId }: { jobId: string }) { - return httpService.http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, method: 'DELETE', }); @@ -173,6 +177,13 @@ export function mlApiServicesProvider(httpService: HttpService) { }); }, + resetJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_reset`, + method: 'POST', + }); + }, + estimateBucketSpan(obj: BucketSpanEstimatorData) { const body = JSON.stringify(obj); return httpService.http({ diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index bf0eee81158c0..96c5e1abce170 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -18,6 +18,7 @@ import type { IndicesOptions, } from '../../../../common/types/anomaly_detection_jobs'; import type { JobMessage } from '../../../../common/types/audit_message'; +import type { JobAction } from '../../../../common/constants/job_actions'; import type { AggFieldNamePair, RuntimeMappings } from '../../../../common/types/fields'; import type { ExistingJobsAndGroups } from '../job_service'; import type { @@ -27,7 +28,11 @@ import type { } from '../../../../common/types/categories'; import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job'; import type { Category } from '../../../../common/types/categories'; -import type { JobsExistResponse, BulkCreateResults } from '../../../../common/types/job_service'; +import type { + JobsExistResponse, + BulkCreateResults, + ResetJobsResponse, +} from '../../../../common/types/job_service'; import { ML_BASE_PATH } from '../../../../common/constants/app'; export const jobsApiProvider = (httpService: HttpService) => ({ @@ -127,6 +132,15 @@ export const jobsApiProvider = (httpService: HttpService) => ({ }); }, + resetJobs(jobIds: string[]) { + const body = JSON.stringify({ jobIds }); + return httpService.http({ + path: `${ML_BASE_PATH}/jobs/reset_jobs`, + method: 'POST', + body, + }); + }, + forceStopAndCloseJob(jobId: string) { const body = JSON.stringify({ jobId }); return httpService.http<{ success: boolean }>({ @@ -169,9 +183,9 @@ export const jobsApiProvider = (httpService: HttpService) => ({ }); }, - deletingJobTasks() { - return httpService.http({ - path: `${ML_BASE_PATH}/jobs/deleting_jobs_tasks`, + blockingJobTasks() { + return httpService.http>({ + path: `${ML_BASE_PATH}/jobs/blocking_jobs_tasks`, method: 'GET', }); }, diff --git a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx index 3111c7f134da0..f4941649ac7a7 100644 --- a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx @@ -83,7 +83,7 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta should: [ { match_phrase: { - [fieldName]: fieldValue, + [fieldName]: String(fieldValue), }, }, ], @@ -104,6 +104,7 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta pageState: { jobIds, timeRange, + // @ts-ignore QueryDslQueryContainer is not compatible with SerializableRecord ...(mlExplorerFilter ? ({ mlExplorerFilter } as SerializableRecord) : {}), query: {}, }, diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts index b345cf8c1245c..ffaa26fc949ee 100644 --- a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts @@ -11,9 +11,39 @@ import type { Logger } from 'kibana/server'; import { MlClient } from '../ml_client'; import { MlJob, MlJobStats } from '@elastic/elasticsearch/api/types'; import { AnnotationService } from '../../models/annotation_service/annotation'; +import { JobsHealthExecutorOptions } from './register_jobs_monitoring_rule_type'; +import { JobAuditMessagesService } from '../../models/job_audit_messages/job_audit_messages'; +import { DeepPartial } from '../../../common/types/common'; const MOCK_DATE_NOW = 1487076708000; +function getDefaultExecutorOptions( + overrides: DeepPartial = {} +): JobsHealthExecutorOptions { + return ({ + state: {}, + startedAt: new Date('2021-08-12T13:13:39.396Z'), + previousStartedAt: new Date('2021-08-12T13:13:27.396Z'), + spaceId: 'default', + namespace: undefined, + name: 'ml-health-check', + tags: [], + createdBy: 'elastic', + updatedBy: 'elastic', + rule: { + name: 'ml-health-check', + tags: [], + consumer: 'alerts', + producer: 'ml', + ruleTypeId: 'xpack.ml.anomaly_detection_jobs_health', + ruleTypeName: 'Anomaly detection jobs health', + enabled: true, + schedule: { interval: '10s' }, + }, + ...overrides, + } as unknown) as JobsHealthExecutorOptions; +} + describe('JobsHealthService', () => { const mlClient = ({ getJobs: jest.fn().mockImplementation(({ job_id: jobIds = [] }) => { @@ -117,6 +147,12 @@ describe('JobsHealthService', () => { }), } as unknown) as jest.Mocked; + const jobAuditMessagesService = ({ + getJobsErrors: jest.fn().mockImplementation((jobIds: string) => { + return Promise.resolve({}); + }), + } as unknown) as jest.Mocked; + const logger = ({ warn: jest.fn(), info: jest.fn(), @@ -127,6 +163,7 @@ describe('JobsHealthService', () => { mlClient, datafeedsService, annotationService, + jobAuditMessagesService, logger ); @@ -143,42 +180,52 @@ describe('JobsHealthService', () => { test('returns empty results when no jobs provided', async () => { // act - const executionResult = await jobHealthService.getTestsResults('testRule', { - testsConfig: null, - includeJobs: { - jobIds: ['*'], - groupIds: [], - }, - excludeJobs: null, - }); + const executionResult = await jobHealthService.getTestsResults( + getDefaultExecutorOptions({ + rule: { name: 'testRule' }, + params: { + testsConfig: null, + includeJobs: { + jobIds: ['*'], + groupIds: [], + }, + excludeJobs: null, + }, + }) + ); expect(logger.warn).toHaveBeenCalledWith('Rule "testRule" does not have associated jobs.'); expect(datafeedsService.getDatafeedByJobId).not.toHaveBeenCalled(); expect(executionResult).toEqual([]); }); test('returns empty results and does not perform datafeed check when test is disabled', async () => { - const executionResult = await jobHealthService.getTestsResults('testRule', { - testsConfig: { - datafeed: { - enabled: false, - }, - behindRealtime: null, - delayedData: { - enabled: false, - docsCount: null, - timeInterval: null, - }, - errorMessages: null, - mml: { - enabled: false, + const executionResult = await jobHealthService.getTestsResults( + getDefaultExecutorOptions({ + rule: { name: 'testRule' }, + params: { + testsConfig: { + datafeed: { + enabled: false, + }, + behindRealtime: null, + delayedData: { + enabled: false, + docsCount: null, + timeInterval: null, + }, + errorMessages: null, + mml: { + enabled: false, + }, + }, + includeJobs: { + jobIds: ['test_job_01'], + groupIds: [], + }, + excludeJobs: null, }, - }, - includeJobs: { - jobIds: ['test_job_01'], - groupIds: [], - }, - excludeJobs: null, - }); + }) + ); expect(logger.warn).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledWith(`Performing health checks for job IDs: test_job_01`); expect(datafeedsService.getDatafeedByJobId).not.toHaveBeenCalled(); @@ -186,27 +233,32 @@ describe('JobsHealthService', () => { }); test('takes into account delayed data params', async () => { - const executionResult = await jobHealthService.getTestsResults('testRule_04', { - testsConfig: { - delayedData: { - enabled: true, - docsCount: 10, - timeInterval: '4h', + const executionResult = await jobHealthService.getTestsResults( + getDefaultExecutorOptions({ + rule: { name: 'testRule_04' }, + params: { + testsConfig: { + delayedData: { + enabled: true, + docsCount: 10, + timeInterval: '4h', + }, + behindRealtime: { enabled: false, timeInterval: null }, + mml: { enabled: false }, + datafeed: { enabled: false }, + errorMessages: { enabled: false }, + }, + includeJobs: { + jobIds: [], + groupIds: ['test_group'], + }, + excludeJobs: { + jobIds: ['test_job_03'], + groupIds: [], + }, }, - behindRealtime: { enabled: false, timeInterval: null }, - mml: { enabled: false }, - datafeed: { enabled: false }, - errorMessages: { enabled: false }, - }, - includeJobs: { - jobIds: [], - groupIds: ['test_group'], - }, - excludeJobs: { - jobIds: ['test_job_03'], - groupIds: [], - }, - }); + }) + ); expect(annotationService.getDelayedDataAnnotations).toHaveBeenCalledWith({ jobIds: ['test_job_01', 'test_job_02'], @@ -234,17 +286,22 @@ describe('JobsHealthService', () => { }); test('returns results based on provided selection', async () => { - const executionResult = await jobHealthService.getTestsResults('testRule_03', { - testsConfig: null, - includeJobs: { - jobIds: [], - groupIds: ['test_group'], - }, - excludeJobs: { - jobIds: ['test_job_03'], - groupIds: [], - }, - }); + const executionResult = await jobHealthService.getTestsResults( + getDefaultExecutorOptions({ + rule: { name: 'testRule_03' }, + params: { + testsConfig: null, + includeJobs: { + jobIds: [], + groupIds: ['test_group'], + }, + excludeJobs: { + jobIds: ['test_job_03'], + groupIds: [], + }, + }, + }) + ); expect(logger.warn).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledWith( `Performing health checks for job IDs: test_job_01, test_job_02` diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts index 52e17fed7a414..bcae57e558573 100644 --- a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts @@ -11,10 +11,7 @@ import { i18n } from '@kbn/i18n'; import { Logger } from 'kibana/server'; import { MlJob } from '@elastic/elasticsearch/api/types'; import { MlClient } from '../ml_client'; -import { - AnomalyDetectionJobsHealthRuleParams, - JobSelection, -} from '../../routes/schemas/alerting_schema'; +import { JobSelection } from '../../routes/schemas/alerting_schema'; import { datafeedsProvider, DatafeedsService } from '../../models/job_service/datafeeds'; import { ALL_JOBS_SELECTION, HEALTH_CHECK_NAMES } from '../../../common/constants/alerts'; import { DatafeedStats } from '../../../common/types/anomaly_detection_jobs'; @@ -22,6 +19,7 @@ import { GetGuards } from '../../shared_services/shared_services'; import { AnomalyDetectionJobsHealthAlertContext, DelayedDataResponse, + JobsHealthExecutorOptions, MmlTestResponse, NotStartedDatafeedResponse, } from './register_jobs_monitoring_rule_type'; @@ -33,6 +31,10 @@ import { AnnotationService } from '../../models/annotation_service/annotation'; import { annotationServiceProvider } from '../../models/annotation_service'; import { parseInterval } from '../../../common/util/parse_interval'; import { isDefined } from '../../../common/types/guards'; +import { + jobAuditMessagesProvider, + JobAuditMessagesService, +} from '../../models/job_audit_messages/job_audit_messages'; interface TestResult { name: string; @@ -45,6 +47,7 @@ export function jobsHealthServiceProvider( mlClient: MlClient, datafeedsService: DatafeedsService, annotationService: AnnotationService, + jobAuditMessagesService: JobAuditMessagesService, logger: Logger ) { /** @@ -236,13 +239,25 @@ export function jobsHealthServiceProvider( return annotations; }, + /** + * Retrieves a list of the latest errors per jobs. + * @param jobIds List of job IDs. + * @param previousStartedAt Time of the previous rule execution. As we intend to notify + * about an error only once, limit the scope of the errors search. + */ + async getErrorsReport(jobIds: string[], previousStartedAt: Date) { + return await jobAuditMessagesService.getJobsErrors(jobIds, previousStartedAt.getTime()); + }, /** * Retrieves report grouped by test. */ - async getTestsResults( - ruleInstanceName: string, - { testsConfig, includeJobs, excludeJobs }: AnomalyDetectionJobsHealthRuleParams - ): Promise { + async getTestsResults(executorOptions: JobsHealthExecutorOptions): Promise { + const { + rule, + previousStartedAt, + params: { testsConfig, includeJobs, excludeJobs }, + } = executorOptions; + const config = getResultJobsHealthRuleConfig(testsConfig); const results: TestsResults = []; @@ -251,7 +266,7 @@ export function jobsHealthServiceProvider( const jobIds = getJobIds(jobs); if (jobIds.length === 0) { - logger.warn(`Rule "${ruleInstanceName}" does not have associated jobs.`); + logger.warn(`Rule "${rule.name}" does not have associated jobs.`); return results; } @@ -334,6 +349,26 @@ export function jobsHealthServiceProvider( } } + if (config.errorMessages.enabled && previousStartedAt) { + const response = await this.getErrorsReport(jobIds, previousStartedAt); + if (response.length > 0) { + results.push({ + name: HEALTH_CHECK_NAMES.errorMessages.name, + context: { + results: response, + message: i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.errorMessagesMessage', + { + defaultMessage: + '{jobsCount, plural, one {# job contains} other {# jobs contain}} errors in the messages.', + values: { jobsCount: response.length }, + } + ), + }, + }); + } + } + return results; }, }; @@ -360,6 +395,7 @@ export function getJobsHealthServiceProvider(getGuards: GetGuards) { mlClient, datafeedsProvider(scopedClient, mlClient), annotationServiceProvider(scopedClient), + jobAuditMessagesProvider(scopedClient, mlClient), logger ).getTestsResults(...args) ); diff --git a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts index 063d8ad5a8980..c49c169d3bd21 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts @@ -22,6 +22,8 @@ import { AlertInstanceState, AlertTypeState, } from '../../../../alerting/common'; +import { JobsErrorsResponse } from '../../models/job_audit_messages/job_audit_messages'; +import { AlertExecutorOptions } from '../../../../alerting/server'; type ModelSizeStats = MlJobStats['model_size_stats']; @@ -55,7 +57,8 @@ export interface DelayedDataResponse { export type AnomalyDetectionJobHealthResult = | MmlTestResponse | NotStartedDatafeedResponse - | DelayedDataResponse; + | DelayedDataResponse + | JobsErrorsResponse[number]; export type AnomalyDetectionJobsHealthAlertContext = { results: AnomalyDetectionJobHealthResult[]; @@ -69,10 +72,18 @@ export type AnomalyDetectionJobRealtimeIssue = typeof ANOMALY_DETECTION_JOB_REAL export const REALTIME_ISSUE_DETECTED: ActionGroup = { id: ANOMALY_DETECTION_JOB_REALTIME_ISSUE, name: i18n.translate('xpack.ml.jobsHealthAlertingRule.actionGroupName', { - defaultMessage: 'Real-time issue detected', + defaultMessage: 'Issue detected', }), }; +export type JobsHealthExecutorOptions = AlertExecutorOptions< + AnomalyDetectionJobsHealthRuleParams, + Record, + Record, + AnomalyDetectionJobsHealthAlertContext, + AnomalyDetectionJobRealtimeIssue +>; + export function registerJobsMonitoringRuleType({ alerting, mlServicesProviders, @@ -120,14 +131,16 @@ export function registerJobsMonitoringRuleType({ producer: PLUGIN_ID, minimumLicenseRequired: MINIMUM_FULL_LICENSE, isExportable: true, - async executor({ services, params, alertId, state, previousStartedAt, startedAt, name, rule }) { + async executor(options) { + const { services, name } = options; + const fakeRequest = {} as KibanaRequest; const { getTestsResults } = mlServicesProviders.jobsHealthServiceProvider( services.savedObjectsClient, fakeRequest, logger ); - const executionResult = await getTestsResults(name, params); + const executionResult = await getTestsResults(options); if (executionResult.length > 0) { logger.info( diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 93c2124eae8d1..452bb803997d7 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -51,7 +51,7 @@ describe('check_capabilities', () => { ); const { capabilities } = await getCapabilities(); const count = Object.keys(capabilities).length; - expect(count).toBe(30); + expect(count).toBe(31); }); }); @@ -88,6 +88,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteJob).toBe(false); expect(capabilities.canOpenJob).toBe(false); expect(capabilities.canCloseJob).toBe(false); + expect(capabilities.canResetJob).toBe(false); expect(capabilities.canForecastJob).toBe(false); expect(capabilities.canStartStopDatafeed).toBe(false); expect(capabilities.canUpdateJob).toBe(false); @@ -137,6 +138,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteJob).toBe(true); expect(capabilities.canOpenJob).toBe(true); expect(capabilities.canCloseJob).toBe(true); + expect(capabilities.canResetJob).toBe(true); expect(capabilities.canForecastJob).toBe(true); expect(capabilities.canStartStopDatafeed).toBe(true); expect(capabilities.canUpdateJob).toBe(true); @@ -185,6 +187,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteJob).toBe(false); expect(capabilities.canOpenJob).toBe(false); expect(capabilities.canCloseJob).toBe(false); + expect(capabilities.canResetJob).toBe(false); expect(capabilities.canForecastJob).toBe(false); expect(capabilities.canStartStopDatafeed).toBe(false); expect(capabilities.canUpdateJob).toBe(false); @@ -233,6 +236,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteJob).toBe(false); expect(capabilities.canOpenJob).toBe(false); expect(capabilities.canCloseJob).toBe(false); + expect(capabilities.canResetJob).toBe(false); expect(capabilities.canForecastJob).toBe(false); expect(capabilities.canStartStopDatafeed).toBe(false); expect(capabilities.canUpdateJob).toBe(false); @@ -281,6 +285,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteJob).toBe(false); expect(capabilities.canOpenJob).toBe(false); expect(capabilities.canCloseJob).toBe(false); + expect(capabilities.canResetJob).toBe(false); expect(capabilities.canForecastJob).toBe(false); expect(capabilities.canStartStopDatafeed).toBe(false); expect(capabilities.canUpdateJob).toBe(false); @@ -331,6 +336,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteJob).toBe(false); expect(capabilities.canOpenJob).toBe(false); expect(capabilities.canCloseJob).toBe(false); + expect(capabilities.canResetJob).toBe(false); expect(capabilities.canForecastJob).toBe(false); expect(capabilities.canStartStopDatafeed).toBe(false); expect(capabilities.canUpdateJob).toBe(false); diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index 0a9c76893dd0b..3829c975d057d 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -474,6 +474,10 @@ export function getMlClient( await jobIdsCheck('anomaly-detector', p); return mlClient.updateJob(...p); }, + async resetJob(...p: Parameters) { + await jobIdsCheck('anomaly-detector', p); + return mlClient.resetJob(...p); + }, async updateModelSnapshot(...p: Parameters) { await jobIdsCheck('anomaly-detector', p); return mlClient.updateModelSnapshot(...p); diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts index 98ed76319a0f7..fcda1a2a3ea73 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts @@ -54,6 +54,10 @@ export function isClearable(index?: string): boolean { return false; } +export type JobsErrorsResponse = Array<{ job_id: string; errors: JobMessage[] }>; + +export type JobAuditMessagesService = ReturnType; + export function jobAuditMessagesProvider( { asInternalUser }: IScopedClusterClient, mlClient: MlClient @@ -178,7 +182,10 @@ export function jobAuditMessagesProvider( return { messages, notificationIndices }; } - // search highest, most recent audit messages for all jobs for the last 24hrs. + /** + * Search highest, most recent audit messages for all jobs for the last 24hrs. + * @param jobIds + */ async function getAuditMessagesSummary(jobIds: string[]): Promise { // TODO This is the current default value of the cluster setting `search.max_buckets`. // This should possibly consider the real settings in a future update. @@ -400,9 +407,70 @@ export function jobAuditMessagesProvider( return (Object.keys(LEVEL) as LevelName[])[Object.values(LEVEL).indexOf(level)]; } + /** + * Retrieve list of errors per job. + * @param jobIds + */ + async function getJobsErrors(jobIds: string[], earliestMs?: number): Promise { + const { body } = await asInternalUser.search({ + index: ML_NOTIFICATION_INDEX_PATTERN, + ignore_unavailable: true, + size: 0, + body: { + query: { + bool: { + filter: [ + ...(earliestMs ? [{ range: { timestamp: { gte: earliestMs } } }] : []), + { terms: { job_id: jobIds } }, + { + term: { level: { value: MESSAGE_LEVEL.ERROR } }, + }, + ], + }, + }, + aggs: { + by_job: { + terms: { + field: 'job_id', + size: jobIds.length, + }, + aggs: { + latest_errors: { + top_hits: { + size: 10, + sort: [ + { + timestamp: { + order: 'desc', + }, + }, + ], + }, + }, + }, + }, + }, + }, + }); + + const errors = body.aggregations!.by_job as estypes.AggregationsTermsAggregate<{ + key: string; + doc_count: number; + latest_errors: Pick, 'hits'>; + }>; + + return errors.buckets.map((bucket) => { + return { + job_id: bucket.key, + errors: bucket.latest_errors.hits.hits.map((v) => v._source!), + }; + }); + } + return { getJobAuditMessages, getAuditMessagesSummary, clearJobAuditMessages, + getJobsErrors, }; } diff --git a/x-pack/plugins/ml/server/models/job_service/error_utils.ts b/x-pack/plugins/ml/server/models/job_service/error_utils.ts index 81a86b1ee5ca0..a1d6c3fcc35d4 100644 --- a/x-pack/plugins/ml/server/models/job_service/error_utils.ts +++ b/x-pack/plugins/ml/server/models/job_service/error_utils.ts @@ -7,9 +7,10 @@ import { i18n } from '@kbn/i18n'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; +import { JobAction } from '../../../common/constants/job_actions'; const REQUEST_TIMEOUT_NAME = 'RequestTimeout'; -type ACTION_STATE = DATAFEED_STATE | JOB_STATE; +type ACTION_STATE = DATAFEED_STATE | JOB_STATE | JobAction; export function isRequestTimeout(error: { name: string }) { return error.name === REQUEST_TIMEOUT_NAME; diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index ee336c96a9c0d..4922608487f66 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import { uniq } from 'lodash'; import Boom from '@hapi/boom'; import { IScopedClusterClient } from 'kibana/server'; @@ -14,6 +13,13 @@ import { parseTimeIntervalForJob, } from '../../../common/util/job_utils'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; +import { + getJobActionString, + JOB_ACTION_TASK, + JOB_ACTION_TASKS, + JOB_ACTION, + JobAction, +} from '../../../common/constants/job_actions'; import { MlSummaryJob, AuditMessage, @@ -27,6 +33,7 @@ import { MlJobsStatsResponse, JobsExistResponse, BulkCreateResults, + ResetJobsResponse, } from '../../../common/types/job_service'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import { datafeedsProvider, MlDatafeedsResponse, MlDatafeedsStatsResponse } from './datafeeds'; @@ -145,6 +152,29 @@ export function jobsProvider( return results; } + async function resetJobs(jobIds: string[]) { + const results: ResetJobsResponse = {}; + for (const jobId of jobIds) { + try { + const { + // @ts-expect-error @elastic-elasticsearch resetJob response incorrect, missing task + body: { task }, + } = await mlClient.resetJob({ + job_id: jobId, + wait_for_completion: false, + }); + results[jobId] = { reset: true, task }; + } catch (error) { + if (isRequestTimeout(error)) { + return fillResultsWithTimeouts(results, jobId, jobIds, JOB_ACTION.RESET); + } else { + results[jobId] = { reset: false, error: error.body }; + } + } + } + return results; + } + async function forceStopAndCloseJob(jobId: string) { const datafeedIds = await getDatafeedIdsByJobId(); const datafeedId = datafeedIds[jobId]; @@ -181,10 +211,6 @@ export function jobsProvider( // fail silently } - const deletingStr = i18n.translate('xpack.ml.models.jobService.deletingJob', { - defaultMessage: 'deleting', - }); - const jobs = fullJobsList.map((job) => { const hasDatafeed = isPopulatedObject(job.datafeed_config); const dataCounts = job.data_counts; @@ -201,7 +227,7 @@ export function jobsProvider( parseTimeIntervalForJob(job.analysis_config?.bucket_span) ), memory_status: job.model_size_stats ? job.model_size_stats.memory_status : '', - jobState: job.deleting === true ? deletingStr : job.state, + jobState: job.blocked === undefined ? job.state : getJobActionString(job.blocked.reason), hasDatafeed, datafeedId: hasDatafeed && job.datafeed_config.datafeed_id ? job.datafeed_config.datafeed_id : '', @@ -217,11 +243,12 @@ export function jobsProvider( isSingleMetricViewerJob: errorMessage === undefined, isNotSingleMetricViewerJobMessage: errorMessage, nodeName: job.node ? job.node.name : undefined, - deleting: job.deleting || undefined, + blocked: job.blocked ?? undefined, awaitingNodeAssignment: isJobAwaitingNodeAssignment(job), alertingRules: job.alerting_rules, jobTags: job.custom_settings?.job_tags ?? {}, }; + if (jobIds.find((j) => j === tempJob.id)) { tempJob.fullJob = job; } @@ -459,21 +486,25 @@ export function jobsProvider( return jobs; } - async function deletingJobTasks() { - const actions = ['cluster:admin/xpack/ml/job/delete']; - const detailed = true; - const jobIds: string[] = []; + async function blockingJobTasks() { + const jobs: Array> = []; try { const { body } = await asInternalUser.tasks.list({ - actions, - detailed, + actions: JOB_ACTION_TASKS, + detailed: true, }); - if (body.nodes) { - Object.keys(body.nodes).forEach((nodeId) => { - const tasks = body.nodes![nodeId].tasks; - Object.keys(tasks).forEach((taskId) => { - jobIds.push(tasks[taskId].description!.replace(/^delete-job-/, '')); + if (body.nodes !== undefined) { + Object.values(body.nodes).forEach(({ tasks }) => { + Object.values(tasks).forEach(({ action, description }) => { + if (description === undefined) { + return; + } + if (JOB_ACTION_TASK[action] === JOB_ACTION.DELETE) { + jobs.push({ [description.replace(/^delete-job-/, '')]: JOB_ACTION.DELETE }); + } else { + jobs.push({ [description]: JOB_ACTION_TASK[action] }); + } }); }); } @@ -481,12 +512,16 @@ export function jobsProvider( // if the user doesn't have permission to load the task list, // use the jobs list to get the ids of deleting jobs const { - body: { jobs }, - } = await mlClient.getJobs(); + body: { jobs: tempJobs }, + } = await mlClient.getJobs(); - jobIds.push(...jobs.filter((j) => j.deleting === true).map((j) => j.job_id)); + jobs.push( + ...tempJobs + .filter((j) => j.blocked !== undefined) + .map((j) => ({ [j.job_id]: j.blocked!.reason })) + ); } - return { jobIds }; + return { jobs }; } // Checks if each of the jobs in the specified list of IDs exist. @@ -613,12 +648,13 @@ export function jobsProvider( forceDeleteJob, deleteJobs, closeJobs, + resetJobs, forceStopAndCloseJob, jobsSummary, jobsWithTimerange, getJobForCloning, createFullJobsList, - deletingJobTasks, + blockingJobTasks, jobsExist, getAllJobAndGroupIds, getLookBackProgress, diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index c5be5e1c9ef2d..1403ce2a7b4db 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -22,6 +22,8 @@ import { getModelSnapshotsSchema, updateModelSnapshotsSchema, updateModelSnapshotBodySchema, + forceQuerySchema, + jobResetQuerySchema, } from './schemas/anomaly_detectors_schema'; /** @@ -270,13 +272,14 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { * @apiDescription Closes an anomaly detection job. * * @apiSchema (params) jobIdSchema + * @apiSchema (query) forceQuerySchema */ router.post( { path: '/api/ml/anomaly_detectors/{jobId}/_close', validate: { params: jobIdSchema, - query: schema.object({ force: schema.maybe(schema.boolean()) }), + query: forceQuerySchema, }, options: { tags: ['access:ml:canCloseJob'], @@ -301,6 +304,46 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { }) ); + /** + * @apiGroup AnomalyDetectors + * + * @api {post} /api/ml/anomaly_detectors/:jobId/_reset Reset specified job + * @apiName ResetAnomalyDetectorsJob + * @apiDescription Resets an anomaly detection job. + * + * @apiSchema (params) jobIdSchema + * @apiSchema (query) jobResetQuerySchema + */ + router.post( + { + path: '/api/ml/anomaly_detectors/{jobId}/_reset', + validate: { + params: jobIdSchema, + query: jobResetQuerySchema, + }, + options: { + tags: ['access:ml:canCloseJob'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { + try { + const options: { job_id: string; wait_for_completion?: boolean } = { + // TODO change this to correct resetJob request type + job_id: request.params.jobId, + ...(request.query.wait_for_completion !== undefined + ? { wait_for_completion: request.query.wait_for_completion } + : {}), + }; + const { body } = await mlClient.resetJob(options); + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup AnomalyDetectors * @@ -309,13 +352,14 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { * @apiDescription Deletes specified anomaly detection job. * * @apiSchema (params) jobIdSchema + * @apiSchema (query) forceQuerySchema */ router.delete( { path: '/api/ml/anomaly_detectors/{jobId}', validate: { params: jobIdSchema, - query: schema.object({ force: schema.maybe(schema.boolean()) }), + query: forceQuerySchema, }, options: { tags: ['access:ml:canDeleteJob'], diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 646a250b96686..b0e94c60f3cb5 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -88,7 +88,7 @@ "TopCategories", "DatafeedPreview", "UpdateGroups", - "DeletingJobTasks", + "BlockingJobTasks", "DeleteJobs", "RevertModelSnapshot", "BulkCreateJobs", diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 63310827ad989..81ef1818383b1 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -174,6 +174,40 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { }) ); + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/reset_jobs Reset multiple jobs + * @apiName ResetJobs + * @apiDescription Resets one or more anomaly detection jobs + * + * @apiSchema (body) jobIdsSchema + */ + router.post( + { + path: '/api/ml/jobs/reset_jobs', + validate: { + body: jobIdsSchema, + }, + options: { + tags: ['access:ml:canResetJob'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { + try { + const { resetJobs } = jobServiceProvider(client, mlClient); + const { jobIds } = request.body; + const resp = await resetJobs(jobIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup JobService * @@ -422,13 +456,13 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { /** * @apiGroup JobService * - * @api {get} /api/ml/jobs/deleting_jobs_tasks Get deleting job tasks - * @apiName DeletingJobTasks - * @apiDescription Gets the ids of deleting anomaly detection jobs + * @api {get} /api/ml/jobs/blocking_jobs_tasks Get blocking job tasks + * @apiName BlockingJobTasks + * @apiDescription Gets the ids of deleting, resetting or reverting anomaly detection jobs */ router.get( { - path: '/api/ml/jobs/deleting_jobs_tasks', + path: '/api/ml/jobs/blocking_jobs_tasks', validate: false, options: { tags: ['access:ml:canGetJobs'], @@ -436,8 +470,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, response }) => { try { - const { deletingJobTasks } = jobServiceProvider(client, mlClient); - const resp = await deletingJobTasks(); + const { blockingJobTasks } = jobServiceProvider(client, mlClient); + const resp = await blockingJobTasks(); return response.ok({ body: resp, diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index 158e522ddf9b3..2b93a3a84457d 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -219,3 +219,13 @@ export const updateModelSnapshotBodySchema = schema.object({ }); export const forecastAnomalyDetector = schema.object({ duration: schema.any() }); + +export const jobResetQuerySchema = schema.object({ + /** wait for completion */ + wait_for_completion: schema.maybe(schema.boolean()), +}); + +export const forceQuerySchema = schema.object({ + /** force close */ + force: schema.maybe(schema.boolean()), +}); diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index 7d82006c6b999..0e02812af28fb 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -2,6 +2,10 @@ "id": "monitoring", "version": "8.0.0", "kibanaVersion": "kibana", + "owner": { + "owner": "Stack Monitoring", + "githubTeam": "stack-monitoring-ui" + }, "configPath": ["monitoring"], "requiredPlugins": [ "licensing", diff --git a/x-pack/plugins/monitoring/server/alerts/base_rule.ts b/x-pack/plugins/monitoring/server/alerts/base_rule.ts index 62fb24560ddd6..4a7e78f535253 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_rule.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_rule.ts @@ -251,7 +251,7 @@ export class BaseRule { ? { timestamp: { format: 'epoch_millis', - gte: +new Date() - limit - this.ruleOptions.fetchClustersRange, + gte: String(+new Date() - limit - this.ruleOptions.fetchClustersRange), }, } : undefined; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts index 8b5803d588e12..984cdee4e1b7b 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts @@ -12,7 +12,7 @@ import { AlertCluster } from '../../../common/types/alerts'; interface RangeFilter { [field: string]: { format?: string; - gte: string | number; + gte: string; }; } diff --git a/x-pack/plugins/observability/public/components/app/cases/case_view/helpers.ts b/x-pack/plugins/observability/public/components/app/cases/case_view/helpers.ts index b180c15b4487a..e8ba66c332778 100644 --- a/x-pack/plugins/observability/public/components/app/cases/case_view/helpers.ts +++ b/x-pack/plugins/observability/public/components/app/cases/case_view/helpers.ts @@ -4,9 +4,52 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { useEffect, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { usePluginContext } from '../../../../hooks/use_plugin_context'; +import { parseAlert } from '../../../../pages/alerts/parse_alert'; +import { TopAlert } from '../../../../pages/alerts/'; +import { useKibana } from '../../../../utils/kibana_react'; import { Ecs } from '../../../../../../cases/common'; // no alerts in observability so far // dummy hook for now as hooks cannot be called conditionally export const useFetchAlertData = (): [boolean, Record] => [false, {}]; + +export const useFetchAlertDetail = (alertId: string): [boolean, TopAlert | null] => { + const { http } = useKibana().services; + const [loading, setLoading] = useState(false); + const { observabilityRuleTypeRegistry } = usePluginContext(); + const [alert, setAlert] = useState(null); + + useEffect(() => { + const abortCtrl = new AbortController(); + const fetchData = async () => { + try { + setLoading(true); + const response = await http.get('/internal/rac/alerts', { + query: { + id: alertId, + }, + }); + if (response) { + const parsedAlert = parseAlert(observabilityRuleTypeRegistry)(response); + setAlert(parsedAlert); + setLoading(false); + } + } catch (error) { + setAlert(null); + } + }; + + if (!isEmpty(alertId) && loading === false && alert === null) { + fetchData(); + } + return () => { + abortCtrl.abort(); + }; + }, [http, alertId, alert, loading, observabilityRuleTypeRegistry]); + + return [loading, alert]; +}; diff --git a/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx b/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx index 9d18381573e32..52a840a6e5447 100644 --- a/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/case_view/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, Suspense } from 'react'; import { casesBreadcrumbs, getCaseDetailsUrl, @@ -15,10 +15,12 @@ import { useFormatUrl, } from '../../../../pages/cases/links'; import { Case } from '../../../../../../cases/common'; -import { useFetchAlertData } from './helpers'; +import { useFetchAlertData, useFetchAlertDetail } from './helpers'; import { useKibana } from '../../../../utils/kibana_react'; +import { usePluginContext } from '../../../../hooks/use_plugin_context'; import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs'; import { observabilityAppId } from '../../../../../common'; +import { LazyAlertsFlyout } from '../../../..'; interface Props { caseId: string; @@ -41,14 +43,17 @@ export interface CaseProps extends Props { export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => { const [caseTitle, setCaseTitle] = useState(null); + const { observabilityRuleTypeRegistry } = usePluginContext(); const { cases: casesUi, - application: { getUrlForApp, navigateToUrl }, + application: { getUrlForApp, navigateToUrl, navigateToApp }, } = useKibana().services; const allCasesLink = getCaseUrl(); const { formatUrl } = useFormatUrl(); const href = formatUrl(allCasesLink); + const [selectedAlertId, setSelectedAlertId] = useState(''); + useBreadcrumbs([ { ...casesBreadcrumbs.cases, href }, ...(caseTitle !== null @@ -80,41 +85,78 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = }), [caseId, formatUrl, subCaseId] ); - const casesUrl = `${getUrlForApp(observabilityAppId)}/cases`; - return casesUi.getCaseView({ - allCasesNavigation: { - href: allCasesHref, - onClick: async (ev) => { - if (ev != null) { - ev.preventDefault(); - } - return navigateToUrl(casesUrl); - }, - }, - caseDetailsNavigation: { - href: caseDetailsHref, - onClick: async (ev) => { - if (ev != null) { - ev.preventDefault(); - } - return navigateToUrl(`${casesUrl}${getCaseDetailsUrl({ id: caseId })}`); - }, - }, - caseId, - configureCasesNavigation: { - href: configureCasesHref, - onClick: async (ev) => { - if (ev != null) { - ev.preventDefault(); - } - return navigateToUrl(`${casesUrl}${configureCasesLink}`); - }, - }, - getCaseDetailHrefWithCommentId, - onCaseDataSuccess, - subCaseId, - useFetchAlertData, - userCanCrud, - }); + + const handleFlyoutClose = useCallback(() => { + setSelectedAlertId(''); + }, []); + + const [alertLoading, alert] = useFetchAlertDetail(selectedAlertId); + + return ( + <> + {alertLoading === false && alert && selectedAlertId !== '' && ( + + + + )} + {casesUi.getCaseView({ + allCasesNavigation: { + href: allCasesHref, + onClick: async (ev) => { + if (ev != null) { + ev.preventDefault(); + } + return navigateToUrl(casesUrl); + }, + }, + caseDetailsNavigation: { + href: caseDetailsHref, + onClick: async (ev) => { + if (ev != null) { + ev.preventDefault(); + } + return navigateToUrl(`${casesUrl}${getCaseDetailsUrl({ id: caseId })}`); + }, + }, + caseId, + configureCasesNavigation: { + href: configureCasesHref, + onClick: async (ev) => { + if (ev != null) { + ev.preventDefault(); + } + return navigateToUrl(`${casesUrl}${configureCasesLink}`); + }, + }, + ruleDetailsNavigation: { + href: (ruleId) => { + return getUrlForApp('management', { + path: `/insightsAndAlerting/triggersActions/rule/${ruleId}`, + }); + }, + onClick: async (ruleId, ev) => { + if (ev != null) { + ev.preventDefault(); + } + return navigateToApp('management', { + path: `/insightsAndAlerting/triggersActions/rule/${ruleId}`, + }); + }, + }, + getCaseDetailHrefWithCommentId, + onCaseDataSuccess, + subCaseId, + useFetchAlertData, + showAlertDetails: (alertId) => { + setSelectedAlertId(alertId); + }, + userCanCrud, + })} + + ); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index 8cd8977fcf741..62d828b337c2d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -52,7 +52,7 @@ describe('ExploratoryViewHeader', function () { to: 'now', }, }, - true + { openInNewTab: true } ); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index ded56ec9e817f..bfa457ee4025f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -69,7 +69,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { timeRange, attributes: lensAttributes, }, - true + { + openInNewTab: true, + } ); } }} diff --git a/x-pack/plugins/observability/public/hooks/use_alert_permission.ts b/x-pack/plugins/observability/public/hooks/use_alert_permission.ts new file mode 100644 index 0000000000000..509324e00f650 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_alert_permission.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState } from 'react'; +import { RecursiveReadonly } from '@kbn/utility-types'; + +export interface UseGetUserAlertsPermissionsProps { + crud: boolean; + read: boolean; + loading: boolean; + featureId: string | null; +} + +export const useGetUserAlertsPermissions = ( + uiCapabilities: RecursiveReadonly>, + featureId?: string +): UseGetUserAlertsPermissionsProps => { + const [alertsPermissions, setAlertsPermissions] = useState({ + crud: false, + read: false, + loading: true, + featureId: null, + }); + + useEffect(() => { + if (!featureId || !uiCapabilities[featureId]) { + setAlertsPermissions({ + crud: false, + read: false, + loading: false, + featureId: null, + }); + } else { + setAlertsPermissions((currentAlertPermissions) => { + if (currentAlertPermissions.featureId === featureId) { + return currentAlertPermissions; + } + const capabilitiesCanUserCRUD: boolean = + typeof uiCapabilities[featureId].save === 'boolean' + ? uiCapabilities[featureId].save + : false; + const capabilitiesCanUserRead: boolean = + typeof uiCapabilities[featureId].show === 'boolean' + ? uiCapabilities[featureId].show + : false; + return { + crud: capabilitiesCanUserCRUD, + read: capabilitiesCanUserRead, + loading: false, + featureId, + }; + }); + } + }, [alertsPermissions.featureId, featureId, uiCapabilities]); + + return alertsPermissions; +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index 01cdfccbd05e5..f32088e2646b3 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -51,6 +51,7 @@ export function AlertsSearchBar({ }); setQueryLanguage((nextQuery?.language || 'kuery') as 'kuery' | 'lucene'); }} + displayStyle="inPage" /> ); } diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx index afa6ba728b41f..d84c820e5d0bd 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -10,12 +10,13 @@ * We have types and code at different imports because we don't want to import the whole package in the resulting webpack bundle for the plugin. * This way plugins can do targeted imports to reduce the final code bundle */ -import type { +import { AlertConsumers as AlertConsumersTyped, ALERT_DURATION as ALERT_DURATION_TYPED, ALERT_SEVERITY_LEVEL as ALERT_SEVERITY_LEVEL_TYPED, ALERT_STATUS as ALERT_STATUS_TYPED, ALERT_RULE_NAME as ALERT_RULE_NAME_TYPED, + ALERT_RULE_CONSUMER, } from '@kbn/rule-data-utils'; import { ALERT_DURATION as ALERT_DURATION_NON_TYPED, @@ -41,7 +42,10 @@ import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import React, { Suspense, useMemo, useState, useCallback } from 'react'; +import { get } from 'lodash'; +import { useGetUserAlertsPermissions } from '../../hooks/use_alert_permission'; import type { TimelinesUIStart, TGridType, SortDirection } from '../../../../timelines/public'; +import { useStatusBulkActionItems } from '../../../../timelines/public'; import type { TopAlert } from './'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import type { @@ -58,6 +62,7 @@ import { usePluginContext } from '../../hooks/use_plugin_context'; import { getDefaultCellActions } from './default_cell_actions'; import { LazyAlertsFlyout } from '../..'; import { parseAlert } from './parse_alert'; +import { CoreStart } from '../../../../../../src/core/public'; const AlertConsumers: typeof AlertConsumersTyped = AlertConsumersNonTyped; const ALERT_DURATION: typeof ALERT_DURATION_TYPED = ALERT_DURATION_NON_TYPED; @@ -75,6 +80,7 @@ interface AlertsTableTGridProps { } interface ObservabilityActionsProps extends ActionProps { + currentStatus: AlertStatus; setFlyoutAlert: React.Dispatch>; } @@ -161,15 +167,27 @@ function ObservabilityActions({ data, eventId, ecsData, + currentStatus, + refetch, setFlyoutAlert, + setEventsLoading, + setEventsDeleted, }: ObservabilityActionsProps) { const { core, observabilityRuleTypeRegistry } = usePluginContext(); const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); const [openActionsPopoverId, setActionsPopover] = useState(null); - const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + const { + timelines, + application: { capabilities }, + } = useKibana().services; + const parseObservabilityAlert = useMemo(() => parseAlert(observabilityRuleTypeRegistry), [ observabilityRuleTypeRegistry, ]); + const alertDataConsumer = useMemo(() => get(dataFieldEs, ALERT_RULE_CONSUMER, [''])[0], [ + dataFieldEs, + ]); + const alert = parseObservabilityAlert(dataFieldEs); const { prepend } = core.http.basePath; @@ -181,8 +199,8 @@ function ObservabilityActions({ setActionsPopover(null); }, []); - const openActionsPopover = useCallback((id) => { - setActionsPopover(id); + const toggleActionsPopover = useCallback((id) => { + setActionsPopover((current) => (current ? null : id)); }, []); const casePermissions = useGetUserCasesPermissions(); const event = useMemo(() => { @@ -193,31 +211,48 @@ function ObservabilityActions({ }; }, [data, eventId, ecsData]); + const onAlertStatusUpdated = useCallback(() => { + setActionsPopover(null); + if (refetch) { + refetch(); + } + }, [setActionsPopover, refetch]); + + const alertPermissions = useGetUserAlertsPermissions(capabilities, alertDataConsumer); + + const statusActionItems = useStatusBulkActionItems({ + eventIds: [eventId], + currentStatus, + indexName: ecsData._index ?? '', + setEventsLoading, + setEventsDeleted, + onUpdateSuccess: onAlertStatusUpdated, + onUpdateFailure: onAlertStatusUpdated, + }); + const actionsPanels = useMemo(() => { return [ { id: 0, content: [ - <> - {timelines.getAddToExistingCaseButton({ - event, - casePermissions, - appId: observabilityFeatureId, - onClose: afterCaseSelection, - })} - , - <> - {timelines.getAddToNewCaseButton({ - event, - casePermissions, - appId: observabilityFeatureId, - onClose: afterCaseSelection, - })} - , + timelines.getAddToExistingCaseButton({ + event, + casePermissions, + appId: observabilityFeatureId, + onClose: afterCaseSelection, + }), + timelines.getAddToNewCaseButton({ + event, + casePermissions, + appId: observabilityFeatureId, + onClose: afterCaseSelection, + }), + ...(alertPermissions.crud ? statusActionItems : []), ], }, ]; - }, [afterCaseSelection, casePermissions, timelines, event]); + }, [afterCaseSelection, casePermissions, timelines, event, statusActionItems, alertPermissions]); + return ( <> @@ -247,7 +282,7 @@ function ObservabilityActions({ color="text" iconType="boxesHorizontal" aria-label="More" - onClick={() => openActionsPopover(eventId)} + onClick={() => toggleActionsPopover(eventId)} /> } isOpen={openActionsPopoverId === eventId} @@ -286,11 +321,17 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { ); }, rowCellRender: (actionProps: ActionProps) => { - return ; + return ( + + ); }, }, ]; - }, []); + }, [status]); const tGridProps = useMemo(() => { const type: TGridType = 'standalone'; diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index fb23856dcd5a3..baed76d49aac8 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -5,14 +5,7 @@ * 2.0. */ -import { - EuiButtonEmpty, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; @@ -27,6 +20,7 @@ import { AlertsTableTGrid } from './alerts_table_t_grid'; import { StatusFilter } from './status_filter'; import { useFetcher } from '../../hooks/use_fetcher'; import { callObservabilityApi } from '../../services/call_observability_api'; +import './styles.scss'; export interface TopAlert { fields: ParsedTechnicalFields; @@ -126,7 +120,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { ], }} > - + - - - - - - - - - - - 0 ? dynamicIndexPattern[0].title : ''} - rangeFrom={rangeFrom} - rangeTo={rangeTo} - kuery={kuery} - status={status} - setRefetch={setRefetch} - /> - - + + + + + + + + + + + 0 ? dynamicIndexPattern[0].title : ''} + rangeFrom={rangeFrom} + rangeTo={rangeTo} + kuery={kuery} + status={status} + setRefetch={setRefetch} + /> + + + ); diff --git a/x-pack/plugins/observability/public/pages/alerts/styles.scss b/x-pack/plugins/observability/public/pages/alerts/styles.scss new file mode 100644 index 0000000000000..cee2ea31a0ea8 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/styles.scss @@ -0,0 +1,12 @@ +$fullscreenFlyoutTop: 72px; +.kbnBody.euiBody--headerIsFixed.euiDataGrid__restrictBody { + .euiOverlayMask--belowHeader { + top: $fullscreenFlyoutTop; + } + + .euiFlyout, + .euiCollapsibleNav { + top: $fullscreenFlyoutTop; + height: calc(100% - #{$fullscreenFlyoutTop}); + } +} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index f51f76a395199..7d11050f14d15 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -24,6 +24,7 @@ import type { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../src/plugins/data/public'; +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { HomePublicPluginSetup, HomePublicPluginStart, @@ -52,6 +53,7 @@ export interface ObservabilityPublicPluginsSetup { export interface ObservabilityPublicPluginsStart { cases: CasesUiStart; + embeddable: EmbeddableStart; home?: HomePublicPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; diff --git a/x-pack/plugins/osquery/kibana.json b/x-pack/plugins/osquery/kibana.json index f947866641c28..a499b2b75ee68 100644 --- a/x-pack/plugins/osquery/kibana.json +++ b/x-pack/plugins/osquery/kibana.json @@ -1,25 +1,14 @@ { - "configPath": [ - "xpack", - "osquery" - ], - "extraPublicDirs": [ - "common" - ], + "configPath": ["xpack", "osquery"], + "extraPublicDirs": ["common"], "id": "osquery", + "owner": { + "name": "Security asset management", + "githubTeam": "security-asset-management" + }, "kibanaVersion": "kibana", - "optionalPlugins": [ - "fleet", - "home", - "usageCollection", - "lens" - ], - "requiredBundles": [ - "esUiShared", - "fleet", - "kibanaUtils", - "kibanaReact" - ], + "optionalPlugins": ["fleet", "home", "usageCollection", "lens"], + "requiredBundles": ["esUiShared", "fleet", "kibanaUtils", "kibanaReact"], "requiredPlugins": [ "actions", "data", diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index ae3ddb1c0b861..1ab87949e3493 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -167,7 +167,7 @@ const ViewResultsInLensActionComponent: React.FC { - const openInNewWindow = !(!isModifiedEvent(event) && isLeftClickEvent(event)); + const openInNewTab = !(!isModifiedEvent(event) && isLeftClickEvent(event)); event.preventDefault(); @@ -181,7 +181,9 @@ const ViewResultsInLensActionComponent: React.FC { causes: undefined, error: undefined, }, - message: 'Response Error', + message: '{"message":"test error"}', }); expect(getSettingsMockFn).toHaveBeenCalled(); diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index faa187aa15d83..8833453efecec 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -46,17 +46,18 @@ export interface ReportDocumentHead { _primary_term: number; } -export interface ReportOutput { - content_type: string | null; +export interface ReportOutput extends TaskRunResult { content: string | null; size: number; +} + +export interface TaskRunResult { + content_type: string | null; csv_contains_formulas?: boolean; max_size_reached?: boolean; warnings?: string[]; } -export type TaskRunResult = Omit; - export interface BaseParams { layout?: LayoutParams; objectType: string; diff --git a/x-pack/plugins/reporting/public/lib/job.tsx b/x-pack/plugins/reporting/public/lib/job.tsx index 31205af340741..d5d0695aaefb9 100644 --- a/x-pack/plugins/reporting/public/lib/job.tsx +++ b/x-pack/plugins/reporting/public/lib/job.tsx @@ -11,7 +11,13 @@ import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; import React from 'react'; import { JOB_STATUSES } from '../../common/constants'; -import { JobId, ReportApiJSON, ReportSource, TaskRunResult } from '../../common/types'; +import { + JobId, + ReportApiJSON, + ReportOutput, + ReportSource, + TaskRunResult, +} from '../../common/types'; const { COMPLETED, FAILED, PENDING, PROCESSING, WARNINGS } = JOB_STATUSES; @@ -45,7 +51,7 @@ export class Job { public kibana_id: ReportSource['kibana_id']; public browser_type: ReportSource['browser_type']; - public size?: TaskRunResult['size']; + public size?: ReportOutput['size']; public content_type?: TaskRunResult['content_type']; public csv_contains_formulas?: TaskRunResult['csv_contains_formulas']; public max_size_reached?: TaskRunResult['max_size_reached']; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 151519b0b6b8f..a981fb964bfcc 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -62,7 +62,6 @@ interface IReportingAPI { getDownloadLink: DownloadReportFn; // Diagnostic-related API calls - verifyConfig(): Promise; verifyBrowser(): Promise; verifyScreenCapture(): Promise; } @@ -196,12 +195,6 @@ export class ReportingAPIClient implements IReportingAPI { public getServerBasePath = () => this.http.basePath.serverBasePath; - public async verifyConfig() { - return await this.http.post(`${API_BASE_URL}/diagnose/config`, { - asSystemRequest: true, - }); - } - public async verifyBrowser() { return await this.http.post(`${API_BASE_URL}/diagnose/browser`, { asSystemRequest: true, diff --git a/x-pack/plugins/reporting/public/management/report_diagnostic.tsx b/x-pack/plugins/reporting/public/management/report_diagnostic.tsx index 7525cf3ba7303..ce585fe427e6c 100644 --- a/x-pack/plugins/reporting/public/management/report_diagnostic.tsx +++ b/x-pack/plugins/reporting/public/management/report_diagnostic.tsx @@ -30,14 +30,12 @@ interface Props { type ResultStatus = 'danger' | 'incomplete' | 'complete'; enum statuses { - configStatus = 'configStatus', chromeStatus = 'chromeStatus', screenshotStatus = 'screenshotStatus', } interface State { isFlyoutVisible: boolean; - configStatus: ResultStatus; chromeStatus: ResultStatus; screenshotStatus: ResultStatus; help: string[]; @@ -47,7 +45,6 @@ interface State { } const initialState: State = { - [statuses.configStatus]: 'incomplete', [statuses.chromeStatus]: 'incomplete', [statuses.screenshotStatus]: 'incomplete', isFlyoutVisible: false, @@ -64,16 +61,7 @@ export const ReportDiagnostic = ({ apiClient }: Props) => { ...state, ...s, }); - const { - configStatus, - isBusy, - screenshotStatus, - chromeStatus, - isFlyoutVisible, - help, - logs, - success, - } = state; + const { isBusy, screenshotStatus, chromeStatus, isFlyoutVisible, help, logs, success } = state; const closeFlyout = () => setState({ ...initialState, isFlyoutVisible: false }); const showFlyout = () => setState({ isFlyoutVisible: true }); @@ -107,35 +95,6 @@ export const ReportDiagnostic = ({ apiClient }: Props) => { const steps = [ { - title: i18n.translate('xpack.reporting.listing.diagnosticConfigTitle', { - defaultMessage: 'Verify Kibana configuration', - }), - children: ( - - - - apiClient.verifyConfig(), statuses.configStatus)} - iconType={configStatus === 'complete' ? 'check' : undefined} - > - - - - ), - status: !success && configStatus !== 'complete' ? 'danger' : configStatus, - }, - ]; - - if (configStatus === 'complete') { - steps.push({ title: i18n.translate('xpack.reporting.listing.diagnosticBrowserTitle', { defaultMessage: 'Check browser', }), @@ -160,8 +119,8 @@ export const ReportDiagnostic = ({ apiClient }: Props) => { ), status: !success && chromeStatus !== 'complete' ? 'danger' : chromeStatus, - }); - } + }, + ]; if (chromeStatus === 'complete') { steps.push({ diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 823ccc3906e49..f14bb249524e2 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -160,7 +160,7 @@ export class HeadlessChromiumDriver { /* * Call Page.screenshot and return a base64-encoded string of the image */ - public async screenshot(elementPosition: ElementPosition): Promise { + public async screenshot(elementPosition: ElementPosition): Promise { const { boundingClientRect, scroll } = elementPosition; const screenshot = await this.page.screenshot({ clip: { @@ -171,10 +171,15 @@ export class HeadlessChromiumDriver { }, }); - if (screenshot) { - return screenshot.toString('base64'); + if (Buffer.isBuffer(screenshot)) { + return screenshot; } - return screenshot; + + if (typeof screenshot === 'string') { + return Buffer.from(screenshot, 'base64'); + } + + return undefined; } public async evaluate( diff --git a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts index 1f186010eb8bb..1cc1b15dbdfba 100644 --- a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -15,15 +15,6 @@ import { LayoutParams, PreserveLayout } from '../../lib/layouts'; import { getScreenshots$, ScreenshotResults } from '../../lib/screenshots'; import { ConditionalHeaders } from '../common'; -function getBase64DecodedSize(value: string) { - // @see https://en.wikipedia.org/wiki/Base64#Output_padding - return ( - (value.length * 3) / 4 - - Number(value[value.length - 1] === '=') - - Number(value[value.length - 2] === '=') - ); -} - export async function generatePngObservableFactory(reporting: ReportingCore) { const config = reporting.getConfig(); const captureConfig = config.get('capture'); @@ -35,7 +26,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { browserTimezone: string | undefined, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams - ): Rx.Observable<{ base64: string | null; warnings: string[] }> { + ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { const apmTrans = apm.startTransaction('reporting generate_png', 'reporting'); const apmLayout = apmTrans?.startSpan('create_layout', 'setup'); if (!layoutParams || !layoutParams.dimensions) { @@ -58,7 +49,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { apmBuffer = apmTrans?.startSpan('get_buffer', 'output') ?? null; }), map((results: ScreenshotResults[]) => ({ - base64: results[0].screenshots[0].base64EncodedData, + buffer: results[0].screenshots[0].data, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); @@ -66,11 +57,9 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { return found; }, [] as string[]), })), - tap(({ base64 }) => { - const byteLength = getBase64DecodedSize(base64); - - logger.debug(`PNG buffer byte length: ${byteLength}`); - apmTrans?.setLabel('byte_length', byteLength, false); + tap(({ buffer }) => { + logger.debug(`PNG buffer byte length: ${buffer.byteLength}`); + apmTrans?.setLabel('byte_length', buffer.byteLength, false); }), finalize(() => { apmBuffer?.end(); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts index 5e53a8c70aa68..a69de1034f5a4 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts @@ -28,7 +28,7 @@ const getMockJob = (base: object) => base as TaskPayloadPNG & TaskPayloadPDF; test(`fails if no URL is passed`, async () => { const fn = () => getFullUrls(mockConfig, getMockJob({})); expect(fn).toThrowErrorMatchingInlineSnapshot( - `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` + `"No valid URL fields found in Job Params! Expected \`job.relativeUrl\` or \`job.objects[{ relativeUrl }]\`"` ); }); @@ -54,14 +54,7 @@ test(`fails if URLs are absolute for PNGs`, async () => { test(`fails if URLs are file-protocols for PDF`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'file://etc/passwd/#/something'; - const fn = () => - getFullUrls( - mockConfig, - getMockJob({ - relativeUrls: [relativeUrl], - forceNow, - }) - ); + const fn = () => getFullUrls(mockConfig, getMockJob({ objects: [{ relativeUrl }], forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` ); @@ -75,7 +68,7 @@ test(`fails if URLs are absolute for PDF`, async () => { getFullUrls( mockConfig, getMockJob({ - relativeUrls: [relativeUrl], + objects: [{ relativeUrl }], forceNow, }) ); @@ -86,13 +79,16 @@ test(`fails if URLs are absolute for PDF`, async () => { test(`fails if any URLs are absolute or file's for PDF`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const relativeUrls = [ - '/app/kibana#/something_aaa', - 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something', - 'file://etc/passwd/#/something', + const objects = [ + { relativeUrl: '/app/kibana#/something_aaa' }, + { + relativeUrl: + 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something', + }, + { relativeUrl: 'file://etc/passwd/#/something' }, ]; - const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrls, forceNow })); + const fn = () => getFullUrls(mockConfig, getMockJob({ objects, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` ); @@ -107,7 +103,7 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls( + const urls = getFullUrls( mockConfig, getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }) ); @@ -120,7 +116,7 @@ test(`adds forceNow to hash's query, if it exists`, async () => { test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls( + const urls = getFullUrls( mockConfig, getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }) ); @@ -131,21 +127,21 @@ test(`appends forceNow to hash's query, if it exists`, async () => { }); test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { - const urls = await getFullUrls(mockConfig, getMockJob({ relativeUrl: '/app/kibana#/something' })); + const urls = getFullUrls(mockConfig, getMockJob({ relativeUrl: '/app/kibana#/something' })); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); }); test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls( + const urls = getFullUrls( mockConfig, getMockJob({ - relativeUrls: [ - '/app/kibana#/something_aaa', - '/app/kibana#/something_bbb', - '/app/kibana#/something_ccc', - '/app/kibana#/something_ddd', + objects: [ + { relativeUrl: '/app/kibana#/something_aaa' }, + { relativeUrl: '/app/kibana#/something_bbb' }, + { relativeUrl: '/app/kibana#/something_ccc' }, + { relativeUrl: '/app/kibana#/something_ddd' }, ], forceNow, }) diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts index 0fe1298932b36..5ae4092a466fa 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts @@ -21,7 +21,7 @@ function isPngJob(job: TaskPayloadPNG | TaskPayloadPDF): job is TaskPayloadPNG { return (job as TaskPayloadPNG).relativeUrl !== undefined; } function isPdfJob(job: TaskPayloadPNG | TaskPayloadPDF): job is TaskPayloadPDF { - return (job as TaskPayloadPDF).relativeUrls !== undefined; + return (job as TaskPayloadPDF).objects !== undefined; } export function getFullUrls(config: ReportingConfig, job: TaskPayloadPDF | TaskPayloadPNG) { @@ -39,17 +39,17 @@ export function getFullUrls(config: ReportingConfig, job: TaskPayloadPDF | TaskP if (isPngJob(job)) { relativeUrls = [job.relativeUrl]; } else if (isPdfJob(job)) { - relativeUrls = job.relativeUrls; + relativeUrls = job.objects.map((obj) => obj.relativeUrl); } else { throw new Error( - `No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`` + `No valid URL fields found in Job Params! Expected \`job.relativeUrl\` or \`job.objects[{ relativeUrl }]\`` ); } validateUrls(relativeUrls); const urls = relativeUrls.map((relativeUrl) => { - const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); + const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); // FIXME: '(urlStr: string): UrlWithStringQuery' is deprecated const jobUrl = getAbsoluteUrl({ path: parsedRelative.pathname === null ? undefined : parsedRelative.pathname, hash: parsedRelative.hash === null ? undefined : parsedRelative.hash, diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts index e4c0285d2ce4f..3349552490a39 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts @@ -9,7 +9,10 @@ import { PreserveLayout, PrintLayout } from '../../../lib/layouts'; import { createMockConfig, createMockConfigSchema } from '../../../test_helpers'; import { PdfMaker } from './'; -const imageBase64 = `iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAGFBMVEXy8vJpaWn7+/vY2Nj39/cAAACcnJzx8fFvt0oZAAAAi0lEQVR4nO3SSQoDIBBFwR7U3P/GQXKEIIJULXr9H3TMrHhX5Yysvj3jjM8+XRnVa9wec8QuHKv3h74Z+PNyGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/xu3Bxy026rXu4ljdUVW395xUFfGzLo946DK+QW+bgCTFcecSAAAAABJRU5ErkJggg==`; +const imageBase64 = Buffer.from( + `iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAGFBMVEXy8vJpaWn7+/vY2Nj39/cAAACcnJzx8fFvt0oZAAAAi0lEQVR4nO3SSQoDIBBFwR7U3P/GQXKEIIJULXr9H3TMrHhX5Yysvj3jjM8+XRnVa9wec8QuHKv3h74Z+PNyGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/xu3Bxy026rXu4ljdUVW395xUFfGzLo946DK+QW+bgCTFcecSAAAAABJRU5ErkJggg==`, + 'base64' +); describe('PdfMaker', () => { it('makes PDF using PrintLayout mode', async () => { diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts index 3338b83321cec..4e5309bcff5b1 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts @@ -101,10 +101,10 @@ export class PdfMaker { this._addContents(contents); } - addImage(base64EncodedData: string, opts = { title: '', description: '' }) { + addImage(image: Buffer, opts = { title: '', description: '' }) { const size = this._layout.getPdfImageSize(); const img = { - image: `data:image/png;base64,${base64EncodedData}`, + image: `data:image/png;base64,${image.toString('base64')}`, alignment: 'center' as 'center', height: size.height, width: size.width, diff --git a/x-pack/plugins/reporting/server/export_types/common/validate_urls.ts b/x-pack/plugins/reporting/server/export_types/common/validate_urls.ts index 22e0fc7de8455..e6d392c0bb55c 100644 --- a/x-pack/plugins/reporting/server/export_types/common/validate_urls.ts +++ b/x-pack/plugins/reporting/server/export_types/common/validate_urls.ts @@ -22,6 +22,15 @@ const isBogusUrl = (url: string) => { }; export const validateUrls = (urls: string[]): void => { + if (!Array.isArray(urls)) { + throw new Error('Invalid relativeUrls. String[] is expected.'); + } + urls.forEach((url) => { + if (typeof url !== 'string') { + throw new Error('Invalid Relative URL in relativeUrls. String is expected.'); + } + }); + const badUrls = filter(urls, (url) => isBogusUrl(url)); if (badUrls.length) { diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 835eddc03d9f9..3f51b4a23b584 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -10,7 +10,6 @@ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import nodeCrypto from '@elastic/node-crypto'; import { ElasticsearchClient, IUiSettingsClient } from 'kibana/server'; import moment from 'moment'; -// @ts-ignore import Puid from 'puid'; import sinon from 'sinon'; import { ReportingConfig, ReportingCore } from '../../'; diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts index 322cde38d7fd6..0ae0adb2fc1de 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts @@ -27,7 +27,7 @@ export const runTaskFnFactory: RunTaskFnFactory< const uiSettingsClient = await reporting.getUiSettingsClient(fakeRequest, logger); const { asCurrentUser: elasticsearchClient } = elasticsearch.asScoped(fakeRequest); - const { maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( + const { maxSizeReached, csvContainsFormulas, warnings } = await generateCsv( job, config, uiSettingsClient, @@ -40,7 +40,6 @@ export const runTaskFnFactory: RunTaskFnFactory< return { content_type: CONTENT_TYPE_CSV, max_size_reached: maxSizeReached, - size, csv_contains_formulas: csvContainsFormulas, warnings, }; diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts index 9dfc32b90e8c5..61f404ed2fb02 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts @@ -76,7 +76,6 @@ export function createGenerateCsv(logger: LevelLogger) { if (!builder.tryAppend(header)) { return { - size: 0, maxSizeReached: true, warnings: [], }; @@ -141,8 +140,6 @@ export function createGenerateCsv(logger: LevelLogger) { } finally { await iterator.return(); } - const size = builder.getSizeInBytes(); - logger.debug(`finished generating, total size in bytes: ${size}`); if (csvContainsFormulas && settings.escapeFormulaValues) { warnings.push( @@ -155,7 +152,6 @@ export function createGenerateCsv(logger: LevelLogger) { return { csvContainsFormulas: csvContainsFormulas && !settings.escapeFormulaValues, maxSizeReached, - size, warnings, }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index 8ba87445efd9d..0263e82040f17 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -78,7 +78,6 @@ type FormatsMapDeprecatedCSV = Map< >; export interface SavedSearchGeneratorResultDeprecatedCSV { - size: number; maxSizeReached: boolean; csvContainsFormulas?: boolean; warnings: string[]; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts index 8751200504405..3e4e663733e2c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -226,7 +226,6 @@ it('calculates the bytes of the content', async () => { stream ); const csvResult = await generateCsv.generateData(); - expect(csvResult.size).toBe(2608); expect(csvResult.max_size_reached).toBe(false); expect(csvResult.warnings).toEqual([]); }); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 8488b54b77f00..b49dbc1043135 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -231,7 +231,6 @@ export class CsvGenerator { if (!builder.tryAppend(header)) { return { - size: 0, content: '', maxSizeReached: true, warnings: [], @@ -401,16 +400,12 @@ export class CsvGenerator { } } - const size = builder.getSizeInBytes(); - this.logger.debug( - `Finished generating. Total size in bytes: ${size}. Row count: ${this.csvRowCount}.` - ); + this.logger.debug(`Finished generating. Row count: ${this.csvRowCount}.`); return { content_type: CONTENT_TYPE_CSV, csv_contains_formulas: this.csvContainsFormulas && !escapeFormulaValues, max_size_reached: this.maxSizeReached, - size, warnings, }; } diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts index c261fa62d97d4..34b7c1c06623d 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts @@ -76,7 +76,7 @@ export const runTaskFnFactory: RunTaskFnFactory = function e } if (result.max_size_reached) { - logger.warn(`Max size reached: CSV output truncated to ${result.size} bytes`); + logger.warn(`Max size reached: CSV output truncated`); } const { warnings } = result; diff --git a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts index cbfbc4a4d34b2..a2f58e5835f22 100644 --- a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts @@ -22,6 +22,7 @@ export const createJobFnFactory: CreateJobFnFactory< validateUrls([jobParams.relativeUrl]); return { + isDeprecated: true, headers: serializedEncryptedHeaders, spaceId: reporting.getSpaceId(req, logger), forceNow: new Date().toISOString(), diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts index 61a987a8a8578..25f0f8e1004b3 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts @@ -70,7 +70,7 @@ afterEach(() => (generatePngObservableFactory as jest.Mock).mockReset()); test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -121,7 +121,7 @@ test(`returns content_type of application/png`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = await generatePngObservableFactory(mockReporting); - (generatePngObservable as jest.Mock).mockReturnValue(Rx.of('foo')); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('foo') })); const { content_type: contentType } = await runTask( 'pngJobId', @@ -132,10 +132,10 @@ test(`returns content_type of application/png`, async () => { expect(contentType).toBe('image/png'); }); -test(`returns content of generatePng getBuffer base64 encoded`, async () => { +test(`returns content of generatePng`, async () => { const testContent = 'raw string from get_screenhots'; const generatePngObservable = await generatePngObservableFactory(mockReporting); - (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ base64: testContent })); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index c602db61bbbc8..34fa745832b8b 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -51,10 +51,9 @@ export const runTaskFnFactory: RunTaskFnFactory< job.layout ); }), - tap(({ base64 }) => stream.write(base64)), - map(({ base64, warnings }) => ({ + tap(({ buffer }) => stream.write(buffer)), + map(({ warnings }) => ({ content_type: 'image/png', - size: (base64 && base64.length) || 0, warnings, })), catchError((err) => { diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts index 2fe0aff58069c..b654e5cc9377e 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts @@ -71,7 +71,7 @@ afterEach(() => (generatePngObservableFactory as jest.Mock).mockReset()); test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -132,7 +132,7 @@ test(`returns content_type of application/png`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = await generatePngObservableFactory(mockReporting); - (generatePngObservable as jest.Mock).mockReturnValue(Rx.of('foo')); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('foo') })); const { content_type: contentType } = await runTask( 'pngJobId', @@ -149,7 +149,7 @@ test(`returns content_type of application/png`, async () => { test(`returns content of generatePng getBuffer base64 encoded`, async () => { const testContent = 'raw string from get_screenhots'; const generatePngObservable = await generatePngObservableFactory(mockReporting); - (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ base64: testContent })); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index 66e13679498aa..06fdcd93e497c 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -54,11 +54,9 @@ export const runTaskFnFactory: RunTaskFnFactory< job.layout ); }), - tap(({ base64 }) => stream.write(base64)), - map(({ base64, warnings }) => ({ + tap(({ buffer }) => stream.write(buffer)), + map(({ warnings }) => ({ content_type: 'image/png', - content: base64, - size: (base64 && base64.length) || 0, warnings, })), catchError((err) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.test.ts new file mode 100644 index 0000000000000..4e366f506af2d --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock, httpServerMock } from 'src/core/server/mocks'; +import { createMockLevelLogger } from '../../../test_helpers'; +import { compatibilityShim } from './compatibility_shim'; + +const mockRequestHandlerContext = { + core: coreMock.createRequestHandlerContext(), + reporting: { usesUiCapabilities: () => true }, +}; +const mockLogger = createMockLevelLogger(); + +const mockKibanaRequest = httpServerMock.createKibanaRequest(); +const createMockSavedObject = (body: any) => ({ + id: 'mockSavedObjectId123', + type: 'mockSavedObjectType', + references: [], + ...body, +}); +const createMockJobParams = (body: any) => ({ + ...body, +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test(`passes title through if provided`, async () => { + const title = 'test title'; + + const createJobMock = jest.fn(); + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ title, relativeUrls: ['/something'] }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(0); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][0].title).toBe(title); +}); + +test(`gets the title from the savedObject`, async () => { + const createJobMock = jest.fn(); + const title = 'savedTitle'; + mockRequestHandlerContext.core.savedObjects.client.get.mockResolvedValue( + createMockSavedObject({ + attributes: { title }, + }) + ); + + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ objectType: 'search', savedObjectId: 'abc' }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(2); + expect(mockLogger.warn.mock.calls[0][0]).toEqual( + 'The relativeUrls have been derived from saved object parameters. This functionality will be removed with the next major version.' + ); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][0].title).toBe(title); +}); + +test(`passes the objectType and savedObjectId to the savedObjectsClient`, async () => { + const createJobMock = jest.fn(); + const context = mockRequestHandlerContext; + context.core.savedObjects.client.get.mockResolvedValue( + createMockSavedObject({ attributes: { title: '' } }) + ); + + const objectType = 'search'; + const savedObjectId = 'abc'; + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ objectType, savedObjectId }), + context, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(2); + expect(mockLogger.warn.mock.calls[0][0]).toEqual( + 'The relativeUrls have been derived from saved object parameters. This functionality will be removed with the next major version.' + ); + expect(mockLogger.warn.mock.calls[1][0]).toEqual( + 'The title has been derived from saved object parameters. This functionality will be removed with the next major version.' + ); + expect(mockLogger.error.mock.calls.length).toBe(0); + + const getMock = context.core.savedObjects.client.get.mock; + expect(getMock.calls.length).toBe(1); + expect(getMock.calls[0][0]).toBe(objectType); + expect(getMock.calls[0][1]).toBe(savedObjectId); +}); + +test(`logs no warnings when title and relativeUrls is passed`, async () => { + const createJobMock = jest.fn(); + + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ title: 'Phenomenal Dashboard', relativeUrls: ['/abc', '/def'] }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(0); + expect(mockLogger.error.mock.calls.length).toBe(0); +}); + +test(`logs warning if title can not be provided`, async () => { + const createJobMock = jest.fn(); + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ relativeUrls: ['/abc'] }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(1); + expect(mockLogger.warn.mock.calls[0][0]).toEqual( + `A title parameter should be provided with the job generation request. Please ` + + `use Kibana to regenerate your POST URL to have a title included in the PDF.` + ); +}); + +test(`logs deprecations when generating the title/relativeUrl using the savedObject`, async () => { + const createJobMock = jest.fn(); + mockRequestHandlerContext.core.savedObjects.client.get.mockResolvedValue( + createMockSavedObject({ + attributes: { title: '' }, + }) + ); + + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ objectType: 'search', savedObjectId: 'abc' }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(2); + expect(mockLogger.warn.mock.calls[0][0]).toEqual( + 'The relativeUrls have been derived from saved object parameters. This functionality will be removed with the next major version.' + ); + expect(mockLogger.warn.mock.calls[1][0]).toEqual( + 'The title has been derived from saved object parameters. This functionality will be removed with the next major version.' + ); +}); + +test(`passes objectType through`, async () => { + const createJobMock = jest.fn(); + + const objectType = 'foo'; + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ title: 'test', relativeUrls: ['/something'], objectType }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(0); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][0].objectType).toBe(objectType); +}); + +test(`passes the relativeUrls through`, async () => { + const createJobMock = jest.fn(); + + const relativeUrls = ['/app/kibana#something', '/app/kibana#something-else']; + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ title: 'test', relativeUrls }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(0); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][0].relativeUrls).toBe(relativeUrls); +}); + +const testSavedObjectRelativeUrl = (objectType: string, expectedUrl: string) => { + test(`generates the saved object relativeUrl for ${objectType}`, async () => { + const createJobMock = jest.fn(); + + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ title: 'test', objectType, savedObjectId: 'abc' }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(1); + expect(mockLogger.warn.mock.calls[0][0]).toEqual( + 'The relativeUrls have been derived from saved object parameters. This functionality will be removed with the next major version.' + ); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][0].relativeUrls).toEqual([expectedUrl]); + }); +}; + +testSavedObjectRelativeUrl('search', '/app/kibana#/discover/abc?'); +testSavedObjectRelativeUrl('visualization', '/app/kibana#/visualize/edit/abc?'); +testSavedObjectRelativeUrl('dashboard', '/app/kibana#/dashboard/abc?'); + +test(`appends the queryString to the relativeUrl when generating from the savedObject`, async () => { + const createJobMock = jest.fn(); + + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ + title: 'test', + objectType: 'search', + savedObjectId: 'abc', + queryString: 'foo=bar', + }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(1); + expect(mockLogger.warn.mock.calls[0][0]).toEqual( + 'The relativeUrls have been derived from saved object parameters. This functionality will be removed with the next major version.' + ); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][0].relativeUrls).toEqual([ + '/app/kibana#/discover/abc?foo=bar', + ]); +}); + +test(`throw an Error if the objectType, savedObjectId and relativeUrls are provided`, async () => { + const createJobMock = jest.fn(); + + const promise = compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ + title: 'test', + objectType: 'something', + relativeUrls: ['/something'], + savedObjectId: 'abc', + }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + await expect(promise).rejects.toBeDefined(); +}); + +test(`passes headers and request through`, async () => { + const createJobMock = jest.fn(); + + await compatibilityShim(createJobMock, mockLogger)( + createMockJobParams({ title: 'test', relativeUrls: ['/something'] }), + mockRequestHandlerContext, + mockKibanaRequest + ); + + expect(mockLogger.warn.mock.calls.length).toBe(0); + expect(mockLogger.error.mock.calls.length).toBe(0); + + expect(createJobMock.mock.calls.length).toBe(1); + expect(createJobMock.mock.calls[0][1]).toBe(mockRequestHandlerContext); + expect(createJobMock.mock.calls[0][2]).toBe(mockKibanaRequest); +}); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts new file mode 100644 index 0000000000000..f806b8a7e5bca --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/compatibility_shim.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from 'kibana/server'; +import { url as urlUtils } from '../../../../../../../src/plugins/kibana_utils/server'; +import type { LevelLogger } from '../../../lib'; +import type { CreateJobFn, ReportingRequestHandlerContext } from '../../../types'; +import type { JobParamsPDF, JobParamsPDFLegacy, TaskPayloadPDF } from '../types'; + +function isLegacyJob( + jobParams: JobParamsPDF | JobParamsPDFLegacy +): jobParams is JobParamsPDFLegacy { + return (jobParams as JobParamsPDFLegacy).savedObjectId != null; +} + +const getSavedObjectTitle = async ( + objectType: string, + savedObjectId: string, + savedObjectsClient: any +) => { + const savedObject = await savedObjectsClient.get(objectType, savedObjectId); + return savedObject.attributes.title; +}; + +const getSavedObjectRelativeUrl = ( + objectType: string, + savedObjectId: string, + queryString: string +) => { + const appPrefixes: Record = { + dashboard: '/dashboard/', + visualization: '/visualize/edit/', + search: '/discover/', + }; + + const appPrefix = appPrefixes[objectType]; + if (!appPrefix) throw new Error('Unexpected app type: ' + objectType); + + const hash = appPrefix + urlUtils.encodeUriQuery(savedObjectId, true); + + return `/app/kibana#${hash}?${queryString || ''}`; +}; + +/* + * The compatibility shim is responsible for migrating an older shape of the + * PDF Job Params into a newer shape, by deriving a report title and relative + * URL from a savedObjectId and queryString. + */ +export function compatibilityShim( + createJobFn: CreateJobFn, + logger: LevelLogger +) { + return async function ( + jobParams: JobParamsPDF | JobParamsPDFLegacy, + context: ReportingRequestHandlerContext, + req: KibanaRequest + ) { + let kibanaRelativeUrls = (jobParams as JobParamsPDF).relativeUrls; + let reportTitle = jobParams.title; + let isDeprecated = false; + + if ( + (jobParams as JobParamsPDFLegacy).savedObjectId && + (jobParams as JobParamsPDF).relativeUrls + ) { + throw new Error(`savedObjectId should not be provided if relativeUrls are provided`); + } + + if (isLegacyJob(jobParams)) { + const { savedObjectId, objectType, queryString } = jobParams; + + // input validation and deprecation logging + if (typeof savedObjectId !== 'string') { + throw new Error('Invalid savedObjectId (deprecated). String is expected.'); + } + if (typeof objectType !== 'string') { + throw new Error('Invalid objectType (deprecated). String is expected.'); + } + + // legacy parameters need to be converted into a relative URL + kibanaRelativeUrls = [getSavedObjectRelativeUrl(objectType, savedObjectId, queryString)]; + logger.warn( + `The relativeUrls have been derived from saved object parameters. ` + + `This functionality will be removed with the next major version.` + ); + + // legacy parameters might need to get the title from the saved object + if (!reportTitle) { + try { + reportTitle = await getSavedObjectTitle( + objectType, + savedObjectId, + context.core.savedObjects.client + ); + logger.warn( + `The title has been derived from saved object parameters. This ` + + `functionality will be removed with the next major version.` + ); + } catch (err) { + logger.error(err); // 404 for the savedObjectId, etc + throw err; + } + } + + isDeprecated = true; + } + + if (typeof reportTitle !== 'string') { + logger.warn( + `A title parameter should be provided with the job generation ` + + `request. Please use Kibana to regenerate your POST URL to have a ` + + `title included in the PDF.` + ); + reportTitle = ''; + } + + const transformedJobParams: JobParamsPDF = { + ...jobParams, + title: reportTitle, + relativeUrls: kibanaRelativeUrls, + isDeprecated, // tack on this flag so it will be saved the TaskPayload + }; + + return await createJobFn(transformedJobParams, context, req); + }; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts index 9dac1560ddbdc..011d0f237d3e6 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts @@ -5,27 +5,40 @@ * 2.0. */ +import { KibanaRequest } from 'src/core/server'; import { cryptoFactory } from '../../../lib'; -import { CreateJobFn, CreateJobFnFactory } from '../../../types'; +import { CreateJobFn, CreateJobFnFactory, ReportingRequestHandlerContext } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPDF, TaskPayloadPDF } from '../types'; +import { JobParamsPDF, JobParamsPDFLegacy, TaskPayloadPDF } from '../types'; +import { compatibilityShim } from './compatibility_shim'; +/* + * Incoming job params can be `JobParamsPDF` or `JobParamsPDFLegacy` depending + * on the version that the POST URL was copied from. + */ export const createJobFnFactory: CreateJobFnFactory< - CreateJobFn + CreateJobFn > = function createJobFactoryFn(reporting, logger) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); - return async function createJob(jobParams, _context, req) { - const serializedEncryptedHeaders = await crypto.encrypt(req.headers); + return compatibilityShim(async function createJobFn( + { relativeUrls, ...jobParams }: JobParamsPDF, // relativeUrls does not belong in the payload + _context: ReportingRequestHandlerContext, + req: KibanaRequest + ) { + validateUrls(relativeUrls); - validateUrls(jobParams.relativeUrls); + const serializedEncryptedHeaders = await crypto.encrypt(req.headers); + // return the payload return { + ...jobParams, + isDeprecated: true, headers: serializedEncryptedHeaders, spaceId: reporting.getSpaceId(req, logger), forceNow: new Date().toISOString(), - ...jobParams, + objects: relativeUrls.map((u) => ({ relativeUrl: u })), }; - }; + }, logger); }; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts index d58ec4cde0f3d..98c00287aa196 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts @@ -7,8 +7,8 @@ jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); -import { Writable } from 'stream'; import * as Rx from 'rxjs'; +import { Writable } from 'stream'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common'; import { cryptoFactory, LevelLogger } from '../../../lib'; @@ -65,7 +65,7 @@ afterEach(() => (generatePdfObservableFactory as jest.Mock).mockReset()); test(`passes browserTimezone to generatePdf`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = (await generatePdfObservableFactory(mockReporting)) as jest.Mock; - generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); + generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -91,11 +91,11 @@ test(`returns content_type of application/pdf`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = await generatePdfObservableFactory(mockReporting); - (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const { content_type: contentType } = await runTask( 'pdfJobId', - getBasePayload({ relativeUrls: [], headers: encryptedHeaders }), + getBasePayload({ objects: [], headers: encryptedHeaders }), cancellationToken, stream ); @@ -111,10 +111,10 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const encryptedHeaders = await encryptHeaders({}); await runTask( 'pdfJobId', - getBasePayload({ relativeUrls: [], headers: encryptedHeaders }), + getBasePayload({ objects: [], headers: encryptedHeaders }), cancellationToken, stream ); - expect(content).toEqual(Buffer.from(testContent).toString('base64')); + expect(content).toEqual(testContent); }); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index 1fcd448344bcf..22e4a588e976d 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -7,7 +7,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; -import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; +import { catchError, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; import { TaskRunResult } from '../../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../../types'; @@ -59,20 +59,16 @@ export const runTaskFnFactory: RunTaskFnFactory< logo ); }), - map(({ buffer, warnings }) => { + tap(({ buffer }) => { apmGeneratePdf?.end(); - const apmEncode = apmTrans?.startSpan('encode_pdf', 'output'); - const content = buffer?.toString('base64') || null; - apmEncode?.end(); - - stream.write(content); - - return { - content_type: 'application/pdf', - size: buffer?.byteLength || 0, - warnings, - }; + if (buffer) { + stream.write(buffer); + } }), + map(({ warnings }) => ({ + content_type: 'application/pdf', + warnings, + })), catchError((err) => { jobLogger.error(err); return Rx.throwError(err); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 737068eaba8b6..a7e492b882c20 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -69,10 +69,10 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { results.forEach((r) => { r.screenshots.forEach((screenshot) => { - logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.base64EncodedData?.length || 0}`); // prettier-ignore + logger.debug(`Adding image to PDF. Image size: ${screenshot.data.byteLength}`); // prettier-ignore tracker.startAddImage(); tracker.endAddImage(); - pdfOutput.addImage(screenshot.base64EncodedData, { + pdfOutput.addImage(screenshot.data, { title: screenshot.title, description: screenshot.description, }); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts index 5172bf300abc8..8e4c45ad79506 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts @@ -10,13 +10,22 @@ import { BaseParams, BasePayload } from '../../types'; interface BaseParamsPDF { layout: LayoutParams; - forceNow?: string; - // TODO: Add comment explaining this field relativeUrls: string[]; + isDeprecated?: boolean; } // Job params: structure of incoming user request data, after being parsed from RISON export type JobParamsPDF = BaseParamsPDF & BaseParams; // Job payload: structure of stored job data provided by create_job -export type TaskPayloadPDF = BaseParamsPDF & BasePayload; +export interface TaskPayloadPDF extends BasePayload { + layout: LayoutParams; + forceNow?: string; + objects: Array<{ relativeUrl: string }>; +} + +type Legacy = Omit; +export interface JobParamsPDFLegacy extends Legacy { + savedObjectId: string; + queryString: string; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts index f1d1ec82cdcdd..4d398956faba4 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts @@ -97,7 +97,7 @@ test(`returns content_type of application/pdf`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = await generatePdfObservableFactory(mockReporting); - (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const { content_type: contentType } = await runTask( 'pdfJobId', @@ -122,5 +122,5 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { stream ); - expect(content).toEqual(Buffer.from(testContent).toString('base64')); + expect(content).toEqual(testContent); }); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index c79f53d48e0f1..2211e7df08770 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -7,7 +7,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; -import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; +import { catchError, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { PDF_JOB_TYPE_V2 } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; @@ -44,7 +44,7 @@ export const runTaskFnFactory: RunTaskFnFactory< ), mergeMap(({ logo, conditionalHeaders }) => { const { browserTimezone, layout, title, locatorParams } = job; - if (apmGetAssets) apmGetAssets.end(); + apmGetAssets?.end(); apmGeneratePdf = apmTrans?.startSpan('generate_pdf_pipeline', 'execute'); return generatePdfObservable( @@ -58,22 +58,17 @@ export const runTaskFnFactory: RunTaskFnFactory< logo ); }), - map(({ buffer, warnings }) => { - if (apmGeneratePdf) apmGeneratePdf.end(); + tap(({ buffer, warnings }) => { + apmGeneratePdf?.end(); - const apmEncode = apmTrans?.startSpan('encode_pdf', 'output'); - const content = buffer?.toString('base64') || null; - apmEncode?.end(); - - stream.write(content); - - return { - content_type: 'application/pdf', - content, - size: buffer?.byteLength || 0, - warnings, - }; + if (buffer) { + stream.write(buffer); + } }), + map(({ warnings }) => ({ + content_type: 'application/pdf', + warnings, + })), catchError((err) => { jobLogger.error(err); return Rx.throwError(err); @@ -82,7 +77,7 @@ export const runTaskFnFactory: RunTaskFnFactory< const stop$ = Rx.fromEventPattern(cancellationToken.on); - if (apmTrans) apmTrans.end(); + apmTrans?.end(); return process$.pipe(takeUntil(stop$)).toPromise(); }; }; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index ff3ee8e52e53a..8cc2d4b3037b3 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -80,10 +80,10 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { results.forEach((r) => { r.screenshots.forEach((screenshot) => { - logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.base64EncodedData?.length || 0}`); // prettier-ignore + logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.data.byteLength}`); // prettier-ignore tracker.startAddImage(); tracker.endAddImage(); - pdfOutput.addImage(screenshot.base64EncodedData, { + pdfOutput.addImage(screenshot.data, { title: screenshot.title, description: screenshot.description, }); diff --git a/x-pack/plugins/reporting/server/lib/content_stream.test.ts b/x-pack/plugins/reporting/server/lib/content_stream.test.ts index 793fcc9c8a425..34b8982a5257d 100644 --- a/x-pack/plugins/reporting/server/lib/content_stream.test.ts +++ b/x-pack/plugins/reporting/server/lib/content_stream.test.ts @@ -6,20 +6,36 @@ */ import { set } from 'lodash'; -import { ElasticsearchClient } from 'src/core/server'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { createMockLevelLogger } from '../test_helpers'; import { ContentStream } from './content_stream'; +import { ExportTypesRegistry } from './export_types_registry'; describe('ContentStream', () => { - let client: jest.Mocked; + let client: ReturnType['asInternalUser']; + let exportTypesRegistry: jest.Mocked; + let logger: ReturnType; let stream: ContentStream; beforeEach(() => { client = elasticsearchServiceMock.createClusterClient().asInternalUser; - stream = new ContentStream(client, { id: 'something', index: 'somewhere' }); + exportTypesRegistry = ({ + get: jest.fn(() => ({})), + } as unknown) as typeof exportTypesRegistry; + logger = createMockLevelLogger(); + stream = new ContentStream(client, exportTypesRegistry, logger, { + id: 'something', + index: 'somewhere', + }); client.search.mockResolvedValue( - set({}, 'body.hits.hits.0._source.output.content', 'some content') + set({}, 'body.hits.hits.0._source', { + jobtype: 'pdf', + output: { + content: 'some content', + size: 12, + }, + }) ); }); @@ -61,17 +77,145 @@ describe('ContentStream', () => { expect(error).toBe('some error'); }); - }); - describe('toString', () => { - it('should return the document contents', async () => { - await expect(stream.toString()).resolves.toBe('some content'); + it('should decode base64 encoded content', async () => { + exportTypesRegistry.get.mockReturnValueOnce({ jobContentEncoding: 'base64' } as ReturnType< + typeof exportTypesRegistry.get + >); + client.search.mockResolvedValueOnce( + set( + {}, + 'body.hits.hits.0._source.output.content', + Buffer.from('encoded content').toString('base64') + ) + ); + const data = await new Promise((resolve) => stream.once('data', resolve)); + + expect(data).toEqual(Buffer.from('encoded content')); + }); + + it('should compound content from multiple chunks', async () => { + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source', { + jobtype: 'pdf', + output: { + content: '12', + size: 6, + }, + }) + ); + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source.output.content', '34') + ); + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source.output.content', '56') + ); + let data = ''; + for await (const chunk of stream) { + data += chunk; + } + + expect(data).toEqual('123456'); + expect(client.search).toHaveBeenCalledTimes(3); + + const [[request1], [request2], [request3]] = client.search.mock.calls; + + expect(request1).toHaveProperty( + 'body.query.constant_score.filter.bool.must.0.term._id', + 'something' + ); + expect(request2).toHaveProperty('index', 'somewhere'); + expect(request2).toHaveProperty( + 'body.query.constant_score.filter.bool.must.0.term.parent_id', + 'something' + ); + expect(request3).toHaveProperty('index', 'somewhere'); + expect(request3).toHaveProperty( + 'body.query.constant_score.filter.bool.must.0.term.parent_id', + 'something' + ); }); - it('should return an empty string for the empty document', async () => { + it('should stop reading on empty chunk', async () => { + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source', { + jobtype: 'pdf', + output: { + content: '12', + size: 6, + }, + }) + ); + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source.output.content', '34') + ); + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source.output.content', '') + ); + let data = ''; + for await (const chunk of stream) { + data += chunk; + } + + expect(data).toEqual('1234'); + expect(client.search).toHaveBeenCalledTimes(3); + }); + + it('should read until chunks are present when there is no size', async () => { + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source', { + jobtype: 'pdf', + output: { + content: '12', + }, + }) + ); + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source.output.content', '34') + ); client.search.mockResolvedValueOnce({ body: {} } as any); + let data = ''; + for await (const chunk of stream) { + data += chunk; + } - await expect(stream.toString()).resolves.toBe(''); + expect(data).toEqual('1234'); + expect(client.search).toHaveBeenCalledTimes(3); + }); + + it('should decode every chunk separately', async () => { + exportTypesRegistry.get.mockReturnValueOnce({ jobContentEncoding: 'base64' } as ReturnType< + typeof exportTypesRegistry.get + >); + client.search.mockResolvedValueOnce( + set({}, 'body.hits.hits.0._source', { + jobtype: 'pdf', + output: { + content: Buffer.from('12').toString('base64'), + size: 6, + }, + }) + ); + client.search.mockResolvedValueOnce( + set( + {}, + 'body.hits.hits.0._source.output.content', + Buffer.from('34').toString('base64') + ) + ); + client.search.mockResolvedValueOnce( + set( + {}, + 'body.hits.hits.0._source.output.content', + Buffer.from('56').toString('base64') + ) + ); + let data = ''; + for await (const chunk of stream) { + data += chunk; + } + + expect(data).toEqual('123456'); }); }); @@ -97,6 +241,15 @@ describe('ContentStream', () => { expect(request).toHaveProperty('body.doc.output.content', '123456'); }); + it('should update a number of written bytes', async () => { + stream.write('123'); + stream.write('456'); + stream.end(); + await new Promise((resolve) => stream.once('finish', resolve)); + + expect(stream.bytesWritten).toBe(6); + }); + it('should emit an error event', async () => { client.update.mockRejectedValueOnce('some error'); @@ -120,5 +273,138 @@ describe('ContentStream', () => { expect(stream.getPrimaryTerm()).toBe(1); expect(stream.getSeqNo()).toBe(10); }); + + it('should encode using base64', async () => { + exportTypesRegistry.get.mockReturnValueOnce({ jobContentEncoding: 'base64' } as ReturnType< + typeof exportTypesRegistry.get + >); + + stream.end('12345'); + await new Promise((resolve) => stream.once('finish', resolve)); + + expect(client.update).toHaveBeenCalledTimes(1); + + const [[request]] = client.update.mock.calls; + + expect(request).toHaveProperty( + 'body.doc.output.content', + Buffer.from('12345').toString('base64') + ); + }); + + it('should remove all previous chunks before writing', async () => { + stream.end('12345'); + await new Promise((resolve) => stream.once('finish', resolve)); + + expect(client.deleteByQuery).toHaveBeenCalledTimes(1); + + const [[request]] = client.deleteByQuery.mock.calls; + + expect(request).toHaveProperty('index', 'somewhere'); + expect(request).toHaveProperty('body.query.match.parent_id', 'something'); + }); + + it('should split raw data into chunks', async () => { + client.cluster.getSettings.mockResolvedValueOnce( + set({}, 'body.defaults.http.max_content_length', 1028) + ); + stream.end('123456'); + await new Promise((resolve) => stream.once('finish', resolve)); + + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining(set({}, 'body.doc.output.content', '12')) + ); + expect(client.index).toHaveBeenCalledTimes(2); + expect(client.index).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: expect.any(String), + index: 'somewhere', + body: { + parent_id: 'something', + output: { + content: '34', + chunk: 1, + }, + }, + }) + ); + expect(client.index).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: expect.any(String), + index: 'somewhere', + body: { + parent_id: 'something', + output: { + content: '56', + chunk: 2, + }, + }, + }) + ); + }); + + it('should encode every chunk separately', async () => { + exportTypesRegistry.get.mockReturnValueOnce({ jobContentEncoding: 'base64' } as ReturnType< + typeof exportTypesRegistry.get + >); + client.cluster.getSettings.mockResolvedValueOnce( + set({}, 'body.defaults.http.max_content_length', 1028) + ); + stream.end('12345678'); + await new Promise((resolve) => stream.once('finish', resolve)); + + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining( + set({}, 'body.doc.output.content', Buffer.from('123').toString('base64')) + ) + ); + expect(client.index).toHaveBeenCalledTimes(2); + expect(client.index).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: expect.any(String), + index: 'somewhere', + body: { + parent_id: 'something', + output: { + content: Buffer.from('456').toString('base64'), + chunk: 1, + }, + }, + }) + ); + expect(client.index).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: expect.any(String), + index: 'somewhere', + body: { + parent_id: 'something', + output: { + content: Buffer.from('78').toString('base64'), + chunk: 2, + }, + }, + }) + ); + }); + + it('should clear the job contents on writing empty data', async () => { + stream.end(); + await new Promise((resolve) => stream.once('finish', resolve)); + + expect(client.deleteByQuery).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); + + const [[deleteRequest]] = client.deleteByQuery.mock.calls; + const [[updateRequest]] = client.update.mock.calls; + + expect(deleteRequest).toHaveProperty('body.query.match.parent_id', 'something'); + expect(updateRequest).toHaveProperty('body.doc.output.content', ''); + }); }); }); diff --git a/x-pack/plugins/reporting/server/lib/content_stream.ts b/x-pack/plugins/reporting/server/lib/content_stream.ts index 5f95394abfb57..1d8ebf2bdfdbe 100644 --- a/x-pack/plugins/reporting/server/lib/content_stream.ts +++ b/x-pack/plugins/reporting/server/lib/content_stream.ts @@ -6,9 +6,21 @@ */ import { Duplex } from 'stream'; +import { defaults, get } from 'lodash'; +import Puid from 'puid'; +import { ByteSizeValue } from '@kbn/config-schema'; import type { ElasticsearchClient } from 'src/core/server'; import { ReportingCore } from '..'; -import { ReportDocument } from '../../common/types'; +import { ReportSource } from '../../common/types'; +import { ExportTypesRegistry } from './export_types_registry'; +import { LevelLogger } from './level_logger'; + +/** + * @note The Elasticsearch `http.max_content_length` is including the whole POST body. + * But the update/index request also contains JSON-serialized query parameters. + * 1Kb span should be enough for that. + */ +const REQUEST_SPAN_SIZE_IN_BYTES = 1024; type Callback = (error?: Error) => void; type SearchRequest = Required>[0]; @@ -20,19 +32,137 @@ interface ContentStreamDocument { if_seq_no?: number; } +interface ChunkOutput { + chunk: number; + content: string; +} + +interface ChunkSource { + parent_id: string; + output: ChunkOutput; +} + export class ContentStream extends Duplex { - private buffer = ''; + /** + * @see https://en.wikipedia.org/wiki/Base64#Output_padding + */ + private static getMaxBase64EncodedSize(max: number) { + return Math.floor(max / 4) * 3; + } + + /** + * @note Raw data might be escaped during JSON serialization. + * In the worst-case, every character is escaped, so the max raw data length is twice less. + */ + private static getMaxJsonEscapedSize(max: number) { + return Math.floor(max / 2); + } + + private buffer = Buffer.from(''); + private bytesRead = 0; + private chunksRead = 0; + private chunksWritten = 0; + private jobContentEncoding?: string; + private jobSize?: number; + private jobType?: string; + private maxChunkSize?: number; + private puid = new Puid(); private primaryTerm?: number; private seqNo?: number; - constructor(private client: ElasticsearchClient, private document: ContentStreamDocument) { + /** + * The number of bytes written so far. + * Does not include data that is still queued for writing. + */ + bytesWritten = 0; + + constructor( + private client: ElasticsearchClient, + private exportTypesRegistry: ExportTypesRegistry, + private logger: LevelLogger, + private document: ContentStreamDocument + ) { super(); } - async _read() { + private async getJobType() { + if (!this.jobType) { + const { id, index } = this.document; + const body: SearchRequest['body'] = { + _source: { includes: ['jobtype'] }, + query: { + constant_score: { + filter: { + bool: { + must: [{ term: { _id: id } }], + }, + }, + }, + }, + size: 1, + }; + + const response = await this.client.search({ body, index }); + const hits = response?.body.hits?.hits?.[0]; + this.jobType = hits?._source?.jobtype; + } + + return this.jobType; + } + + private async getJobContentEncoding() { + if (!this.jobContentEncoding) { + const jobType = await this.getJobType(); + + ({ jobContentEncoding: this.jobContentEncoding } = this.exportTypesRegistry.get( + ({ jobType: item }) => item === jobType + )); + } + + return this.jobContentEncoding; + } + + private async decode(content: string) { + const contentEncoding = await this.getJobContentEncoding(); + + return Buffer.from(content, contentEncoding === 'base64' ? 'base64' : undefined); + } + + private async encode(buffer: Buffer) { + const contentEncoding = await this.getJobContentEncoding(); + + return buffer.toString(contentEncoding === 'base64' ? 'base64' : undefined); + } + + private async getMaxContentSize() { + const { body } = await this.client.cluster.getSettings({ include_defaults: true }); + const { persistent, transient, defaults: defaultSettings } = body; + const settings = defaults({}, persistent, transient, defaultSettings); + const maxContentSize = get(settings, 'http.max_content_length', '100mb'); + + return ByteSizeValue.parse(maxContentSize).getValueInBytes(); + } + + private async getMaxChunkSize() { + if (!this.maxChunkSize) { + const maxContentSize = (await this.getMaxContentSize()) - REQUEST_SPAN_SIZE_IN_BYTES; + const jobContentEncoding = await this.getJobContentEncoding(); + + this.maxChunkSize = + jobContentEncoding === 'base64' + ? ContentStream.getMaxBase64EncodedSize(maxContentSize) + : ContentStream.getMaxJsonEscapedSize(maxContentSize); + + this.logger.debug(`Chunk size is ${this.maxChunkSize} bytes.`); + } + + return this.maxChunkSize; + } + + private async readHead() { const { id, index } = this.document; const body: SearchRequest['body'] = { - _source: { includes: ['output.content'] }, + _source: { includes: ['output.content', 'output.size', 'jobtype'] }, query: { constant_score: { filter: { @@ -45,55 +175,161 @@ export class ContentStream extends Duplex { size: 1, }; - try { - const response = await this.client.search({ body, index }); - const hits = response?.body.hits?.hits?.[0] as ReportDocument | undefined; - const output = hits?._source.output?.content; + this.logger.debug(`Reading report contents.`); + + const response = await this.client.search({ body, index }); + const hits = response?.body.hits?.hits?.[0]; + + this.jobType = hits?._source?.jobtype; + this.jobSize = hits?._source?.output?.size; + + return hits?._source?.output?.content; + } + + private async readChunk() { + const { id, index } = this.document; + const body: SearchRequest['body'] = { + _source: { includes: ['output.content'] }, + query: { + constant_score: { + filter: { + bool: { + must: [{ term: { parent_id: id } }, { term: { 'output.chunk': this.chunksRead } }], + }, + }, + }, + }, + size: 1, + }; + + this.logger.debug(`Reading chunk #${this.chunksRead}.`); - if (output != null) { - this.push(output); + const response = await this.client.search({ body, index }); + const hits = response?.body.hits?.hits?.[0]; + + return hits?._source?.output.content; + } + + private isRead() { + return this.jobSize != null && this.bytesRead >= this.jobSize; + } + + async _read() { + try { + const content = this.chunksRead ? await this.readChunk() : await this.readHead(); + if (!content) { + this.logger.debug(`Chunk is empty.`); + this.push(null); + return; } - this.push(null); + const buffer = await this.decode(content); + + this.push(buffer); + this.chunksRead++; + this.bytesRead += buffer.byteLength; + + if (this.isRead()) { + this.logger.debug(`Read ${this.bytesRead} of ${this.jobSize} bytes.`); + this.push(null); + } } catch (error) { this.destroy(error); } } - _write(chunk: Buffer | string, _encoding: string, callback: Callback) { - this.buffer += typeof chunk === 'string' ? chunk : chunk.toString(); - callback(); + private async removeChunks() { + const { id, index } = this.document; + + await this.client.deleteByQuery({ + index, + body: { + query: { + match: { parent_id: id }, + }, + }, + }); } - async _final(callback: Callback) { - try { - const { body } = await this.client.update({ - ...this.document, - body: { - doc: { - output: { - content: this.buffer, - }, - }, + private async writeHead(content: string) { + this.logger.debug(`Updating report contents.`); + + const { body } = await this.client.update({ + ...this.document, + body: { + doc: { + output: { content }, + }, + }, + }); + + ({ _primary_term: this.primaryTerm, _seq_no: this.seqNo } = body); + } + + private async writeChunk(content: string) { + const { id: parentId, index } = this.document; + const id = this.puid.generate(); + + this.logger.debug(`Writing chunk #${this.chunksWritten} (${id}).`); + + await this.client.index({ + id, + index, + body: { + parent_id: parentId, + output: { + content, + chunk: this.chunksWritten, }, - }); + }, + }); + } + + private async flush(size = this.buffer.byteLength) { + const chunk = this.buffer.slice(0, size); + const content = await this.encode(chunk); + + if (!this.chunksWritten) { + await this.removeChunks(); + await this.writeHead(content); + } else if (chunk.byteLength) { + await this.writeChunk(content); + } + + if (chunk.byteLength) { + this.chunksWritten++; + } + + this.bytesWritten += chunk.byteLength; + this.buffer = this.buffer.slice(size); + } + + async _write(chunk: Buffer | string, encoding: BufferEncoding, callback: Callback) { + this.buffer = Buffer.concat([ + this.buffer, + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding), + ]); + + try { + const maxChunkSize = await this.getMaxChunkSize(); + + while (this.buffer.byteLength >= maxChunkSize) { + await this.flush(maxChunkSize); + } - ({ _primary_term: this.primaryTerm, _seq_no: this.seqNo } = body); - this.buffer = ''; callback(); } catch (error) { callback(error); } } - async toString(): Promise { - let result = ''; - - for await (const chunk of this) { - result += chunk; + async _final(callback: Callback) { + try { + await this.flush(); + callback(); + } catch (error) { + callback(error); } - - return result; } getSeqNo(): number | undefined { @@ -107,6 +343,13 @@ export class ContentStream extends Duplex { export async function getContentStream(reporting: ReportingCore, document: ContentStreamDocument) { const { asInternalUser: client } = await reporting.getEsClient(); + const exportTypesRegistry = reporting.getExportTypesRegistry(); + const { logger } = reporting.getPluginSetupDeps(); - return new ContentStream(client, document); + return new ContentStream( + client, + exportTypesRegistry, + logger.clone(['content_stream', document.id]), + document + ); } diff --git a/x-pack/plugins/reporting/server/lib/puid.d.ts b/x-pack/plugins/reporting/server/lib/puid.d.ts new file mode 100644 index 0000000000000..2cf7dad67d06e --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/puid.d.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +declare module 'puid' { + declare class Puid { + generate(): string; + } + + // eslint-disable-next-line import/no-default-export + export default Puid; +} diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts index 70838079ad3a1..77c732b3336be 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts @@ -29,14 +29,14 @@ export const getScreenshots = async ( const endTrace = startTrace('get_screenshots', 'read'); const item = elementsPositionAndAttributes[i]; - const base64EncodedData = await browser.screenshot(item.position); + const data = await browser.screenshot(item.position); - if (!base64EncodedData) { - throw new Error(`Failure in getScreenshots! Base64 data is void`); + if (!data?.byteLength) { + throw new Error(`Failure in getScreenshots! Screenshot data is void`); } screenshots.push({ - base64EncodedData, + data, title: item.attributes.title, description: item.attributes.description, }); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts index d5920605a8be6..e6769739ac75a 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/index.ts @@ -44,7 +44,7 @@ export interface ElementsPositionAndAttribute { } export interface Screenshot { - base64EncodedData: string; + data: Buffer; title: string; description: string; } diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index e5caa2490153a..901abe7a7b3fb 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -30,7 +30,6 @@ import { createMockLevelLogger, createMockReportingCore, } from '../../test_helpers'; -import { ElementsPositionAndAttribute } from './'; import * as contexts from './constants'; import { getScreenshots$ } from './'; @@ -101,7 +100,21 @@ describe('Screenshot Observable Pipeline', () => { "error": undefined, "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64", + "data": Object { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, "description": "Default ", "title": "Default Mock Title", }, @@ -114,10 +127,7 @@ describe('Screenshot Observable Pipeline', () => { it('pipelines multiple urls into', async () => { // mock implementations - const mockScreenshot = jest.fn().mockImplementation((item: ElementsPositionAndAttribute) => { - return Promise.resolve(`allyourBase64 screenshots`); - }); - + const mockScreenshot = jest.fn(async () => Buffer.from('some screenshots')); const mockOpen = jest.fn(); // mocks @@ -164,7 +174,27 @@ describe('Screenshot Observable Pipeline', () => { "error": undefined, "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64 screenshots", + "data": Object { + "data": Array [ + 115, + 111, + 109, + 101, + 32, + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + 115, + ], + "type": "Buffer", + }, "description": "Default ", "title": "Default Mock Title", }, @@ -195,7 +225,27 @@ describe('Screenshot Observable Pipeline', () => { "error": undefined, "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64 screenshots", + "data": Object { + "data": Array [ + 115, + 111, + 109, + 101, + 32, + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + 115, + ], + "type": "Buffer", + }, "description": "Default ", "title": "Default Mock Title", }, @@ -264,7 +314,21 @@ describe('Screenshot Observable Pipeline', () => { "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64", + "data": Object { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, "description": undefined, "title": undefined, }, @@ -292,7 +356,21 @@ describe('Screenshot Observable Pipeline', () => { "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64", + "data": Object { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, "description": undefined, "title": undefined, }, @@ -384,7 +462,21 @@ describe('Screenshot Observable Pipeline', () => { "error": undefined, "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64", + "data": Object { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, "description": undefined, "title": undefined, }, diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index 69f432562ec98..7a7a16c7bc7ea 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -47,9 +47,11 @@ export const mapping = { kibana_name: { type: 'keyword' }, kibana_id: { type: 'keyword' }, status: { type: 'keyword' }, + parent_id: { type: 'keyword' }, output: { type: 'object', properties: { + chunk: { type: 'long' }, content_type: { type: 'keyword' }, size: { type: 'long' }, content: { type: 'object', enabled: false }, diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 0f970ead7c75c..bb0fd90f576e3 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -7,7 +7,6 @@ import { omit } from 'lodash'; import moment from 'moment'; -// @ts-ignore no module definition import Puid from 'puid'; import { JOB_STATUSES } from '../../../common/constants'; import { diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index 1bbaa406088af..890312619e4a2 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -34,6 +34,8 @@ import { } from './'; import { errorLogger } from './error_logger'; +type CompletedReportOutput = Omit; + interface ReportingExecuteTaskInstance { state: object; taskType: string; @@ -41,7 +43,7 @@ interface ReportingExecuteTaskInstance { runAt?: Date; } -function isOutput(output: any): output is TaskRunResult { +function isOutput(output: any): output is CompletedReportOutput { return output?.size != null; } @@ -204,7 +206,7 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportFailed(report, doc); } - private _formatOutput(output: TaskRunResult | Error): ReportOutput { + private _formatOutput(output: CompletedReportOutput | Error): ReportOutput { const docOutput = {} as ReportOutput; const unknownMime = null; @@ -248,7 +250,7 @@ export class ExecuteReportTask implements ReportingTask { .toPromise(); } - public async _completeJob(report: Report, output: TaskRunResult): Promise { + public async _completeJob(report: Report, output: CompletedReportOutput): Promise { let docId = `/${report._index}/_doc/${report._id}`; this.logger.debug(`Saving ${report.jobtype} to ${docId}.`); @@ -337,7 +339,11 @@ export class ExecuteReportTask implements ReportingTask { report._primary_term = stream.getPrimaryTerm(); if (output) { - report = await this._completeJob(report, output); + this.logger.debug(`Job output size: ${stream.bytesWritten} bytes.`); + report = await this._completeJob(report, { + ...output, + size: stream.bytesWritten, + }); } // untrack the report for concurrency awareness this.logger.debug(`Stopping ${jobId}.`); diff --git a/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts index 57324830af4a0..efdb91d948536 100644 --- a/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts @@ -80,14 +80,18 @@ export function registerGenerateCsvFromSavedObjectImmediate( }, }); - const { - content_type: jobOutputContentType, - size: jobOutputSize, - }: TaskRunResult = await runTaskFn(null, req.body, context, stream, req); + const { content_type: jobOutputContentType }: TaskRunResult = await runTaskFn( + null, + req.body, + context, + stream, + req + ); stream.end(); const jobOutputContent = buffer.toString(); + const jobOutputSize = buffer.byteLength; - logger.info(`Job output size: ${jobOutputSize} bytes`); + logger.info(`Job output size: ${jobOutputSize} bytes.`); // convert null to undefined so the value can be sent to h.response() if (jobOutputContent === null) { diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts deleted file mode 100644 index 9e6a7769f6351..0000000000000 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { UnwrapPromise } from '@kbn/utility-types'; -import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; -import { ElasticsearchClient } from 'kibana/server'; -import { setupServer } from 'src/core/server/test_utils'; -import supertest from 'supertest'; -import { ReportingCore } from '../..'; -import { ReportingConfigType } from '../../config'; -import { - createMockConfig, - createMockConfigSchema, - createMockLevelLogger, - createMockPluginSetup, - createMockReportingCore, -} from '../../test_helpers'; -import type { ReportingRequestHandlerContext } from '../../types'; -import { registerDiagnoseConfig } from './config'; - -type SetupServerReturn = UnwrapPromise>; - -describe('POST /diagnose/config', () => { - const reportingSymbol = Symbol('reporting'); - let server: SetupServerReturn['server']; - let httpSetup: SetupServerReturn['httpSetup']; - let core: ReportingCore; - let mockSetupDeps: any; - let config: ReportingConfigType; - let mockEsClient: DeeplyMockedKeys; - - const mockLogger = createMockLevelLogger(); - - beforeEach(async () => { - ({ server, httpSetup } = await setupServer(reportingSymbol)); - httpSetup.registerRouteHandlerContext( - reportingSymbol, - 'reporting', - () => ({ usesUiCapabilities: () => false }) - ); - - mockSetupDeps = createMockPluginSetup({ - router: httpSetup.createRouter(''), - } as unknown) as any; - - config = createMockConfigSchema({ queue: { timeout: 120000 }, csv: { maxSizeBytes: 1024 } }); - core = await createMockReportingCore(config, mockSetupDeps); - mockEsClient = (await core.getEsClient()).asInternalUser as typeof mockEsClient; - }); - - afterEach(async () => { - await server.stop(); - }); - - it('returns a 200 by default when configured properly', async () => { - mockEsClient.cluster.getSettings.mockResolvedValueOnce({ - body: { - defaults: { - http: { - max_content_length: '100mb', - }, - }, - }, - } as any); - registerDiagnoseConfig(core, mockLogger); - - await server.start(); - - await supertest(httpSetup.server.listener) - .post('/api/reporting/diagnose/config') - .expect(200) - .then(({ body }) => { - expect(body).toMatchInlineSnapshot(` - Object { - "help": Array [], - "logs": "", - "success": true, - } - `); - }); - }); - - it('returns a 200 with help text when not configured properly', async () => { - core.setConfig( - createMockConfig( - createMockConfigSchema({ queue: { timeout: 120000 }, csv: { maxSizeBytes: 10485760 } }) - ) - ); - mockEsClient.cluster.getSettings.mockResolvedValueOnce({ - body: { - defaults: { - http: { - max_content_length: '5mb', - }, - }, - }, - } as any); - registerDiagnoseConfig(core, mockLogger); - - await server.start(); - - await supertest(httpSetup.server.listener) - .post('/api/reporting/diagnose/config') - .expect(200) - .then(({ body }) => { - expect(body).toMatchInlineSnapshot(` - Object { - "help": Array [ - "xpack.reporting.csv.maxSizeBytes (10485760) is higher than ElasticSearch's http.max_content_length (5242880). Please set http.max_content_length in ElasticSearch to match, or lower your xpack.reporting.csv.maxSizeBytes in Kibana.", - ], - "logs": "xpack.reporting.csv.maxSizeBytes (10485760) is higher than ElasticSearch's http.max_content_length (5242880). Please set http.max_content_length in ElasticSearch to match, or lower your xpack.reporting.csv.maxSizeBytes in Kibana.", - "success": false, - } - `); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts deleted file mode 100644 index 93677409693f8..0000000000000 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ByteSizeValue } from '@kbn/config-schema'; -import { i18n } from '@kbn/i18n'; -import { defaults, get } from 'lodash'; -import { ReportingCore } from '../..'; -import { API_DIAGNOSE_URL } from '../../../common/constants'; -import { LevelLogger as Logger } from '../../lib'; -import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; -import { DiagnosticResponse } from './'; - -const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; -const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; - -const numberToByteSizeValue = (value: number | ByteSizeValue) => { - if (typeof value === 'number') { - return new ByteSizeValue(value); - } - - return value; -}; - -export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) => { - const setupDeps = reporting.getPluginSetupDeps(); - const { router } = setupDeps; - - router.post( - { - path: `${API_DIAGNOSE_URL}/config`, - validate: {}, - }, - authorizedUserPreRouting(reporting, async (_user, _context, _req, res) => { - const warnings = []; - const { asInternalUser: elasticsearchClient } = await reporting.getEsClient(); - const config = reporting.getConfig(); - - const { body: clusterSettings } = await elasticsearchClient.cluster.getSettings({ - include_defaults: true, - }); - const { persistent, transient, defaults: defaultSettings } = clusterSettings; - const elasticClusterSettings = defaults({}, persistent, transient, defaultSettings); - - const elasticSearchMaxContent = get( - elasticClusterSettings, - 'http.max_content_length', - '100mb' - ); - const elasticSearchMaxContentBytes = ByteSizeValue.parse(elasticSearchMaxContent); - const kibanaMaxContentBytes = numberToByteSizeValue(config.get('csv', 'maxSizeBytes')); - - if (kibanaMaxContentBytes.isGreaterThan(elasticSearchMaxContentBytes)) { - const maxContentSizeWarning = i18n.translate( - 'xpack.reporting.diagnostic.configSizeMismatch', - { - defaultMessage: - `xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) is higher than ElasticSearch's {ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes}). ` + - `Please set {ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} in Kibana.`, - values: { - kibanaMaxContentBytes: kibanaMaxContentBytes.getValueInBytes(), - elasticSearchMaxContentBytes: elasticSearchMaxContentBytes.getValueInBytes(), - KIBANA_MAX_SIZE_BYTES_PATH, - ES_MAX_SIZE_BYTES_PATH, - }, - } - ); - warnings.push(maxContentSizeWarning); - } - - if (warnings.length) { - warnings.forEach((warn) => logger.warn(warn)); - } - - const body: DiagnosticResponse = { - help: warnings, - success: !warnings.length, - logs: warnings.join('\n'), - }; - - return res.ok({ body }); - }) - ); -}; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts index ea9d810542ff2..92404b76e0741 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts @@ -6,14 +6,12 @@ */ import { registerDiagnoseBrowser } from './browser'; -import { registerDiagnoseConfig } from './config'; import { registerDiagnoseScreenshot } from './screenshot'; import { LevelLogger as Logger } from '../../lib'; import { ReportingCore } from '../../core'; export const registerDiagnosticRoutes = (reporting: ReportingCore, logger: Logger) => { registerDiagnoseBrowser(reporting, logger); - registerDiagnoseConfig(reporting, logger); registerDiagnoseScreenshot(reporting, logger); }; diff --git a/x-pack/plugins/reporting/server/routes/generation.ts b/x-pack/plugins/reporting/server/routes/generation.ts index 4082084c82fbc..adbfbda727af2 100644 --- a/x-pack/plugins/reporting/server/routes/generation.ts +++ b/x-pack/plugins/reporting/server/routes/generation.ts @@ -11,8 +11,9 @@ import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { LevelLogger as Logger } from '../lib'; import { enqueueJob } from '../lib/enqueue_job'; -import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObjectImmediate } from './csv_searchsource_immediate'; +import { registerGenerateFromJobParams } from './generate_from_jobparams'; +import { registerLegacy } from './legacy'; import { HandlerFunction } from './types'; const getDownloadBaseUrl = (reporting: ReportingCore) => { @@ -87,4 +88,5 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo registerGenerateFromJobParams(reporting, handler, handleError); registerGenerateCsvFromSavedObjectImmediate(reporting, handleError, logger); + registerLegacy(reporting, handler, handleError, logger); } diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index b666f0a2f394d..883970bd45a74 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -9,6 +9,7 @@ jest.mock('../lib/content_stream', () => ({ getContentStream: jest.fn(), })); +import { Readable } from 'stream'; import { UnwrapPromise } from '@kbn/utility-types'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { of } from 'rxjs'; @@ -98,7 +99,12 @@ describe('GET /api/reporting/jobs/download', () => { core.getExportTypesRegistry = () => exportTypesRegistry; mockEsClient = (await core.getEsClient()).asInternalUser as typeof mockEsClient; - stream = ({ toString: jest.fn(() => 'test') } as unknown) as typeof stream; + stream = new Readable({ + read() { + this.push('test'); + this.push(null); + }, + }) as typeof stream; (getContentStream as jest.MockedFunction).mockResolvedValue(stream); }); @@ -192,14 +198,14 @@ describe('GET /api/reporting/jobs/download', () => { }); it('when a job fails', async () => { - mockEsClient.search.mockResolvedValueOnce({ + mockEsClient.search.mockResolvedValue({ body: getHits({ jobtype: 'unencodedJobType', status: 'failed', + output: { content: 'job failure message' }, payload: { title: 'failing job!' }, }), } as any); - stream.toString.mockResolvedValueOnce('job failure message'); registerJobInfoRoutes(core); await server.start(); @@ -255,7 +261,7 @@ describe('GET /api/reporting/jobs/download', () => { .expect('content-disposition', 'inline; filename="report.csv"'); }); - it(`doesn't encode output-content for non-specified job-types`, async () => { + it('forwards job content stream', async () => { mockEsClient.search.mockResolvedValueOnce({ body: getCompleteHits({ jobType: 'unencodedJobType', @@ -271,24 +277,6 @@ describe('GET /api/reporting/jobs/download', () => { .then(({ text }) => expect(text).toEqual('test')); }); - it(`base64 encodes output content for configured jobTypes`, async () => { - mockEsClient.search.mockResolvedValueOnce({ - body: getCompleteHits({ - jobType: 'base64EncodedJobType', - outputContentType: 'application/pdf', - }), - } as any); - registerJobInfoRoutes(core); - - await server.start(); - await supertest(httpSetup.server.listener) - .get('/api/reporting/jobs/download/dank') - .expect(200) - .expect('Content-Type', 'application/pdf') - .expect('content-disposition', 'inline; filename="report.pdf"') - .then(({ body }) => expect(Buffer.from(body).toString('base64')).toEqual('test')); - }); - it('refuses to return unknown content-types', async () => { mockEsClient.search.mockResolvedValueOnce({ body: getCompleteHits({ diff --git a/x-pack/plugins/reporting/server/routes/legacy.ts b/x-pack/plugins/reporting/server/routes/legacy.ts new file mode 100644 index 0000000000000..79f1b7f17c2da --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/legacy.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import querystring from 'querystring'; +import { authorizedUserPreRouting } from './lib/authorized_user_pre_routing'; +import { API_BASE_URL } from '../../common/constants'; +import { HandlerErrorFunction, HandlerFunction } from './types'; +import { ReportingCore } from '../core'; +import { LevelLogger } from '../lib'; + +const BASE_GENERATE = `${API_BASE_URL}/generate`; + +export function registerLegacy( + reporting: ReportingCore, + handler: HandlerFunction, + handleError: HandlerErrorFunction, + logger: LevelLogger +) { + const { router } = reporting.getPluginSetupDeps(); + + function createLegacyPdfRoute({ path, objectType }: { path: string; objectType: string }) { + const exportTypeId = 'printablePdf'; + + router.post( + { + path, + validate: { + params: schema.object({ + savedObjectId: schema.string({ minLength: 3 }), + }), + query: schema.any(), + }, + }, + + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + const message = `The following URL is deprecated and will stop working in the next major version: ${req.url.pathname}${req.url.search}`; + logger.warn(message, ['deprecation']); + + try { + const { + title, + savedObjectId, + browserTimezone, + }: { title: string; savedObjectId: string; browserTimezone: string } = req.params as any; + const queryString = querystring.stringify(req.query as any); + + return await handler( + user, + exportTypeId, + { + title, + objectType, + savedObjectId, + browserTimezone, + queryString, + version: reporting.getKibanaVersion(), + }, + context, + req, + res + ); + } catch (err) { + throw handleError(res, err); + } + }) + ); + } + + createLegacyPdfRoute({ + path: `${BASE_GENERATE}/visualization/{savedId}`, + objectType: 'visualization', + }); + + createLegacyPdfRoute({ + path: `${BASE_GENERATE}/search/{savedId}`, + objectType: 'search', + }); + + createLegacyPdfRoute({ + path: `${BASE_GENERATE}/dashboard/{savedId}`, + objectType: 'dashboard', + }); +} diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index 869eb93bd3b42..0d7a249daa5ac 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Stream } from 'stream'; // @ts-ignore import contentDisposition from 'content-disposition'; import { CSV_JOB_TYPE, CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; @@ -12,6 +13,7 @@ import { ReportApiJSON } from '../../../common/types'; import { ReportingCore } from '../../'; import { getContentStream, statuses } from '../../lib'; import { ExportTypeDefinition } from '../../types'; +import { jobsQueryFactory } from './jobs_query'; export interface ErrorFromPayload { message: string; @@ -20,7 +22,7 @@ export interface ErrorFromPayload { // interface of the API result interface Payload { statusCode: number; - content: string | Buffer | ErrorFromPayload; + content: string | Stream | ErrorFromPayload; contentType: string | null; headers: Record; } @@ -49,23 +51,11 @@ const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeDefini export function getDocumentPayloadFactory(reporting: ReportingCore) { const exportTypesRegistry = reporting.getExportTypesRegistry(); - function encodeContent( - content: string | null, - exportType: ExportTypeDefinition - ): Buffer | string { - switch (exportType.jobContentEncoding) { - case 'base64': - return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string - default: - return content ? content : ''; // convert null to empty string - } - } - async function getCompleted( output: TaskRunResult, jobType: string, title: string, - content: string + content: Stream ): Promise { const exportType = exportTypesRegistry.get( (item: ExportTypeDefinition) => item.jobType === jobType @@ -74,12 +64,13 @@ export function getDocumentPayloadFactory(reporting: ReportingCore) { const headers = getReportingHeaders(output, exportType); return { + content, statusCode: 200, - content: encodeContent(content, exportType), contentType: output.content_type, headers: { ...headers, 'Content-Disposition': contentDisposition(filename, { type: 'inline' }), + 'Content-Length': output.size, }, }; } @@ -115,15 +106,17 @@ export function getDocumentPayloadFactory(reporting: ReportingCore) { payload: { title }, }: ReportApiJSON): Promise { if (output) { - const stream = await getContentStream(reporting, { id, index }); - const content = await stream.toString(); - if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { - return getCompleted(output, jobType, title, content); + const stream = await getContentStream(reporting, { id, index }); + + return getCompleted(output, jobType, title, stream); } if (status === statuses.JOB_STATUS_FAILED) { - return getFailure(content); + const jobsQuery = jobsQueryFactory(reporting); + const error = await jobsQuery.getError(id); + + return getFailure(error); } } diff --git a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts index 419f2e118f039..747b09ae1e748 100644 --- a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { promisify } from 'util'; import { kibanaResponseFactory } from 'kibana/server'; import { ReportingCore } from '../../'; import { ALLOWED_JOB_CONTENT_TYPES } from '../../../common/constants'; +import { getContentStream } from '../../lib'; import { ReportingUser } from '../../types'; import { getDocumentPayloadFactory } from './get_document_payload'; import { jobsQueryFactory } from './jobs_query'; @@ -85,8 +87,12 @@ export async function deleteJobResponseHandler( }); } + const docIndex = doc.index; + const stream = await getContentStream(reporting, { id: docId, index: docIndex }); + try { - const docIndex = doc.index; + /** @note Overwriting existing content with an empty buffer to remove all the chunks. */ + await promisify(stream.end.bind(stream))(); await jobsQuery.delete(docIndex, docId); return res.ok({ body: { deleted: true }, diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index 3e33124b1ed0f..e4262596694a5 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { UnwrapPromise } from '@kbn/utility-types'; import { ElasticsearchClient } from 'src/core/server'; import { ReportingCore } from '../../'; +import { statuses } from '../../lib/statuses'; import { ReportApiJSON, ReportSource } from '../../../common/types'; import { Report } from '../../lib/store'; import { ReportingUser } from '../../types'; @@ -46,6 +47,7 @@ interface JobsQueryFactory { ): Promise; count(jobTypes: string[], user: ReportingUser): Promise; get(user: ReportingUser, id: string): Promise; + getError(id: string): Promise; delete(deleteIndex: string, id: string): Promise>; } @@ -166,6 +168,36 @@ export function jobsQueryFactory(reportingCore: ReportingCore): JobsQueryFactory return report.toApiJSON(); }, + async getError(id) { + const body: SearchRequest['body'] = { + _source: { + includes: ['output.content', 'status'], + }, + query: { + constant_score: { + filter: { + bool: { + must: [{ term: { _id: id } }], + }, + }, + }, + }, + size: 1, + }; + + const response = await execQuery((elasticsearchClient) => + elasticsearchClient.search({ body, index: getIndex() }) + ); + const hits = response?.body.hits?.hits?.[0]; + const status = hits?._source?.status; + + if (status !== statuses.JOB_STATUS_FAILED) { + throw new Error(`Can not get error for ${id}`); + } + + return hits?._source?.output?.content!; + }, + async delete(deleteIndex, id) { try { const { asInternalUser: elasticsearchClient } = await reportingCore.getEsClient(); diff --git a/x-pack/plugins/reporting/server/routes/types.d.ts b/x-pack/plugins/reporting/server/routes/types.d.ts index 2e8e94eaf265a..336605e6ff9b9 100644 --- a/x-pack/plugins/reporting/server/routes/types.d.ts +++ b/x-pack/plugins/reporting/server/routes/types.d.ts @@ -8,15 +8,16 @@ import { KibanaRequest, KibanaResponseFactory } from 'src/core/server'; import type { BaseParams, + BaseParamsLegacyPDF, BasePayload, - ReportingUser, ReportingRequestHandlerContext, + ReportingUser, } from '../types'; export type HandlerFunction = ( user: ReportingUser, exportType: string, - jobParams: BaseParams, + jobParams: BaseParams | BaseParamsLegacyPDF, context: ReportingRequestHandlerContext, req: KibanaRequest, res: KibanaResponseFactory diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts index 7dd7c246e9a04..24aa068eca0b1 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts @@ -80,10 +80,7 @@ mockBrowserEvaluate.mockImplementation(() => { } throw new Error(mockCall); }); -const mockScreenshot = jest.fn(); -mockScreenshot.mockImplementation((item: ElementsPositionAndAttribute) => { - return Promise.resolve(`allyourBase64`); -}); +const mockScreenshot = jest.fn(async () => Buffer.from('screenshot')); const getCreatePage = (driver: HeadlessChromiumDriver) => jest.fn().mockImplementation(() => Rx.of({ driver, exit$: Rx.never() })); diff --git a/x-pack/plugins/rollup/kibana.json b/x-pack/plugins/rollup/kibana.json index 10541d9a4ebdd..20f284686f3b5 100644 --- a/x-pack/plugins/rollup/kibana.json +++ b/x-pack/plugins/rollup/kibana.json @@ -4,23 +4,12 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": [ - "management", - "licensing", - "features" - ], - "optionalPlugins": [ - "home", - "indexManagement", - "usageCollection", - "visTypeTimeseries" - ], + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, + "requiredPlugins": ["management", "licensing", "features"], + "optionalPlugins": ["home", "indexManagement", "usageCollection", "visTypeTimeseries"], "configPath": ["xpack", "rollup"], - "requiredBundles": [ - "kibanaUtils", - "kibanaReact", - "home", - "esUiShared", - "data" - ] + "requiredBundles": ["kibanaUtils", "kibanaReact", "home", "esUiShared", "data"] } diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json index 360ea18df9ca1..a750c4a91072a 100644 --- a/x-pack/plugins/rule_registry/kibana.json +++ b/x-pack/plugins/rule_registry/kibana.json @@ -2,15 +2,8 @@ "id": "ruleRegistry", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": [ - "xpack", - "ruleRegistry" - ], - "requiredPlugins": [ - "alerting", - "data", - "triggersActionsUi" - ], + "configPath": ["xpack", "ruleRegistry"], + "requiredPlugins": ["alerting", "data", "triggersActionsUi"], "optionalPlugins": ["security"], "server": true } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts index 7ad5926d53d08..e53e1db5391e7 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts @@ -241,59 +241,72 @@ export class ResourceInstaller { logger.debug(`Creating write target for ${primaryNamespacedAlias}`); - const { body: indicesExist } = await clusterClient.indices.exists({ - index: primaryNamespacedPattern, - allow_no_indices: false, - }); - - if (!indicesExist) { - await this.installNamespacedIndexTemplate(indexInfo, namespace); - - try { - await clusterClient.indices.create({ - index: initialIndexName, - body: { - aliases: { - [primaryNamespacedAlias]: { - is_write_index: true, - }, - }, - }, - }); - } catch (err) { - // If the index already exists and it's the write index for the alias, - // something else created it so suppress the error. If it's not the write - // index, that's bad, throw an error. - if (err?.meta?.body?.error?.type === 'resource_already_exists_exception') { - const { body: existingIndices } = await clusterClient.indices.get({ - index: initialIndexName, - }); - if ( - !existingIndices[initialIndexName]?.aliases?.[primaryNamespacedAlias]?.is_write_index - ) { - throw Error( - `Attempted to create index: ${initialIndexName} as the write index for alias: ${primaryNamespacedAlias}, but the index already exists and is not the write index for the alias` - ); - } - } else { - throw err; - } - } - } else { - // If we find indices matching the pattern, then we expect one of them to be the write index for the alias. - // Throw an error if none of them are the write index. - const { body: aliasesResponse } = await clusterClient.indices.getAlias({ + try { + // When a new namespace is created we expect getAlias to return a 404 error, + // we'll catch it below and continue on. A non-404 error is a real problem so we throw. + + // It's critical that we specify *both* the index pattern and alias in this request. The alias prevents the + // request from finding other namespaces that could match the -* part of the index pattern + // (see https://github.com/elastic/kibana/issues/107704). The index pattern prevents the request from + // finding legacy .siem-signals indices that we add the alias to for backwards compatibility reasons. Together, + // the index pattern and alias should ensure that we retrieve only the "new" backing indices for this + // particular alias. + const { body: aliases } = await clusterClient.indices.getAlias({ index: primaryNamespacedPattern, + name: primaryNamespacedAlias, }); + + // If we find backing indices for the alias here, we shouldn't be making a new concrete index - + // either one of the indices is the write index so we return early because we don't need a new write target, + // or none of them are the write index so we'll throw an error because one of the existing indices should have + // been the write target if ( - !Object.entries(aliasesResponse).some( - ([_, aliasesObject]) => aliasesObject.aliases[primaryNamespacedAlias]?.is_write_index + Object.values(aliases).some( + (aliasesObject) => aliasesObject.aliases[primaryNamespacedAlias].is_write_index ) ) { - throw Error( + return; + } else { + throw new Error( `Indices matching pattern ${primaryNamespacedPattern} exist but none are set as the write index for alias ${primaryNamespacedAlias}` ); } + } catch (err) { + // 404 is expected if the alerts-as-data index hasn't been created yet + if (err.statusCode !== 404) { + throw err; + } + } + + await this.installNamespacedIndexTemplate(indexInfo, namespace); + + try { + await clusterClient.indices.create({ + index: initialIndexName, + body: { + aliases: { + [primaryNamespacedAlias]: { + is_write_index: true, + }, + }, + }, + }); + } catch (err) { + // If the index already exists and it's the write index for the alias, + // something else created it so suppress the error. If it's not the write + // index, that's bad, throw an error. + if (err?.meta?.body?.error?.type === 'resource_already_exists_exception') { + const { body: existingIndices } = await clusterClient.indices.get({ + index: initialIndexName, + }); + if (!existingIndices[initialIndexName]?.aliases?.[primaryNamespacedAlias]?.is_write_index) { + throw Error( + `Attempted to create index: ${initialIndexName} as the write index for alias: ${primaryNamespacedAlias}, but the index already exists and is not the write index for the alias` + ); + } + } else { + throw err; + } } } diff --git a/x-pack/plugins/runtime_fields/kibana.json b/x-pack/plugins/runtime_fields/kibana.json index 65932c723c474..ef5514a01b3cf 100644 --- a/x-pack/plugins/runtime_fields/kibana.json +++ b/x-pack/plugins/runtime_fields/kibana.json @@ -3,13 +3,12 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [ - ], - "optionalPlugins": [ - ], + "owner": { + "name": "App Services", + "githubTeam": "kibana-app-services" + }, + "requiredPlugins": [], + "optionalPlugins": [], "configPath": ["xpack", "runtime_fields"], - "requiredBundles": [ - "kibanaReact", - "esUiShared" - ] + "requiredBundles": ["kibanaReact", "esUiShared"] } diff --git a/x-pack/plugins/searchprofiler/kibana.json b/x-pack/plugins/searchprofiler/kibana.json index 6c94701c0ec09..864e3880ae200 100644 --- a/x-pack/plugins/searchprofiler/kibana.json +++ b/x-pack/plugins/searchprofiler/kibana.json @@ -5,6 +5,10 @@ "configPath": ["xpack", "searchprofiler"], "server": true, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": ["devTools", "home", "licensing"], "requiredBundles": ["esUiShared"] } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d9452a537826d..a89d204bb79b3 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { AlertConsumers } from '@kbn/rule-data-utils'; import type { TransformConfigSchema } from './transforms/types'; import { ENABLE_CASE_CONNECTOR } from '../../cases/common'; import { metadataTransformPattern } from './endpoint/constants'; @@ -290,11 +291,6 @@ if (ENABLE_CASE_CONNECTOR) { export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; export const NOTIFICATION_THROTTLE_RULE = 'rule'; -/** - * Histograms for fields named in this list should be displayed with an - * "All others" bucket, to count events that don't specify a value for - * the field being counted - */ export const showAllOthersBucket: string[] = [ 'destination.ip', 'event.action', @@ -315,3 +311,5 @@ export const showAllOthersBucket: string[] = [ export const ELASTIC_NAME = 'estc'; export const TRANSFORM_STATS_URL = `/api/transform/transforms/${metadataTransformPattern}-*/_stats`; + +export const SECURITY_SOLUTION_ALERT_CONSUMERS: AlertConsumers[] = [AlertConsumers.SIEM]; diff --git a/x-pack/plugins/security_solution/common/ecs/event/index.ts b/x-pack/plugins/security_solution/common/ecs/event/index.ts index 4e38bacefd351..95b3fa90d0620 100644 --- a/x-pack/plugins/security_solution/common/ecs/event/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/event/index.ts @@ -44,3 +44,14 @@ export interface EventEcs { type?: string[]; } + +export enum EventCode { + // Malware Protection alert + MALICIOUS_FILE = 'malicious_file', + // Ransomware Protection alert + RANSOMWARE = 'ransomware', + // Memory Protection alert + MEMORY_SIGNATURE = 'memory_signature', + // Memory Protection alert + MALICIOUS_THREAD = 'malicious_thread', +} diff --git a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts index 257a6f0c30981..772c16fc9cb99 100644 --- a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts +++ b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts @@ -18,6 +18,7 @@ export const emptyMlCapabilities: MlCapabilitiesResponse = { canDeleteJob: false, canOpenJob: false, canCloseJob: false, + canResetJob: false, canForecastJob: false, canGetDatafeeds: false, canStartStopDatafeed: false, diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 6a3d812b1bf5b..cdd9b35a7fa30 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -460,6 +460,17 @@ export enum TimelineTabs { eql = 'eql', } +/** + * Used for scrolling top inside a tab. Especially when swiching tabs. + */ +export interface ScrollToTopEvent { + /** + * Timestamp of the moment when the event happened. + * The timestamp might be necessary for the scenario where the event could happen multiple times. + */ + timestamp: number; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any type EmptyObject = Record; diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index 4203b9125d155..096ac0595d76c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -16,6 +16,7 @@ import { TIMELINE_FLYOUT_WRAPPER, TIMELINE_PANEL, TIMELINE_TAB_CONTENT_EQL, + TIMELINE_TAB_CONTENT_GRAPHS_NOTES, } from '../../screens/timeline'; import { createTimelineTemplate } from '../../tasks/api_calls/timelines'; @@ -90,7 +91,9 @@ describe('Timelines', (): void => { it('can be added notes', () => { addNotesToTimeline(getTimeline().notes); - cy.get(NOTES_TEXT).should('have.text', getTimeline().notes); + cy.get(TIMELINE_TAB_CONTENT_GRAPHS_NOTES) + .find(NOTES_TEXT) + .should('have.text', getTimeline().notes); }); it('should update timeline after adding eql', () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index d487cf6d00ed3..4a61a94e4acea 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -133,15 +133,16 @@ export const goToQueryTab = () => { export const addNotesToTimeline = (notes: string) => { goToNotesTab().then(() => { - cy.get(NOTES_TEXT_AREA).type(notes); - cy.root() - .pipe(($el) => { - $el.find(ADD_NOTE_BUTTON).trigger('click'); - return $el.find(NOTES_TAB_BUTTON).find('.euiBadge'); - }) - .should('have.text', '1'); + cy.get(NOTES_TAB_BUTTON) + .find('.euiBadge__text') + .then(($el) => { + const notesCount = parseInt($el.text(), 10); + + cy.get(NOTES_TEXT_AREA).type(notes); + cy.get(ADD_NOTE_BUTTON).trigger('click'); + cy.get(`${NOTES_TAB_BUTTON} .euiBadge`).should('have.text', `${notesCount + 1}`); + }); }); - goToQueryTab(); goToNotesTab(); }; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 990756f3da701..8bb1f4d75e6bc 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -1,5 +1,9 @@ { "id": "securitySolution", + "owner": { + "name": "Security solution", + "githubTeam": "security-solution" + }, "version": "8.0.0", "extraPublicDirs": ["common"], "kibanaVersion": "kibana", @@ -30,6 +34,7 @@ "security", "spaces", "usageCollection", + "lens", "lists", "home", "telemetry", diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index fdb12170309c7..0342c995b9215 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; - import { getCaseDetailsUrl, getCaseDetailsUrlWithCommentId, @@ -20,7 +19,7 @@ import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common' import { TimelineId } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; import { useKibana } from '../../../common/lib/kibana'; -import { APP_ID } from '../../../../common/constants'; +import { APP_ID, SECURITY_SOLUTION_ALERT_CONSUMERS } from '../../../../common/constants'; import { timelineActions } from '../../../timelines/store/timeline'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; @@ -33,6 +32,7 @@ import { SpyRoute } from '../../../common/utils/route/spy_routes'; import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; import { getEndpointDetailsPath } from '../../../management/common/routing'; +import { EntityType } from '../../../../../timelines/common'; interface Props { caseId: string; @@ -53,13 +53,15 @@ export interface CaseProps extends Props { updateCase: (newCase: Case) => void; } -const TimelineDetailsPanel = () => { +const TimelineDetailsPanel = ({ alertConsumers }: { alertConsumers?: AlertConsumers[] }) => { const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections); return ( @@ -228,6 +230,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = showAlertDetails, subCaseId, timelineIntegration: { + alertConsumers: SECURITY_SOLUTION_ALERT_CONSUMERS, editor_plugins: { parsingPlugin: timelineMarkdownPlugin.parser, processingPluginRenderer: timelineMarkdownPlugin.renderer, diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx index a01f22a0942de..878a6de89747b 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx @@ -26,9 +26,6 @@ jest.mock('@elastic/eui', () => { }; }); -const allOthersDataProviderId = - 'draggable-legend-item-527adabe-8e1c-4a1f-965c-2f3d65dda9e1-event_dataset-All others'; - const legendItems: LegendItem[] = [ { color: '#1EA593', @@ -57,12 +54,6 @@ const legendItems: LegendItem[] = [ field: 'event.dataset', value: 'esensor', }, - { - color: '#F37020', - dataProviderId: allOthersDataProviderId, - field: 'event.dataset', - value: 'All others', - }, ]; describe('DraggableLegend', () => { @@ -95,14 +86,7 @@ describe('DraggableLegend', () => { it('renders the legend items', () => { legendItems.forEach((item) => expect( - wrapper - .find( - item.dataProviderId !== allOthersDataProviderId - ? `[data-test-subj="legend-item-${item.dataProviderId}"]` - : '[data-test-subj="all-others-legend-item"]' - ) - .first() - .text() + wrapper.find(`[data-test-subj="legend-item-${item.dataProviderId}"]`).first().text() ).toEqual(item.value) ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 175239fcaebe7..cc272e568bce7 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -26,97 +26,32 @@ jest.mock('@elastic/eui', () => { }); describe('DraggableLegendItem', () => { - describe('rendering a regular (non "All others") legend item', () => { - const legendItem: LegendItem = { - color: '#1EA593', - dataProviderId: - 'draggable-legend-item-3207fda7-d008-402a-86a0-8ad632081bad-event_dataset-flow', - field: 'event.dataset', - value: 'flow', - }; - - let wrapper: ReactWrapper; - - beforeEach(() => { - wrapper = mount( - - - - ); - }); - - it('renders a colored circle with the expected legend item color', () => { - expect(wrapper.find('[data-test-subj="legend-color"]').first().props().color).toEqual( - legendItem.color - ); - }); - - it('renders draggable legend item text', () => { - expect( - wrapper.find(`[data-test-subj="legend-item-${legendItem.dataProviderId}"]`).first().text() - ).toEqual(legendItem.value); - }); - - it('does NOT render a non-draggable "All others" legend item', () => { - expect(wrapper.find(`[data-test-subj="all-others-legend-item"]`).exists()).toBe(false); - }); - }); - - describe('rendering an "All others" legend item', () => { - const allOthersLegendItem: LegendItem = { - color: '#F37020', - dataProviderId: - 'draggable-legend-item-527adabe-8e1c-4a1f-965c-2f3d65dda9e1-event_dataset-All others', - field: 'event.dataset', - value: 'All others', - }; - - let wrapper: ReactWrapper; - - beforeEach(() => { - wrapper = mount( - - - - ); - }); - - it('renders a colored circle with the expected legend item color', () => { - expect(wrapper.find('[data-test-subj="legend-color"]').first().props().color).toEqual( - allOthersLegendItem.color - ); - }); - - it('does NOT render a draggable legend item', () => { - expect( - wrapper - .find(`[data-test-subj="legend-item-${allOthersLegendItem.dataProviderId}"]`) - .exists() - ).toBe(false); - }); - - it('renders NON-draggable `All others` legend item text', () => { - expect(wrapper.find(`[data-test-subj="all-others-legend-item"]`).first().text()).toEqual( - allOthersLegendItem.value - ); - }); - }); + const legendItem: LegendItem = { + color: '#1EA593', + dataProviderId: 'draggable-legend-item-3207fda7-d008-402a-86a0-8ad632081bad-event_dataset-flow', + field: 'event.dataset', + value: 'flow', + }; - it('does NOT render a colored circle when the legend item has no color', () => { - const noColorLegendItem: LegendItem = { - // no `color` attribute for this `LegendItem`! - dataProviderId: - 'draggable-legend-item-3207fda7-d008-402a-86a0-8ad632081bad-event_dataset-flow', - field: 'event.dataset', - value: 'flow', - }; + let wrapper: ReactWrapper; - const wrapper = mount( + beforeEach(() => { + wrapper = mount( - + ); + }); + + it('renders a colored circle with the expected legend item color', () => { + expect(wrapper.find('[data-test-subj="legend-color"]').first().props().color).toEqual( + legendItem.color + ); + }); - expect(wrapper.find('[data-test-subj="legend-color"]').exists()).toBe(false); + it('renders draggable legend item text', () => { + expect( + wrapper.find(`[data-test-subj="legend-item-${legendItem.dataProviderId}"]`).first().text() + ).toEqual(legendItem.value); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx index 493ce4da78eba..b4b12437f8660 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx @@ -7,17 +7,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui'; import React from 'react'; -import styled from 'styled-components'; import { DefaultDraggable } from '../draggables'; -import * as i18n from './translation'; - -// The "All others" legend item is not draggable -const AllOthers = styled.span` - padding-left: 7px; -`; - export interface LegendItem { color?: string; dataProviderId: string; @@ -41,20 +33,14 @@ const DraggableLegendItemComponent: React.FC<{ )} - {value !== i18n.ALL_OTHERS ? ( - - ) : ( - <> - {value} - - )} + diff --git a/x-pack/plugins/security_solution/public/common/components/charts/translation.ts b/x-pack/plugins/security_solution/public/common/components/charts/translation.ts index f0bf8ef0d0910..a527a85f62c71 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/translation.ts +++ b/x-pack/plugins/security_solution/public/common/components/charts/translation.ts @@ -20,7 +20,3 @@ export const DATA_NOT_AVAILABLE_TITLE = i18n.translate( defaultMessage: 'Chart Data Not Available', } ); - -export const ALL_OTHERS = i18n.translate('xpack.securitySolution.chart.allOthersGroupingLabel', { - defaultMessage: 'All others', -}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap new file mode 100644 index 0000000000000..b71121b995c08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap @@ -0,0 +1,837 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertSummaryView Memory event code renders additional summary rows 1`] = ` +.c1 { + line-height: 1.7rem; +} + +.c0 .euiTableHeaderCell, +.c0 .euiTableRowCell { + border: none; +} + +.c0 .euiTableHeaderCell .euiTableCellContent { + padding: 0; +} + +.c0 .flyoutOverviewDescription .hoverActions-active .timelines__hoverActionButton, +.c0 .flyoutOverviewDescription .hoverActions-active .securitySolution__hoverActionButton { + opacity: 1; +} + +.c0 .flyoutOverviewDescription:hover .timelines__hoverActionButton, +.c0 .flyoutOverviewDescription:hover .securitySolution__hoverActionButton { + opacity: 1; +} + +.c2 { + min-width: 138px; + padding: 0 8px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.c2:focus-within .timelines__hoverActionButton, +.c2:focus-within .securitySolution__hoverActionButton { + opacity: 1; +} + +.c2:hover .timelines__hoverActionButton, +.c2:hover .securitySolution__hoverActionButton { + opacity: 1; +} + +.c2 .timelines__hoverActionButton, +.c2 .securitySolution__hoverActionButton { + opacity: 0; +} + +.c2 .timelines__hoverActionButton:focus, +.c2 .securitySolution__hoverActionButton:focus { + opacity: 1; +} + +.c3 { + padding: 4px 0; +} + +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
+
+
+ Status +
+
+
+
+
+
+ open +
+
+
+
+

+ You are in a dialog, containing options for field signal.status. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ Timestamp +
+
+
+
+
+
+ + Nov 25, 2020 @ 15:42:39.417 + +
+
+
+
+

+ You are in a dialog, containing options for field @timestamp. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ Rule +
+
+
+
+
+
+ xxx +
+
+
+
+

+ You are in a dialog, containing options for field signal.rule.name. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ Severity +
+
+
+
+
+
+ low +
+
+
+
+

+ You are in a dialog, containing options for field signal.rule.severity. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ Risk Score +
+
+
+
+
+
+ 21 +
+
+
+
+

+ You are in a dialog, containing options for field signal.rule.risk_score. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ host.name +
+
+
+
+
+
+ windows-native +
+
+
+
+

+ You are in a dialog, containing options for field host.name. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ user.name +
+
+
+
+
+
+ administrator +
+
+
+
+

+ You are in a dialog, containing options for field user.name. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ source.ip +
+
+
+
+
+
+ + + +
+
+
+
+

+ You are in a dialog, containing options for field source.ip. Press tab to navigate options. Press escape to exit. +

+
+ Filter button +
+
+ Filter out button +
+
+ Overflow button +
+
+
+
+
+
+
+ destination.ip +
+
+
+
+
+ — +
+
+
+
+
+ Threshold Count +
+
+
+
+
+ — +
+
+
+
+
+ Threshold Terms +
+
+
+
+
+ — +
+
+
+
+
+ Threshold Cardinality +
+
+
+
+
+ — +
+
+
+
+
+ Rule name +
+
+
+
+
+ — +
+
+
+
+
+ Import Hash +
+
+
+
+
+ — +
+
+
+
+
+`; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index ed0a957fd2dd3..0de4f3fe01690 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -13,7 +13,7 @@ import { mockAlertDetailsData } from './__mocks__'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { useRuleWithFallback } from '../../../detections/containers/detection_engine/rules/use_rule_with_fallback'; -import { TestProviders } from '../../mock'; +import { TestProviders, TestProvidersComponent } from '../../mock'; import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; @@ -78,4 +78,26 @@ describe('AlertSummaryView', () => { expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(false); }); }); + test('Memory event code renders additional summary rows', () => { + const renderProps = { + ...props, + data: mockAlertDetailsData.map((item) => { + if (item.category === 'event' && item.field === 'event.code') { + return { + category: 'event', + field: 'event.code', + values: ['malicious_thread'], + originalValue: ['malicious_thread'], + }; + } + return item; + }) as TimelineEventsDetailsItem[], + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('div[data-test-subj="summary-view"]').render()).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index 8bf8fdf0691ae..1d95797cdd9ad 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -19,7 +19,9 @@ import { ALERTS_HEADERS_THRESHOLD_CARDINALITY, ALERTS_HEADERS_THRESHOLD_COUNT, ALERTS_HEADERS_THRESHOLD_TERMS, + ALERTS_HEADERS_RULE_NAME, SIGNAL_STATUS, + ALERTS_HEADERS_TARGET_IMPORT_HASH, TIMESTAMP, } from '../../../detections/components/alerts_table/translations'; import { @@ -38,6 +40,7 @@ import { getEmptyValue } from '../empty_value'; import { ActionCell } from './table/action_cell'; import { FieldValueCell } from './table/field_value_cell'; import { TimelineEventsDetailsItem } from '../../../../common'; +import { EventCode } from '../../../../common/ecs/event'; export const Indent = styled.div` padding: 0 8px; @@ -48,7 +51,15 @@ const StyledEmptyComponent = styled.div` padding: ${(props) => `${props.theme.eui.paddingSizes.xs} 0`}; `; -const fields = [ +interface EventSummaryField { + id: string; + label?: string; + linkField?: string; + fieldType?: string; + overrideField?: string; +} + +const defaultDisplayFields: EventSummaryField[] = [ { id: 'signal.status', label: SIGNAL_STATUS }, { id: '@timestamp', label: TIMESTAMP }, { @@ -68,20 +79,34 @@ const fields = [ { id: 'signal.threshold_result.cardinality', label: ALERTS_HEADERS_THRESHOLD_CARDINALITY }, ]; -const processFields = [ - ...fields, +const processCategoryFields: EventSummaryField[] = [ + ...defaultDisplayFields, { id: 'process.name' }, { id: 'process.parent.name' }, { id: 'process.args' }, ]; -const networkFields = [ - ...fields, +const networkCategoryFields: EventSummaryField[] = [ + ...defaultDisplayFields, { id: 'destination.address' }, { id: 'destination.port' }, { id: 'process.name' }, ]; +const memoryShellCodeAlertFields: EventSummaryField[] = [ + ...defaultDisplayFields, + { id: 'rule.name', label: ALERTS_HEADERS_RULE_NAME }, + { + id: 'Target.process.thread.Ext.start_address_details.memory_pe.imphash', + label: ALERTS_HEADERS_TARGET_IMPORT_HASH, + }, +]; + +const memorySignatureAlertFields: EventSummaryField[] = [ + ...defaultDisplayFields, + { id: 'rule.name', label: ALERTS_HEADERS_RULE_NAME }, +]; + const getDescription = ({ data, eventId, @@ -117,7 +142,33 @@ const getDescription = ({ ); }; -const getSummaryRows = ({ +function getEventFieldsToDisplay({ + eventCategory, + eventCode, +}: { + eventCategory: string; + eventCode?: string; +}): EventSummaryField[] { + switch (eventCode) { + // memory protection fields + case EventCode.MALICIOUS_THREAD: + return memoryShellCodeAlertFields; + case EventCode.MEMORY_SIGNATURE: + return memorySignatureAlertFields; + } + + switch (eventCategory) { + case 'network': + return networkCategoryFields; + + case 'process': + return processCategoryFields; + } + + return defaultDisplayFields; +} + +export const getSummaryRows = ({ data, browserFields, timelineId, @@ -128,19 +179,19 @@ const getSummaryRows = ({ timelineId: string; eventId: string; }) => { - const categoryField = find({ category: 'event', field: 'event.category' }, data) as - | TimelineEventsDetailsItem - | undefined; - const eventCategory = Array.isArray(categoryField?.originalValue) - ? categoryField?.originalValue[0] - : categoryField?.originalValue; - - const tableFields = - eventCategory === 'network' - ? networkFields - : eventCategory === 'process' - ? processFields - : fields; + const eventCategoryField = find({ category: 'event', field: 'event.category' }, data); + + const eventCategory = Array.isArray(eventCategoryField?.originalValue) + ? eventCategoryField?.originalValue[0] + : eventCategoryField?.originalValue; + + const eventCodeField = find({ category: 'event', field: 'event.code' }, data); + + const eventCode = Array.isArray(eventCodeField?.originalValue) + ? eventCodeField?.originalValue?.[0] + : eventCodeField?.originalValue; + + const tableFields = getEventFieldsToDisplay({ eventCategory, eventCode }); return data != null ? tableFields.reduce((acc, item) => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx index 020297a322c4f..0756fc8dad88f 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx @@ -149,6 +149,7 @@ const columns: Array> = [ name: '', }, { + className: 'flyoutOverviewDescription', field: 'description', truncateText: false, render: EnrichmentDescription, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 9fd0057220116..1b21eafc2ba2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -217,6 +217,7 @@ const StatefulEventsViewerComponent: React.FC = ({ = ({ children, lineClampHeight = LINE_CLAMP_HEIGHT }) => { - const [isOverflow, setIsOverflow] = useState(null); const [isExpanded, setIsExpanded] = useState(null); - const descriptionRef = useRef(null); + const [isOverflow, descriptionRef] = useIsOverflow(children); + const toggleReadMore = useCallback(() => { setIsExpanded((prevState) => !prevState); }, []); - useEffect(() => { - if (descriptionRef?.current?.clientHeight != null) { - if ( - (descriptionRef?.current?.scrollHeight ?? 0) > (descriptionRef?.current?.clientHeight ?? 0) - ) { - setIsOverflow(true); - } - - if ( - (descriptionRef?.current?.scrollHeight ?? 0) <= (descriptionRef?.current?.clientHeight ?? 0) - ) { - setIsOverflow(false); - } - } - }, []); - if (isExpanded) { return ( <> diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx index 12084a17e888a..c35d613203f76 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx @@ -5,8 +5,18 @@ * 2.0. */ -import React, { memo, useEffect, useState, useCallback } from 'react'; +import React, { + forwardRef, + memo, + useEffect, + useImperativeHandle, + useRef, + useState, + useCallback, + ElementRef, +} from 'react'; import { EuiMarkdownEditor } from '@elastic/eui'; +import { ContextShape } from '@elastic/eui/src/components/markdown_editor/markdown_context'; import { uiPlugins, parsingPlugins, processingPlugins } from './plugins'; @@ -17,41 +27,64 @@ interface MarkdownEditorProps { editorId?: string; dataTestSubj?: string; height?: number; + autoFocusDisabled?: boolean; } -const MarkdownEditorComponent: React.FC = ({ - onChange, - value, - ariaLabel, - editorId, - dataTestSubj, - height, -}) => { - const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); - const onParse = useCallback((err, { messages }) => { - setMarkdownErrorMessages(err ? [err] : messages); - }, []); - - useEffect( - () => document.querySelector('textarea.euiMarkdownEditorTextArea')?.focus(), - [] - ); - - return ( - - ); -}; +type EuiMarkdownEditorRef = ElementRef; + +export interface MarkdownEditorRef { + textarea: HTMLTextAreaElement | null; + replaceNode: ContextShape['replaceNode']; + toolbar: HTMLDivElement | null; +} + +const MarkdownEditorComponent = forwardRef( + ({ onChange, value, ariaLabel, editorId, dataTestSubj, height, autoFocusDisabled }, ref) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + const editorRef = useRef(null); + + useEffect(() => { + if (!autoFocusDisabled) { + editorRef.current?.textarea?.focus(); + } + }, [autoFocusDisabled]); + + // @ts-expect-error update types + useImperativeHandle(ref, () => { + if (!editorRef.current) { + return null; + } + + const editorNode = editorRef.current?.textarea?.closest('.euiMarkdownEditor'); + + return { + ...editorRef.current, + toolbar: editorNode?.querySelector('.euiMarkdownEditorToolbar'), + }; + }); + + return ( + + ); + } +); + +MarkdownEditorComponent.displayName = 'MarkdownEditorComponent'; export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx index 1c407b3b8f8c2..82e4d5d5a2600 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import React from 'react'; +import React, { forwardRef } from 'react'; import styled from 'styled-components'; import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import { MarkdownEditor } from './editor'; +import { MarkdownEditor, MarkdownEditorRef } from './editor'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -27,40 +27,41 @@ const BottomContentWrapper = styled(EuiFlexGroup)` `} `; -export const MarkdownEditorForm: React.FC = ({ - id, - field, - dataTestSubj, - idAria, - bottomRightContent, -}) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); +export const MarkdownEditorForm = React.memo( + forwardRef( + ({ id, field, dataTestSubj, idAria, bottomRightContent }, ref) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - return ( - - <> - - {bottomRightContent && ( - - {bottomRightContent} - - )} - - - ); -}; + return ( + + <> + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + + ); + } + ) +); + +MarkdownEditorForm.displayName = 'MarkdownEditorForm'; diff --git a/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.test.tsx index e9e235ff3ed9c..938601b3b6b2d 100644 --- a/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.test.tsx @@ -31,6 +31,24 @@ describe('Scroll to top', () => { Object.defineProperty(globalNode.window, 'scroll', { value: null }); Object.defineProperty(globalNode.window, 'scrollTo', { value: spyScrollTo }); mount( useScrollToTop()} />); + expect(spyScrollTo).toHaveBeenCalled(); }); + + test('should not scroll when `shouldScroll` is false', () => { + Object.defineProperty(globalNode.window, 'scroll', { value: spyScroll }); + mount( useScrollToTop(undefined, false)} />); + + expect(spyScrollTo).not.toHaveBeenCalled(); + }); + + test('should scroll the element matching the given selector', () => { + const fakeElement = { scroll: spyScroll }; + Object.defineProperty(globalNode.document, 'querySelector', { + value: () => fakeElement, + }); + mount( useScrollToTop('fake selector')} />); + + expect(spyScroll).toHaveBeenCalledWith(0, 0); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx b/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx index d9f80b7e1c3d2..79e5273b9735e 100644 --- a/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx @@ -7,14 +7,22 @@ import { useEffect } from 'react'; -export const useScrollToTop = () => { +/** + * containerSelector: The element with scrolling. It defaults to the window. + * shouldScroll: It should be used for conditional scrolling. + */ +export const useScrollToTop = (containerSelector?: string, shouldScroll = true) => { useEffect(() => { + const container = containerSelector ? document.querySelector(containerSelector) : window; + + if (!shouldScroll || !container) return; + // trying to use new API - https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo - if (window.scroll) { - window.scroll(0, 0); + if (container.scroll) { + container.scroll(0, 0); } else { // just a fallback for older browsers - window.scrollTo(0, 0); + container.scrollTo(0, 0); } }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.tsx new file mode 100644 index 0000000000000..c191b945cc31e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; + +/** + * It checks if the element that receives the returned Ref has oveflow the max height. + */ +export const useIsOverflow: ( + dependency: unknown +) => [isOveflow: boolean | null, ref: React.RefObject] = (dependency) => { + const [isOverflow, setIsOverflow] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (ref.current?.clientHeight != null) { + if ((ref?.current?.scrollHeight ?? 0) > (ref?.current?.clientHeight ?? 0)) { + setIsOverflow(true); + } + + if ((ref.current?.scrollHeight ?? 0) <= (ref?.current?.clientHeight ?? 0)) { + setIsOverflow(false); + } + } + }, [ref, dependency]); + + return [isOverflow, ref]; +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 337234bb752f5..647ce4dcd15e8 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -44,7 +44,7 @@ const MockKibanaContextProvider = createKibanaContextProviderMock(); const { storage } = createSecuritySolutionStorageMock(); /** A utility for wrapping children in the providers required to run most tests */ -const TestProvidersComponent: React.FC = ({ +export const TestProvidersComponent: React.FC = ({ children, store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage), onDragEnd = jest.fn(), diff --git a/x-pack/plugins/security_solution/public/common/mock/utils.ts b/x-pack/plugins/security_solution/public/common/mock/utils.ts index 0d9e2f4f367ec..b1851fd055b33 100644 --- a/x-pack/plugins/security_solution/public/common/mock/utils.ts +++ b/x-pack/plugins/security_solution/public/common/mock/utils.ts @@ -24,6 +24,8 @@ import { defaultHeaders } from '../../timelines/components/timeline/body/column_ interface Global extends NodeJS.Global { // eslint-disable-next-line @typescript-eslint/no-explicit-any window?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + document?: any; } export const globalNode: Global = global; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx index 2c59868d8a6fe..2c0be879966cc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx @@ -16,7 +16,6 @@ import { DefaultDraggable } from '../../../../common/components/draggables'; import type { GenericBuckets } from '../../../../../common'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; import type { AlertsCountAggregation } from './types'; -import { MISSING_IP } from '../common/helpers'; interface AlertsCountProps { loading: boolean; @@ -29,10 +28,6 @@ const Wrapper = styled.div` margin-top: -8px; `; -const StyledSpan = styled.span` - padding-left: 8px; -`; - const getAlertsCountTableColumns = ( selectedStackByOption: string, defaultNumberFormat: string @@ -43,9 +38,7 @@ const getAlertsCountTableColumns = ( name: selectedStackByOption, truncateText: true, render: function DraggableStackOptionField(value: string) { - return value === i18n.ALL_OTHERS || value === MISSING_IP ? ( - {value} - ) : ( + return ( = [] ) => { - const missing = getMissingFields(stackByField); - return { size: 0, aggs: { alertsByGroupingCount: { terms: { field: stackByField, - ...missing, order: { _count: 'desc', }, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx index 298158440224f..e5534900a3784 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/helpers.tsx @@ -10,7 +10,6 @@ import moment from 'moment'; import { isEmpty } from 'lodash/fp'; import type { HistogramData, AlertsAggregation, AlertsBucket, AlertsGroupBucket } from './types'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; -import { getMissingFields } from '../common/helpers'; import type { AlertsStackByField } from '../common/types'; const EMPTY_ALERTS_DATA: HistogramData[] = []; @@ -41,14 +40,11 @@ export const getAlertsHistogramQuery = ( bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; }> ) => { - const missing = getMissingFields(stackByField); - return { aggs: { alertsByGrouping: { terms: { field: stackByField, - ...missing, order: { _count: 'desc', }, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/helpers.ts deleted file mode 100644 index ecc7cc0197778..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/helpers.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { showAllOthersBucket } from '../../../../../common/constants'; -import type { AlertsStackByField } from './types'; -import * as i18n from './translations'; - -export const MISSING_IP = '0.0.0.0'; - -export const getMissingFields = (stackByField: AlertsStackByField) => - showAllOthersBucket.includes(stackByField) - ? { - missing: stackByField.endsWith('.ip') ? MISSING_IP : i18n.ALL_OTHERS, - } - : {}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts index ef540e088877c..d99e1d4744ae7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts @@ -13,10 +13,3 @@ export const STACK_BY_LABEL = i18n.translate( defaultMessage: 'Stack by', } ); - -export const ALL_OTHERS = i18n.translate( - 'xpack.securitySolution.detectionEngine.alerts.histogram.allOthersGroupingLabel', - { - defaultMessage: 'All others', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx index 7be51c4eaa41a..6639a7f3129c9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx @@ -26,6 +26,7 @@ const AddEndpointExceptionComponent: React.FC = ({ id="addEndpointException" onClick={onClick} disabled={disabled} + size="s" > {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx index 99eef3aefd42c..af3d15184a686 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx @@ -23,6 +23,7 @@ const AddExceptionComponent: React.FC = ({ disabled, onClick id="addException" onClick={onClick} disabled={disabled} + size="s" > {i18n.ACTION_ADD_EXCEPTION} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 9155d38ba315b..c6243b0e8d709 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -184,6 +184,7 @@ const AlertContextMenuComponent: React.FC = ({ eventId: ecsRowData?._id, indexName: ecsRowData?._index ?? '', timelineId, + refetch, closePopover, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index f06d671549357..3380ab314be02 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -8,18 +8,9 @@ import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { timelineActions } from '../../../../timelines/store/timeline'; import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types'; -import * as i18nCommon from '../../../../common/translations'; -import * as i18n from '../translations'; - -import { - useStateToaster, - displaySuccessToast, - displayErrorToast, -} from '../../../../common/components/toasters'; import { useStatusBulkActionItems } from '../../../../../../timelines/public'; interface Props { @@ -28,6 +19,7 @@ interface Props { eventId: string; timelineId: string; indexName: string; + refetch?: () => void; } export const useAlertsActions = ({ @@ -36,59 +28,16 @@ export const useAlertsActions = ({ eventId, timelineId, indexName, + refetch, }: Props) => { const dispatch = useDispatch(); - const [, dispatchToaster] = useStateToaster(); - - const { addWarning } = useAppToasts(); - - const onAlertStatusUpdateSuccess = useCallback( - (updated: number, conflicts: number, newStatus: Status) => { - closePopover(); - if (conflicts > 0) { - // Partial failure - addWarning({ - title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), - text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), - }); - } else { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); - } - displaySuccessToast(title, dispatchToaster); - } - }, - [addWarning, closePopover, dispatchToaster] - ); - - const onAlertStatusUpdateFailure = useCallback( - (newStatus: Status, error: Error) => { - let title: string; - closePopover(); - - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_FAILED_TOAST; - break; - case 'open': - title = i18n.OPENED_ALERT_FAILED_TOAST; - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; - } - displayErrorToast(title, [error.message], dispatchToaster); - }, - [closePopover, dispatchToaster] - ); + const onStatusUpdate = useCallback(() => { + closePopover(); + if (refetch) { + refetch(); + } + }, [closePopover, refetch]); const setEventsLoading = useCallback( ({ eventIds, isLoading }: SetEventsLoadingProps) => { @@ -110,8 +59,8 @@ export const useAlertsActions = ({ indexName, setEventsLoading, setEventsDeleted, - onUpdateSuccess: onAlertStatusUpdateSuccess, - onUpdateFailure: onAlertStatusUpdateFailure, + onUpdateSuccess: onStatusUpdate, + onUpdateFailure: onStatusUpdate, }); return { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index badc077244acd..ac768cf4c929d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -129,6 +129,13 @@ export const ALERTS_HEADERS_THRESHOLD_CARDINALITY = i18n.translate( } ); +export const ALERTS_HEADERS_TARGET_IMPORT_HASH = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.overviewTable.targetImportHash', + { + defaultMessage: 'Import Hash', + } +); + export const ACTION_OPEN_ALERT = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.actions.openAlertTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index 4487455c11a00..c40821b1b2949 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -159,6 +159,7 @@ export const TakeActionDropdown = React.memo( eventId: actionsData.eventId, indexName, timelineId, + refetch, closePopover: closePopoverAndFlyout, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index 64832bf7f039d..4eb91ca8ee272 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -41,6 +41,12 @@ const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` } `; +const TitleConatiner = styled(EuiFlexItem)` + overflow: hidden; + display: inline-block; + text-overflow: ellipsis; +`; + const ActiveTimelinesComponent: React.FC = ({ timelineId, timelineStatus, @@ -100,7 +106,7 @@ const ActiveTimelinesComponent: React.FC = ({ /> - {title} + {title} {!isOpen && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index ee994e2a16f46..e3a1152428d62 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -12,9 +12,10 @@ import { EuiToolTip, EuiButtonIcon, EuiText, + EuiButtonEmpty, EuiTextColor, } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { MouseEventHandler, MouseEvent, useCallback, useMemo } from 'react'; import { isEmpty, get, pick } from 'lodash/fp'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; @@ -52,7 +53,9 @@ import * as i18n from './translations'; import * as commonI18n from '../../timeline/properties/translations'; import { getTimelineStatusByIdSelector } from './selectors'; import { TimelineKPIs } from './kpis'; -import { LineClamp } from '../../../../common/components/line_clamp'; + +import { setActiveTabTimeline } from '../../../store/timeline/actions'; +import { useIsOverflow } from '../../../../common/hooks/use_is_overflow'; // to hide side borders const StyledPanel = styled(EuiPanel)` @@ -67,6 +70,10 @@ interface FlyoutHeaderPanelProps { timelineId: string; } +const ActiveTimelinesContainer = styled(EuiFlexItem)` + overflow: hidden; +`; + const FlyoutHeaderPanelComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const { indexPattern, browserFields } = useSourcererScope(SourcererScopeName.timeline); @@ -145,7 +152,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline > - + = ({ timeline isOpen={show} updated={updated} /> - + {show && ( @@ -190,6 +197,34 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline export const FlyoutHeaderPanel = React.memo(FlyoutHeaderPanelComponent); +const StyledDiv = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +`; + +const ReadMoreButton = ({ + description, + onclick, +}: { + description: string; + onclick: MouseEventHandler; +}) => { + const [isOverflow, ref] = useIsOverflow(description); + return ( + <> + {description} + {isOverflow && ( + + {i18n.READ_MORE} + + )} + + ); +}; + const StyledTimelineHeader = styled(EuiFlexGroup)` ${({ theme }) => `margin: ${theme.eui.euiSizeXS} ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS};`} flex: 0; @@ -197,6 +232,7 @@ const StyledTimelineHeader = styled(EuiFlexGroup)` const TimelineStatusInfoContainer = styled.span` ${({ theme }) => `margin-left: ${theme.eui.euiSizeS};`} + white-space: nowrap; `; const KpisContainer = styled.div` @@ -208,6 +244,14 @@ const RowFlexItem = styled(EuiFlexItem)` align-items: center; `; +const TimelineTitleContainer = styled.h3` + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-word; +`; + const TimelineNameComponent: React.FC = ({ timelineId }) => { const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const { title, timelineType } = useDeepEqualSelector((state) => @@ -224,9 +268,11 @@ const TimelineNameComponent: React.FC = ({ timelineId }) => { const content = useMemo(() => title || placeholder, [title, placeholder]); return ( - -

{content}

-
+ + + {content} + + ); }; @@ -237,15 +283,24 @@ const TimelineDescriptionComponent: React.FC = ({ timelineId const description = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).description ); + const dispatch = useDispatch(); + + const onReadMore: MouseEventHandler = useCallback( + (event: MouseEvent) => { + dispatch( + setActiveTabTimeline({ + id: timelineId, + activeTab: TimelineTabs.notes, + scrollToTop: true, + }) + ); + }, + [dispatch, timelineId] + ); + return ( - {description ? ( - - {description} - - ) : ( - commonI18n.DESCRIPTION - )} + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index 7483d0cae71c5..2f0717dea32aa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -61,6 +61,10 @@ export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kp defaultMessage: 'Users', }); +export const READ_MORE = i18n.translate('xpack.securitySolution.timeline.properties.readMore', { + defaultMessage: 'Read More', +}); + export const TIMELINE_TOGGLE_BUTTON_ARIA_LABEL = ({ isOpen, title, diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap index 69e06bc7e0d1b..32e17a19045b1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap @@ -6,6 +6,7 @@ exports[`NewNote renders correctly 1`] = ` > void; updateNewNote: UpdateInternalNewNote; -}>(({ associateNote, newNote, onCancelAddNote, updateNewNote }) => { + autoFocusDisabled?: boolean; +}>(({ associateNote, newNote, onCancelAddNote, updateNewNote, autoFocusDisabled = false }) => { const dispatch = useDispatch(); const updateNote = useCallback((note: Note) => dispatch(appActions.updateNote({ note })), [ @@ -87,7 +88,12 @@ export const AddNote = React.memo<{

{i18n.YOU_ARE_EDITING_A_NOTE}

- + {onCancelAddNote != null ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx index 761df470e6f4d..bf1a2227f6f99 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx @@ -24,7 +24,8 @@ export const NewNote = React.memo<{ noteInputHeight: number; note: string; updateNewNote: UpdateInternalNewNote; -}>(({ note, noteInputHeight, updateNewNote }) => { + autoFocusDisabled?: boolean; +}>(({ note, noteInputHeight, updateNewNote, autoFocusDisabled = false }) => { return ( ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index 0c611ca5106e8..1cca5a3999b81 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -10,11 +10,21 @@ import moment from 'moment'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import '../../../../common/mock/formatted_relative'; - +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineResult, TimelineResultNote } from '../types'; import { NotePreviews } from '.'; +jest.mock('../../../../common/hooks/use_selector'); + +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('NotePreviews', () => { let mockResults: OpenTimelineResult[]; let note1updated: number; @@ -26,6 +36,7 @@ describe('NotePreviews', () => { note1updated = moment('2019-03-24T04:12:33.000Z').valueOf(); note2updated = moment(note1updated).add(1, 'minute').valueOf(); note3updated = moment(note2updated).add(1, 'minute').valueOf(); + (useDeepEqualSelector as jest.Mock).mockReset(); }); test('it renders a note preview for each note when isModal is false', () => { @@ -48,24 +59,6 @@ describe('NotePreviews', () => { }); }); - test('it does NOT render the preview container if notes is undefined', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toBe(false); - }); - - test('it does NOT render the preview container if notes is null', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toBe(false); - }); - - test('it does NOT render the preview container if notes is empty', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toBe(false); - }); - test('it filters-out non-unique savedObjectIds', () => { const nonUniqueNotes: TimelineResultNote[] = [ { @@ -145,4 +138,26 @@ describe('NotePreviews', () => { expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); + + test('it renders timeline description as a note when showTimelineDescription is true and timelineId is defined', () => { + const timeline = mockTimelineResults[0]; + (useDeepEqualSelector as jest.Mock).mockReturnValue(timeline); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="note-preview-description"]').first().text()).toContain( + timeline.description + ); + }); + + test('it does`t render timeline description as a note when it is undefined', () => { + const timeline = mockTimelineResults[0]; + (useDeepEqualSelector as jest.Mock).mockReturnValue({ ...timeline, description: undefined }); + + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="note-preview-description"]').exists()).toBe(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 5581ea4e5c165..aff12b74fbfbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -6,7 +6,13 @@ */ import { uniqBy } from 'lodash/fp'; -import { EuiAvatar, EuiButtonIcon, EuiCommentList, EuiScreenReaderOnly } from '@elastic/eui'; +import { + EuiAvatar, + EuiButtonIcon, + EuiCommentList, + EuiScreenReaderOnly, + EuiText, +} from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -15,12 +21,13 @@ import { useDispatch } from 'react-redux'; import { TimelineResultNote } from '../types'; import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { timelineActions } from '../../../store/timeline'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { NOTE_CONTENT_CLASS_NAME } from '../../timeline/body/helpers'; import * as i18n from './translations'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { sourcererSelectors } from '../../../../common/store'; +import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; export const NotePreviewsContainer = styled.section` padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; @@ -78,10 +85,45 @@ interface NotePreviewsProps { eventIdToNoteIds?: Record; notes?: TimelineResultNote[] | null; timelineId?: string; + showTimelineDescription?: boolean; } export const NotePreviews = React.memo( - ({ eventIdToNoteIds, notes, timelineId }) => { + ({ eventIdToNoteIds, notes, timelineId, showTimelineDescription }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timeline = useDeepEqualSelector((state) => + timelineId ? getTimeline(state, timelineId) : null + ); + + const descriptionList = useMemo( + () => + showTimelineDescription && timelineId && timeline?.description + ? [ + { + username: defaultToEmptyTag(timeline.updatedBy), + event: i18n.ADDED_A_DESCRIPTION, + 'data-test-subj': 'note-preview-description', + id: 'note-preview-description', + timestamp: timeline.updated ? ( + + ) : ( + getEmptyValue() + ), + children: {timeline.description}, + timelineIcon: ( + + ), + actions: , + }, + ] + : [], + [timeline, timelineId, showTimelineDescription] + ); + const notesList = useMemo( () => uniqBy('savedObjectId', notes).map((note) => { @@ -125,11 +167,12 @@ export const NotePreviews = React.memo( [eventIdToNoteIds, notes, timelineId] ); - if (notes == null || notes.length === 0) { - return null; - } - - return ; + return ( + + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts index 0945050a34a4d..c2d01704c2d9e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts @@ -18,6 +18,13 @@ export const ADDED_A_NOTE = i18n.translate('xpack.securitySolution.timeline.adde defaultMessage: 'added a note', }); +export const ADDED_A_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.timeline.addedADescriptionLabel', + { + defaultMessage: 'added description', + } +); + export const AN_UNKNOWN_USER = i18n.translate( 'xpack.securitySolution.timeline.anUnknownUserLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx index 1826413110f1e..bdb55aaf20969 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx @@ -27,6 +27,15 @@ const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); jest.mock('../../../../common/lib/kibana'); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + useSelector: () => jest.fn(), + }; +}); + describe('#getCommonColumns', () => { let mockResults: OpenTimelineResult[]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index 65963c9609320..21262d66fdbfe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -20,7 +20,7 @@ import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { TimelineType } from '../../../../../common/types/timeline'; -const DescriptionCell = styled.span` +const LineClampTextContainer = styled.span` text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 5; @@ -79,7 +79,11 @@ export const getCommonColumns = ({ }) } > - {isUntitled(timelineResult) ? i18n.UNTITLED_TIMELINE : title} + {isUntitled(timelineResult) ? ( + i18n.UNTITLED_TIMELINE + ) : ( + {title} + )} ) : (
@@ -93,9 +97,9 @@ export const getCommonColumns = ({ field: 'description', name: i18n.DESCRIPTION, render: (description: string) => ( - + {description != null && description.trim().length > 0 ? description : getEmptyTagValue()} - + ), sortable: false, }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index aeda4ba96ff8a..8ac4a76d26c77 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -17,6 +17,7 @@ import { import React, { useState, useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { AlertConsumers } from '@kbn/rule-data-utils'; import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; @@ -33,6 +34,8 @@ import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoin import { TimelineNonEcsData } from '../../../../../common'; import { Ecs } from '../../../../../common/ecs'; import { EventDetailsFooter } from './footer'; +import { EntityType } from '../../../../../../timelines/common'; +import { SECURITY_SOLUTION_ALERT_CONSUMERS } from '../../../../../common/constants'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -49,8 +52,10 @@ const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` `; interface EventDetailsPanelProps { + alertConsumers?: AlertConsumers[]; browserFields: BrowserFields; docValueFields: DocValueFields[]; + entityType?: EntityType; expandedEvent: { eventId: string; indexName: string; @@ -65,8 +70,10 @@ interface EventDetailsPanelProps { } const EventDetailsPanelComponent: React.FC = ({ + alertConsumers = SECURITY_SOLUTION_ALERT_CONSUMERS, // Default to Security Solution so only other applications have to pass this in browserFields, docValueFields, + entityType = 'events', // Default to events so only alerts have to pass entityType in expandedEvent, handleOnEventClosed, isFlyoutView, @@ -74,7 +81,9 @@ const EventDetailsPanelComponent: React.FC = ({ timelineId, }) => { const [loading, detailsData] = useTimelineEventsDetails({ + alertConsumers, docValueFields, + entityType, indexName: expandedEvent.indexName ?? '', eventId: expandedEvent.eventId ?? '', skip: !expandedEvent.eventId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx index 3e57ec2e039f5..e264c7ec9fa04 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -8,6 +8,8 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; +import { AlertConsumers } from '@kbn/rule-data-utils'; + import { timelineActions, timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; @@ -16,10 +18,13 @@ import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { EventDetailsPanel } from './event_details'; import { HostDetailsPanel } from './host_details'; import { NetworkDetailsPanel } from './network_details'; +import { EntityType } from '../../../../../timelines/common'; interface DetailsPanelProps { + alertConsumers?: AlertConsumers[]; browserFields: BrowserFields; docValueFields: DocValueFields[]; + entityType?: EntityType; handleOnPanelClosed?: () => void; isFlyoutView?: boolean; tabType?: TimelineTabs; @@ -33,8 +38,10 @@ interface DetailsPanelProps { */ export const DetailsPanel = React.memo( ({ + alertConsumers, browserFields, docValueFields, + entityType, handleOnPanelClosed, isFlyoutView, tabType, @@ -70,8 +77,10 @@ export const DetailsPanel = React.memo( panelSize = 'm'; visiblePanel = ( { timelineId={'test'} refetch={jest.fn()} showCheckboxes={true} + setEventsLoading={jest.fn()} + setEventsDeleted={jest.fn()} /> ); @@ -104,6 +106,8 @@ describe('Actions', () => { onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={false} + setEventsLoading={jest.fn()} + setEventsDeleted={jest.fn()} /> ); @@ -136,6 +140,8 @@ describe('Actions', () => { onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={true} + setEventsLoading={jest.fn()} + setEventsDeleted={jest.fn()} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx index ecacbc51e395a..789cd5211f121 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx @@ -47,6 +47,8 @@ describe('Columns', () => { eventIdToNoteIds={{}} leadingControlColumns={[defaultControlColumn]} trailingControlColumns={[]} + setEventsLoading={jest.fn()} + setEventsDeleted={jest.fn()} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx index 11bf88977fe61..82207906a6295 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx @@ -12,6 +12,7 @@ import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-g import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; +import type { SetEventsLoading, SetEventsDeleted } from '../../../../../../../timelines/common'; import { ColumnHeaderOptions, CellValueElementProps, @@ -76,6 +77,8 @@ interface DataDrivenColumnProps { toggleShowNotes: () => void; trailingControlColumns: ControlColumnProps[]; leadingControlColumns: ControlColumnProps[]; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; } const SPACE = ' '; @@ -149,6 +152,8 @@ const TgridActionTdCell = ({ tabType, timelineId, toggleShowNotes, + setEventsLoading, + setEventsDeleted, }: ActionProps & { columnId: string; hasRowRenderers: boolean; @@ -200,6 +205,8 @@ const TgridActionTdCell = ({ showNotes={showNotes} timelineId={timelineId} toggleShowNotes={toggleShowNotes} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> )} @@ -302,6 +309,8 @@ export const DataDrivenColumns = React.memo( toggleShowNotes, trailingControlColumns, leadingControlColumns, + setEventsLoading, + setEventsDeleted, }) => { const trailingActionCells = useMemo( () => @@ -348,6 +357,8 @@ export const DataDrivenColumns = React.memo( tabType={tabType} timelineId={timelineId} toggleShowNotes={toggleShowNotes} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> ) ); @@ -378,6 +389,8 @@ export const DataDrivenColumns = React.memo( timelineId, toggleShowNotes, trailingActionCells, + setEventsLoading, + setEventsDeleted, ] ); const ColumnHeaders = useMemo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 60fd170a47532..74dbf28694390 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -89,6 +89,8 @@ describe('EventColumnView', () => { isEventPinned: false, leadingControlColumns: [defaultControlColumn], trailingControlColumns: [], + setEventsLoading: jest.fn(), + setEventsDeleted: jest.fn(), }; test('it does NOT render a notes button when isEventsViewer is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 298ce252ba925..3876b91c8bdaa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -9,6 +9,7 @@ import React, { useMemo } from 'react'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; +import type { SetEventsLoading, SetEventsDeleted } from '../../../../../../../timelines/common'; import { OnRowSelected } from '../../events'; import { EventsTrData, EventsTdGroupActions } from '../../styles'; import { DataDrivenColumns, getMappedNonEcsValue } from '../data_driven_columns'; @@ -47,6 +48,8 @@ interface Props { toggleShowNotes: () => void; leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; } export const EventColumnView = React.memo( @@ -76,6 +79,8 @@ export const EventColumnView = React.memo( toggleShowNotes, leadingControlColumns, trailingControlColumns, + setEventsLoading, + setEventsDeleted, }) => { // Each action button shall announce itself to screen readers via an `aria-label` // in the following format: @@ -139,6 +144,8 @@ export const EventColumnView = React.memo( tabType={tabType} timelineId={timelineId} toggleShowNotes={toggleShowNotes} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> )} @@ -167,6 +174,8 @@ export const EventColumnView = React.memo( tabType, timelineId, toggleShowNotes, + setEventsLoading, + setEventsDeleted, ] ); return ( @@ -201,6 +210,8 @@ export const EventColumnView = React.memo( selectedEventIds={selectedEventIds} showNotes={showNotes} toggleShowNotes={toggleShowNotes} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 302aead337ed7..bcfdf83eae90b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -18,6 +18,7 @@ import { TimelineId, TimelineTabs, } from '../../../../../../common/types/timeline'; +import type { SetEventsDeleted, SetEventsLoading } from '../../../../../../../timelines/common'; import { BrowserFields } from '../../../../../common/containers/source'; import { TimelineItem, @@ -212,6 +213,20 @@ const StatefulEventComponent: React.FC = ({ [dispatch, event, isEventPinned, timelineId] ); + const setEventsLoading = useCallback( + ({ eventIds, isLoading }) => { + dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); + }, + [dispatch, timelineId] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }) => { + dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); + }, + [dispatch, timelineId] + ); + const RowRendererContent = useMemo( () => ( @@ -276,6 +291,8 @@ const StatefulEventComponent: React.FC = ({ toggleShowNotes={onToggleShowNotes} leadingControlColumns={leadingControlColumns} trailingControlColumns={trailingControlColumns} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index e95efdf754418..e8846d88ef919 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -62,9 +62,9 @@ const StatefulTimelineComponent: React.FC = ({ const containerElement = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const { selectedPatterns } = useSourcererScope(SourcererScopeName.timeline); - const { graphEventId, savedObjectId, timelineType } = useDeepEqualSelector((state) => + const { graphEventId, savedObjectId, timelineType, description } = useDeepEqualSelector((state) => pick( - ['graphEventId', 'savedObjectId', 'timelineType'], + ['graphEventId', 'savedObjectId', 'timelineType', 'description'], getTimeline(state, timelineId) ?? timelineDefaults ) ); @@ -146,6 +146,7 @@ const StatefulTimelineComponent: React.FC = ({ setTimelineFullScreen={setTimelineFullScreen} timelineId={timelineId} timelineType={timelineType} + timelineDescription={description} timelineFullScreen={timelineFullScreen} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index 0d32e790dab50..e9b1e8046cec0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -16,6 +16,7 @@ import { EuiPanel, EuiHorizontalRule, } from '@elastic/eui'; + import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; @@ -23,7 +24,10 @@ import styled from 'styled-components'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineActions } from '../../../store/timeline'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { + useDeepEqualSelector, + useShallowEqualSelector, +} from '../../../../common/hooks/use_selector'; import { TimelineStatus, TimelineTabs } from '../../../../../common/types/timeline'; import { appSelectors } from '../../../../common/store/app'; import { AddNote } from '../../notes/add_note'; @@ -33,6 +37,8 @@ import { NotePreviews } from '../../open_timeline/note_previews'; import { TimelineResultNote } from '../../open_timeline/types'; import { getTimelineNoteSelector } from './selectors'; import { DetailsPanel } from '../../side_panel'; +import { getScrollToTopSelector } from '../tabs_content/selectors'; +import { useScrollToTop } from '../../../../common/components/scroll_to_top'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -122,6 +128,12 @@ interface NotesTabContentProps { const NotesTabContentComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); + + const getScrollToTop = useMemo(() => getScrollToTopSelector(), []); + const scrollToTop = useShallowEqualSelector((state) => getScrollToTop(state, timelineId)); + + useScrollToTop('#scrollableNotes', !!scrollToTop); + const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); const { createdBy, @@ -202,16 +214,26 @@ const NotesTabContentComponent: React.FC = ({ timelineId } return ( - +

{NOTES}

- + {!isImmutable && ( - + )}
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 8cdd7722d7fbd..cfe2af0ab7c31 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -6,6 +6,7 @@ */ import { EuiBadge, EuiLoadingContent, EuiTabs, EuiTab } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; @@ -59,6 +60,7 @@ interface BasicTimelineTab { timelineId: TimelineId; timelineType: TimelineType; graphEventId?: string; + timelineDescription: string; } const QueryTab: React.FC<{ @@ -222,6 +224,7 @@ const TabsContentComponent: React.FC = ({ timelineFullScreen, timelineType, graphEventId, + timelineDescription, }) => { const dispatch = useDispatch(); const getActiveTab = useMemo(() => getActiveTabSelector(), []); @@ -233,6 +236,7 @@ const TabsContentComponent: React.FC = ({ const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); + const numberOfPinnedEvents = useShallowEqualSelector((state) => getNumberOfPinnedEvents(state, timelineId) ); @@ -253,8 +257,10 @@ const TabsContentComponent: React.FC = ({ }, [globalTimelineNoteIds, eventIdToNoteIds]); const numberOfNotes = useMemo( - () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length, - [appNotes, allTimelineNoteIds] + () => + appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length + + (isEmpty(timelineDescription) ? 0 : 1), + [appNotes, allTimelineNoteIds, timelineDescription] ); const setQueryAsActiveTab = useCallback(() => { @@ -362,6 +368,7 @@ const TabsContentComponent: React.FC = ({ rowRenderers={rowRenderers} timelineId={timelineId} timelineType={timelineType} + timelineDescription={timelineDescription} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts index ccb07135747f5..04045e94aee25 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts @@ -27,3 +27,6 @@ export const getEventIdToNoteIdsSelector = () => export const getNotesSelector = () => createSelector(selectNotesById, (notesById) => Object.values(notesById)); + +export const getScrollToTopSelector = () => + createSelector(selectTimeline, (timeline) => timeline?.scrollToTop); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 86624ba161a83..8f9d34dbf89e5 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -9,6 +9,7 @@ import { isEmpty, noop } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import { Subscription } from 'rxjs'; +import { AlertConsumers } from '@kbn/rule-data-utils'; import { inputsModel } from '../../../common/store'; import { useKibana } from '../../../common/lib/kibana'; @@ -22,19 +23,26 @@ import { import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/public'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import * as i18n from './translations'; +import { EntityType } from '../../../../../timelines/common'; export interface EventsArgs { detailsData: TimelineEventsDetailsItem[] | null; } export interface UseTimelineEventsDetailsProps { + alertConsumers?: AlertConsumers[]; + entityType?: EntityType; docValueFields: DocValueFields[]; indexName: string; eventId: string; skip: boolean; } +const EMPTY_ARRAY: AlertConsumers[] = []; + export const useTimelineEventsDetails = ({ + alertConsumers = EMPTY_ARRAY, + entityType = EntityType.EVENTS, docValueFields, indexName, eventId, @@ -104,7 +112,9 @@ export const useTimelineEventsDetails = ({ setTimelineDetailsRequest((prevRequest) => { const myRequest = { ...(prevRequest ?? {}), + alertConsumers, docValueFields, + entityType, indexName, eventId, factoryQueryType: TimelineEventsQueries.details, @@ -114,7 +124,7 @@ export const useTimelineEventsDetails = ({ } return prevRequest; }); - }, [docValueFields, eventId, indexName]); + }, [alertConsumers, docValueFields, entityType, eventId, indexName]); useEffect(() => { timelineDetailsSearch(timelineDetailsRequest); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index d5f692cc9dc17..d0d5fdacad312 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -17,7 +17,7 @@ import { import { KqlMode, TimelineModel } from './model'; import { InsertTimeline } from './types'; import { FieldsEqlOptions } from '../../../../common/search_strategy/timeline'; -import { +import type { TimelineEventsType, RowRendererId, TimelineTabs, @@ -204,6 +204,7 @@ export const updateIndexNames = actionCreator<{ export const setActiveTabTimeline = actionCreator<{ id: string; activeTab: TimelineTabs; + scrollToTop?: boolean; }>('SET_ACTIVE_TAB_TIMELINE'); export const toggleModalSaveTimeline = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index ef47b474350c7..3c2449a2e787d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -11,6 +11,7 @@ import type { TimelineType, TimelineStatus, TimelineTabs, + ScrollToTopEvent, } from '../../../../common/types/timeline'; import { PinnedEvent } from '../../../../common/types/timeline/pinned_event'; import type { TGridModelForTimeline } from '../../../../../timelines/public'; @@ -23,6 +24,9 @@ export type TimelineModel = TGridModelForTimeline & { /** The selected tab to displayed in the timeline */ activeTab: TimelineTabs; prevActiveTab: TimelineTabs; + + /** Used for scrolling to top when swiching tabs. It includes the timestamp of when the event happened */ + scrollToTop?: ScrollToTopEvent; /** Timeline saved object owner */ createdBy?: string; /** A summary of the events and notes in this timeline */ @@ -63,6 +67,8 @@ export type TimelineModel = TGridModelForTimeline & { status: TimelineStatus; /** updated saved object timestamp */ updated?: number; + /** updated saved object user */ + updatedBy?: string | null; /** timeline is saving */ isSaving: boolean; version: string | null; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index a302f43e61b13..97fa72667a3c6 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -331,7 +331,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) - .case(setActiveTabTimeline, (state, { id, activeTab }) => ({ + .case(setActiveTabTimeline, (state, { id, activeTab, scrollToTop }) => ({ ...state, timelineById: { ...state.timelineById, @@ -339,6 +339,11 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state.timelineById[id], activeTab, prevActiveTab: state.timelineById[id].activeTab, + scrollToTop: scrollToTop + ? { + timestamp: Math.floor(Date.now() / 1000), // convert to seconds to avoid unnecessary rerenders for multiple clicks + } + : undefined, }, }, })) diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 326a6973db53b..968211a0c82df 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -9,6 +9,7 @@ import { CoreStart } from '../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { LensPublicStart } from '../../../plugins/lens/public'; import { NewsfeedPublicPluginStart } from '../../../../src/plugins/newsfeed/public'; import { Start as InspectorStart } from '../../../../src/plugins/inspector/public'; import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; @@ -58,6 +59,7 @@ export interface StartPlugins { embeddable: EmbeddableStart; inspector: InspectorStart; fleet?: FleetStart; + lens: LensPublicStart; lists?: ListsPluginStart; licensing: LicensingPluginStart; newsfeed?: NewsfeedPublicPluginStart; diff --git a/x-pack/plugins/security_solution/scripts/beat_docs/build.js b/x-pack/plugins/security_solution/scripts/beat_docs/build.js index b8bcedda9356a..554581e26d30f 100644 --- a/x-pack/plugins/security_solution/scripts/beat_docs/build.js +++ b/x-pack/plugins/security_solution/scripts/beat_docs/build.js @@ -26,7 +26,7 @@ const zlib = require('zlib'); const OUTPUT_DIRECTORY = resolve('scripts', 'beat_docs'); const OUTPUT_SERVER_DIRECTORY = resolve('server', 'utils', 'beat_schema'); -const BEATS_VERSION = '7.12.0'; +const BEATS_VERSION = '7.14.0'; const beats = [ { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index a266cff73344f..6031ca9a6f5ae 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -99,7 +99,6 @@ export function handleEntities(): RequestHandler=7.15) + Events: allowlistBaseEventFields, rule: { id: true, name: true, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/cti/event_enrichment/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/cti/event_enrichment/helpers.ts index f24bfc08b39e0..22dba31701e17 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/cti/event_enrichment/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/cti/event_enrichment/helpers.ts @@ -27,6 +27,7 @@ export const buildIndicatorShouldClauses = ( if (!isEmpty(eventFieldValue)) { shoulds.push({ + // @ts-expect-error unknown is not assignable to query match: { [EVENT_ENRICHMENT_INDICATOR_FIELD_MAP[eventField]]: { query: eventFieldValue, diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/fields.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/fields.ts index e308c8866c9d3..b2f01d9ddb366 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/fields.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/fields.ts @@ -161,6 +161,13 @@ export const fieldsBeat: BeatFields = { name: 'client.geo.city_name', type: 'keyword', }, + 'client.geo.continent_code': { + category: 'client', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'client.geo.continent_code', + type: 'keyword', + }, 'client.geo.continent_name': { category: 'client', description: 'Name of the continent.', @@ -197,6 +204,14 @@ export const fieldsBeat: BeatFields = { name: 'client.geo.name', type: 'keyword', }, + 'client.geo.postal_code': { + category: 'client', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'client.geo.postal_code', + type: 'keyword', + }, 'client.geo.region_iso_code': { category: 'client', description: 'Region ISO code.', @@ -211,6 +226,13 @@ export const fieldsBeat: BeatFields = { name: 'client.geo.region_name', type: 'keyword', }, + 'client.geo.timezone': { + category: 'client', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'client.geo.timezone', + type: 'keyword', + }, 'client.ip': { category: 'client', description: 'IP address of the client (IPv4 or IPv6).', @@ -219,7 +241,9 @@ export const fieldsBeat: BeatFields = { }, 'client.mac': { category: 'client', - description: 'MAC address of the client.', + description: + 'MAC address of the client. The notation format from RFC 7042 is suggested: Each octet (that is, 8-bit byte) is represented by two [uppercase] hexadecimal digits giving the value of the octet as an unsigned integer. Successive octets are separated by a hyphen.', + example: '00-00-5E-00-53-23', name: 'client.mac', type: 'keyword', }, @@ -414,6 +438,14 @@ export const fieldsBeat: BeatFields = { name: 'cloud.region', type: 'keyword', }, + 'cloud.service.name': { + category: 'cloud', + description: + 'The cloud service name is intended to distinguish services running on different platforms within a provider, eg AWS EC2 vs Lambda, GCP GCE vs App Engine, Azure VM vs App Server. Examples: app engine, app service, cloud run, fargate, lambda.', + example: 'lambda', + name: 'cloud.service.name', + type: 'keyword', + }, 'code_signature.exists': { category: 'code_signature', description: 'Boolean to capture if a signature is present.', @@ -421,6 +453,14 @@ export const fieldsBeat: BeatFields = { name: 'code_signature.exists', type: 'boolean', }, + 'code_signature.signing_id': { + category: 'code_signature', + description: + 'The identifier used to sign the process. This is used to identify the application manufactured by a software vendor. The field is relevant to Apple *OS only.', + example: 'com.apple.xpc.proxy', + name: 'code_signature.signing_id', + type: 'keyword', + }, 'code_signature.status': { category: 'code_signature', description: @@ -436,6 +476,14 @@ export const fieldsBeat: BeatFields = { name: 'code_signature.subject_name', type: 'keyword', }, + 'code_signature.team_id': { + category: 'code_signature', + description: + 'The team identifier used to sign the process. This is used to identify the team or vendor of a software product. The field is relevant to Apple *OS only.', + example: 'EQHXZ8M8AV', + name: 'code_signature.team_id', + type: 'keyword', + }, 'code_signature.trusted': { category: 'code_signature', description: @@ -489,6 +537,30 @@ export const fieldsBeat: BeatFields = { name: 'container.runtime', type: 'keyword', }, + 'data_stream.dataset': { + category: 'data_stream', + description: + 'The field can contain anything that makes sense to signify the source of the data. Examples include `nginx.access`, `prometheus`, `endpoint` etc. For data streams that otherwise fit, but that do not have dataset set we use the value "generic" for the dataset value. `event.dataset` should have the same value as `data_stream.dataset`. Beyond the Elasticsearch data stream naming criteria noted above, the `dataset` value has additional restrictions: * Must not contain `-` * No longer than 100 characters', + example: 'nginx.access', + name: 'data_stream.dataset', + type: 'constant_keyword', + }, + 'data_stream.namespace': { + category: 'data_stream', + description: + 'A user defined namespace. Namespaces are useful to allow grouping of data. Many users already organize their indices this way, and the data stream naming scheme now provides this best practice as a default. Many users will populate this field with `default`. If no value is used, it falls back to `default`. Beyond the Elasticsearch index naming criteria noted above, `namespace` value has the additional restrictions: * Must not contain `-` * No longer than 100 characters', + example: 'production', + name: 'data_stream.namespace', + type: 'constant_keyword', + }, + 'data_stream.type': { + category: 'data_stream', + description: + 'An overarching type for the data stream. Currently allowed values are "logs" and "metrics". We expect to also add "traces" and "synthetics" in the near future.', + example: 'logs', + name: 'data_stream.type', + type: 'constant_keyword', + }, 'destination.address': { category: 'destination', description: @@ -532,6 +604,13 @@ export const fieldsBeat: BeatFields = { name: 'destination.geo.city_name', type: 'keyword', }, + 'destination.geo.continent_code': { + category: 'destination', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'destination.geo.continent_code', + type: 'keyword', + }, 'destination.geo.continent_name': { category: 'destination', description: 'Name of the continent.', @@ -568,6 +647,14 @@ export const fieldsBeat: BeatFields = { name: 'destination.geo.name', type: 'keyword', }, + 'destination.geo.postal_code': { + category: 'destination', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'destination.geo.postal_code', + type: 'keyword', + }, 'destination.geo.region_iso_code': { category: 'destination', description: 'Region ISO code.', @@ -582,6 +669,13 @@ export const fieldsBeat: BeatFields = { name: 'destination.geo.region_name', type: 'keyword', }, + 'destination.geo.timezone': { + category: 'destination', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'destination.geo.timezone', + type: 'keyword', + }, 'destination.ip': { category: 'destination', description: 'IP address of the destination (IPv4 or IPv6).', @@ -590,7 +684,9 @@ export const fieldsBeat: BeatFields = { }, 'destination.mac': { category: 'destination', - description: 'MAC address of the destination.', + description: + 'MAC address of the destination. The notation format from RFC 7042 is suggested: Each octet (that is, 8-bit byte) is represented by two [uppercase] hexadecimal digits giving the value of the octet as an unsigned integer. Successive octets are separated by a hyphen.', + example: '00-00-5E-00-53-23', name: 'destination.mac', type: 'keyword', }, @@ -720,6 +816,14 @@ export const fieldsBeat: BeatFields = { name: 'dll.code_signature.exists', type: 'boolean', }, + 'dll.code_signature.signing_id': { + category: 'dll', + description: + 'The identifier used to sign the process. This is used to identify the application manufactured by a software vendor. The field is relevant to Apple *OS only.', + example: 'com.apple.xpc.proxy', + name: 'dll.code_signature.signing_id', + type: 'keyword', + }, 'dll.code_signature.status': { category: 'dll', description: @@ -735,6 +839,14 @@ export const fieldsBeat: BeatFields = { name: 'dll.code_signature.subject_name', type: 'keyword', }, + 'dll.code_signature.team_id': { + category: 'dll', + description: + 'The team identifier used to sign the process. This is used to identify the team or vendor of a software product. The field is relevant to Apple *OS only.', + example: 'EQHXZ8M8AV', + name: 'dll.code_signature.team_id', + type: 'keyword', + }, 'dll.code_signature.trusted': { category: 'dll', description: @@ -775,6 +887,12 @@ export const fieldsBeat: BeatFields = { name: 'dll.hash.sha512', type: 'keyword', }, + 'dll.hash.ssdeep': { + category: 'dll', + description: 'SSDEEP hash.', + name: 'dll.hash.ssdeep', + type: 'keyword', + }, 'dll.name': { category: 'dll', description: 'Name of the library. This generally maps to the name of the file on disk.', @@ -1233,6 +1351,14 @@ export const fieldsBeat: BeatFields = { name: 'file.code_signature.exists', type: 'boolean', }, + 'file.code_signature.signing_id': { + category: 'file', + description: + 'The identifier used to sign the process. This is used to identify the application manufactured by a software vendor. The field is relevant to Apple *OS only.', + example: 'com.apple.xpc.proxy', + name: 'file.code_signature.signing_id', + type: 'keyword', + }, 'file.code_signature.status': { category: 'file', description: @@ -1248,6 +1374,14 @@ export const fieldsBeat: BeatFields = { name: 'file.code_signature.subject_name', type: 'keyword', }, + 'file.code_signature.team_id': { + category: 'file', + description: + 'The team identifier used to sign the process. This is used to identify the team or vendor of a software product. The field is relevant to Apple *OS only.', + example: 'EQHXZ8M8AV', + name: 'file.code_signature.team_id', + type: 'keyword', + }, 'file.code_signature.trusted': { category: 'file', description: @@ -1346,6 +1480,12 @@ export const fieldsBeat: BeatFields = { name: 'file.hash.sha512', type: 'keyword', }, + 'file.hash.ssdeep': { + category: 'file', + description: 'SSDEEP hash.', + name: 'file.hash.ssdeep', + type: 'keyword', + }, 'file.inode': { category: 'file', description: 'Inode representing the file in the filesystem.', @@ -1650,6 +1790,13 @@ export const fieldsBeat: BeatFields = { name: 'geo.city_name', type: 'keyword', }, + 'geo.continent_code': { + category: 'geo', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'geo.continent_code', + type: 'keyword', + }, 'geo.continent_name': { category: 'geo', description: 'Name of the continent.', @@ -1686,6 +1833,14 @@ export const fieldsBeat: BeatFields = { name: 'geo.name', type: 'keyword', }, + 'geo.postal_code': { + category: 'geo', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'geo.postal_code', + type: 'keyword', + }, 'geo.region_iso_code': { category: 'geo', description: 'Region ISO code.', @@ -1700,6 +1855,13 @@ export const fieldsBeat: BeatFields = { name: 'geo.region_name', type: 'keyword', }, + 'geo.timezone': { + category: 'geo', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'geo.timezone', + type: 'keyword', + }, 'group.domain': { category: 'group', description: @@ -1743,6 +1905,12 @@ export const fieldsBeat: BeatFields = { name: 'hash.sha512', type: 'keyword', }, + 'hash.ssdeep': { + category: 'hash', + description: 'SSDEEP hash.', + name: 'hash.ssdeep', + type: 'keyword', + }, 'host.architecture': { category: 'host', description: 'Operating system architecture.', @@ -1750,6 +1918,27 @@ export const fieldsBeat: BeatFields = { name: 'host.architecture', type: 'keyword', }, + 'host.cpu.usage': { + category: 'host', + description: + 'Percent CPU used which is normalized by the number of CPU cores and it ranges from 0 to 1. Scaling factor: 1000. For example: For a two core host, this value should be the average of the two cores, between 0 and 1.', + name: 'host.cpu.usage', + type: 'scaled_float', + }, + 'host.disk.read.bytes': { + category: 'host', + description: + 'The total number of bytes (gauge) read successfully (aggregated from all disks) since the last metric collection.', + name: 'host.disk.read.bytes', + type: 'long', + }, + 'host.disk.write.bytes': { + category: 'host', + description: + 'The total number of bytes (gauge) written successfully (aggregated from all disks) since the last metric collection.', + name: 'host.disk.write.bytes', + type: 'long', + }, 'host.domain': { category: 'host', description: @@ -1765,6 +1954,13 @@ export const fieldsBeat: BeatFields = { name: 'host.geo.city_name', type: 'keyword', }, + 'host.geo.continent_code': { + category: 'host', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'host.geo.continent_code', + type: 'keyword', + }, 'host.geo.continent_name': { category: 'host', description: 'Name of the continent.', @@ -1801,6 +1997,14 @@ export const fieldsBeat: BeatFields = { name: 'host.geo.name', type: 'keyword', }, + 'host.geo.postal_code': { + category: 'host', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'host.geo.postal_code', + type: 'keyword', + }, 'host.geo.region_iso_code': { category: 'host', description: 'Region ISO code.', @@ -1815,6 +2019,13 @@ export const fieldsBeat: BeatFields = { name: 'host.geo.region_name', type: 'keyword', }, + 'host.geo.timezone': { + category: 'host', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'host.geo.timezone', + type: 'keyword', + }, 'host.hostname': { category: 'host', description: @@ -1837,7 +2048,9 @@ export const fieldsBeat: BeatFields = { }, 'host.mac': { category: 'host', - description: 'Host mac addresses.', + description: + 'Host MAC addresses. The notation format from RFC 7042 is suggested: Each octet (that is, 8-bit byte) is represented by two [uppercase] hexadecimal digits giving the value of the octet as an unsigned integer. Successive octets are separated by a hyphen.', + example: '["00-00-5E-00-53-23", "00-00-5E-00-53-24"]', name: 'host.mac', type: 'keyword', }, @@ -1848,6 +2061,34 @@ export const fieldsBeat: BeatFields = { name: 'host.name', type: 'keyword', }, + 'host.network.egress.bytes': { + category: 'host', + description: + 'The number of bytes (gauge) sent out on all network interfaces by the host since the last metric collection.', + name: 'host.network.egress.bytes', + type: 'long', + }, + 'host.network.egress.packets': { + category: 'host', + description: + 'The number of packets (gauge) sent out on all network interfaces by the host since the last metric collection.', + name: 'host.network.egress.packets', + type: 'long', + }, + 'host.network.ingress.bytes': { + category: 'host', + description: + 'The number of bytes received (gauge) on all network interfaces by the host since the last metric collection.', + name: 'host.network.ingress.bytes', + type: 'long', + }, + 'host.network.ingress.packets': { + category: 'host', + description: + 'The number of packets (gauge) received on all network interfaces by the host since the last metric collection.', + name: 'host.network.ingress.packets', + type: 'long', + }, 'host.os.family': { category: 'host', description: 'OS family (such as redhat, debian, freebsd, windows).', @@ -2001,6 +2242,14 @@ export const fieldsBeat: BeatFields = { type: 'long', format: 'bytes', }, + 'http.request.id': { + category: 'http', + description: + 'A unique identifier for each HTTP request to correlate logs between clients and servers in transactions. The id may be contained in a non-standard HTTP header, such as `X-Request-ID` or `X-Correlation-ID`.', + example: '123e4567-e89b-12d3-a456-426614174000', + name: 'http.request.id', + type: 'keyword', + }, 'http.request.method': { category: 'http', description: @@ -2320,7 +2569,7 @@ export const fieldsBeat: BeatFields = { 'observer.egress': { category: 'observer', description: - 'Observer.egress holds information like interface number and name, vlan, and zone information to classify egress traffic. Single armed monitoring such as a network sensor on a span port should only use observer.ingress to categorize traffic.', + 'Observer.egress holds information like interface number and name, vlan, and zone information to classify egress traffic. Single armed monitoring such as a network sensor on a span port should only use observer.ingress to categorize traffic.', name: 'observer.egress', type: 'object', }, @@ -2363,7 +2612,7 @@ export const fieldsBeat: BeatFields = { 'observer.egress.zone': { category: 'observer', description: - 'Network zone of outbound traffic as reported by the observer to categorize the destination area of egress traffic, e.g. Internal, External, DMZ, HR, Legal, etc.', + 'Network zone of outbound traffic as reported by the observer to categorize the destination area of egress traffic, e.g. Internal, External, DMZ, HR, Legal, etc.', example: 'Public_Internet', name: 'observer.egress.zone', type: 'keyword', @@ -2375,6 +2624,13 @@ export const fieldsBeat: BeatFields = { name: 'observer.geo.city_name', type: 'keyword', }, + 'observer.geo.continent_code': { + category: 'observer', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'observer.geo.continent_code', + type: 'keyword', + }, 'observer.geo.continent_name': { category: 'observer', description: 'Name of the continent.', @@ -2411,6 +2667,14 @@ export const fieldsBeat: BeatFields = { name: 'observer.geo.name', type: 'keyword', }, + 'observer.geo.postal_code': { + category: 'observer', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'observer.geo.postal_code', + type: 'keyword', + }, 'observer.geo.region_iso_code': { category: 'observer', description: 'Region ISO code.', @@ -2425,6 +2689,13 @@ export const fieldsBeat: BeatFields = { name: 'observer.geo.region_name', type: 'keyword', }, + 'observer.geo.timezone': { + category: 'observer', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'observer.geo.timezone', + type: 'keyword', + }, 'observer.hostname': { category: 'observer', description: 'Hostname of the observer.', @@ -2434,7 +2705,7 @@ export const fieldsBeat: BeatFields = { 'observer.ingress': { category: 'observer', description: - 'Observer.ingress holds information like interface number and name, vlan, and zone information to classify ingress traffic. Single armed monitoring such as a network sensor on a span port should only use observer.ingress to categorize traffic.', + 'Observer.ingress holds information like interface number and name, vlan, and zone information to classify ingress traffic. Single armed monitoring such as a network sensor on a span port should only use observer.ingress to categorize traffic.', name: 'observer.ingress', type: 'object', }, @@ -2477,7 +2748,7 @@ export const fieldsBeat: BeatFields = { 'observer.ingress.zone': { category: 'observer', description: - 'Network zone of incoming traffic as reported by the observer to categorize the source area of ingress traffic. e.g. internal, External, DMZ, HR, Legal, etc.', + 'Network zone of incoming traffic as reported by the observer to categorize the source area of ingress traffic. e.g. internal, External, DMZ, HR, Legal, etc.', example: 'DMZ', name: 'observer.ingress.zone', type: 'keyword', @@ -2490,7 +2761,9 @@ export const fieldsBeat: BeatFields = { }, 'observer.mac': { category: 'observer', - description: 'MAC addresses of the observer', + description: + 'MAC addresses of the observer. The notation format from RFC 7042 is suggested: Each octet (that is, 8-bit byte) is represented by two [uppercase] hexadecimal digits giving the value of the octet as an unsigned integer. Successive octets are separated by a hyphen.', + example: '["00-00-5E-00-53-23", "00-00-5E-00-53-24"]', name: 'observer.mac', type: 'keyword', }, @@ -2586,6 +2859,66 @@ export const fieldsBeat: BeatFields = { name: 'observer.version', type: 'keyword', }, + 'orchestrator.api_version': { + category: 'orchestrator', + description: 'API version being used to carry out the action', + example: 'v1beta1', + name: 'orchestrator.api_version', + type: 'keyword', + }, + 'orchestrator.cluster.name': { + category: 'orchestrator', + description: 'Name of the cluster.', + name: 'orchestrator.cluster.name', + type: 'keyword', + }, + 'orchestrator.cluster.url': { + category: 'orchestrator', + description: 'URL of the API used to manage the cluster.', + name: 'orchestrator.cluster.url', + type: 'keyword', + }, + 'orchestrator.cluster.version': { + category: 'orchestrator', + description: 'The version of the cluster.', + name: 'orchestrator.cluster.version', + type: 'keyword', + }, + 'orchestrator.namespace': { + category: 'orchestrator', + description: 'Namespace in which the action is taking place.', + example: 'kube-system', + name: 'orchestrator.namespace', + type: 'keyword', + }, + 'orchestrator.organization': { + category: 'orchestrator', + description: 'Organization affected by the event (for multi-tenant orchestrator setups).', + example: 'elastic', + name: 'orchestrator.organization', + type: 'keyword', + }, + 'orchestrator.resource.name': { + category: 'orchestrator', + description: 'Name of the resource being acted upon.', + example: 'test-pod-cdcws', + name: 'orchestrator.resource.name', + type: 'keyword', + }, + 'orchestrator.resource.type': { + category: 'orchestrator', + description: 'Type of resource being acted upon.', + example: 'service', + name: 'orchestrator.resource.type', + type: 'keyword', + }, + 'orchestrator.type': { + category: 'orchestrator', + description: 'Orchestrator cluster type (e.g. kubernetes, nomad or cloudfoundry).', + example: 'kubernetes', + name: 'orchestrator.type', + type: 'keyword', + }, 'organization.id': { category: 'organization', description: 'Unique identifier for the organization.', @@ -2815,6 +3148,14 @@ export const fieldsBeat: BeatFields = { name: 'process.code_signature.exists', type: 'boolean', }, + 'process.code_signature.signing_id': { + category: 'process', + description: + 'The identifier used to sign the process. This is used to identify the application manufactured by a software vendor. The field is relevant to Apple *OS only.', + example: 'com.apple.xpc.proxy', + name: 'process.code_signature.signing_id', + type: 'keyword', + }, 'process.code_signature.status': { category: 'process', description: @@ -2830,6 +3171,14 @@ export const fieldsBeat: BeatFields = { name: 'process.code_signature.subject_name', type: 'keyword', }, + 'process.code_signature.team_id': { + category: 'process', + description: + 'The team identifier used to sign the process. This is used to identify the team or vendor of a software product. The field is relevant to Apple *OS only.', + example: 'EQHXZ8M8AV', + name: 'process.code_signature.team_id', + type: 'keyword', + }, 'process.code_signature.trusted': { category: 'process', description: @@ -2901,6 +3250,12 @@ export const fieldsBeat: BeatFields = { name: 'process.hash.sha512', type: 'keyword', }, + 'process.hash.ssdeep': { + category: 'process', + description: 'SSDEEP hash.', + name: 'process.hash.ssdeep', + type: 'keyword', + }, 'process.name': { category: 'process', description: 'Process name. Sometimes called program name or similar.', @@ -2931,6 +3286,14 @@ export const fieldsBeat: BeatFields = { name: 'process.parent.code_signature.exists', type: 'boolean', }, + 'process.parent.code_signature.signing_id': { + category: 'process', + description: + 'The identifier used to sign the process. This is used to identify the application manufactured by a software vendor. The field is relevant to Apple *OS only.', + example: 'com.apple.xpc.proxy', + name: 'process.parent.code_signature.signing_id', + type: 'keyword', + }, 'process.parent.code_signature.status': { category: 'process', description: @@ -2946,6 +3309,14 @@ export const fieldsBeat: BeatFields = { name: 'process.parent.code_signature.subject_name', type: 'keyword', }, + 'process.parent.code_signature.team_id': { + category: 'process', + description: + 'The team identifier used to sign the process. This is used to identify the team or vendor of a software product. The field is relevant to Apple *OS only.', + example: 'EQHXZ8M8AV', + name: 'process.parent.code_signature.team_id', + type: 'keyword', + }, 'process.parent.code_signature.trusted': { category: 'process', description: @@ -3017,6 +3388,12 @@ export const fieldsBeat: BeatFields = { name: 'process.parent.hash.sha512', type: 'keyword', }, + 'process.parent.hash.ssdeep': { + category: 'process', + description: 'SSDEEP hash.', + name: 'process.parent.hash.ssdeep', + type: 'keyword', + }, 'process.parent.name': { category: 'process', description: 'Process name. Sometimes called program name or similar.', @@ -3455,6 +3832,13 @@ export const fieldsBeat: BeatFields = { name: 'server.geo.city_name', type: 'keyword', }, + 'server.geo.continent_code': { + category: 'server', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'server.geo.continent_code', + type: 'keyword', + }, 'server.geo.continent_name': { category: 'server', description: 'Name of the continent.', @@ -3491,6 +3875,14 @@ export const fieldsBeat: BeatFields = { name: 'server.geo.name', type: 'keyword', }, + 'server.geo.postal_code': { + category: 'server', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'server.geo.postal_code', + type: 'keyword', + }, 'server.geo.region_iso_code': { category: 'server', description: 'Region ISO code.', @@ -3505,6 +3897,13 @@ export const fieldsBeat: BeatFields = { name: 'server.geo.region_name', type: 'keyword', }, + 'server.geo.timezone': { + category: 'server', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'server.geo.timezone', + type: 'keyword', + }, 'server.ip': { category: 'server', description: 'IP address of the server (IPv4 or IPv6).', @@ -3513,7 +3912,9 @@ export const fieldsBeat: BeatFields = { }, 'server.mac': { category: 'server', - description: 'MAC address of the server.', + description: + 'MAC address of the server. The notation format from RFC 7042 is suggested: Each octet (that is, 8-bit byte) is represented by two [uppercase] hexadecimal digits giving the value of the octet as an unsigned integer. Successive octets are separated by a hyphen.', + example: '00-00-5E-00-53-23', name: 'server.mac', type: 'keyword', }, @@ -3733,6 +4134,13 @@ export const fieldsBeat: BeatFields = { name: 'source.geo.city_name', type: 'keyword', }, + 'source.geo.continent_code': { + category: 'source', + description: "Two-letter code representing continent's name.", + example: 'NA', + name: 'source.geo.continent_code', + type: 'keyword', + }, 'source.geo.continent_name': { category: 'source', description: 'Name of the continent.', @@ -3769,6 +4177,14 @@ export const fieldsBeat: BeatFields = { name: 'source.geo.name', type: 'keyword', }, + 'source.geo.postal_code': { + category: 'source', + description: + 'Postal code associated with the location. Values appropriate for this field may also be known as a postcode or ZIP code and will vary widely from country to country.', + example: 94040, + name: 'source.geo.postal_code', + type: 'keyword', + }, 'source.geo.region_iso_code': { category: 'source', description: 'Region ISO code.', @@ -3783,6 +4199,13 @@ export const fieldsBeat: BeatFields = { name: 'source.geo.region_name', type: 'keyword', }, + 'source.geo.timezone': { + category: 'source', + description: 'The time zone of the location, such as IANA time zone name.', + example: 'America/Argentina/Buenos_Aires', + name: 'source.geo.timezone', + type: 'keyword', + }, 'source.ip': { category: 'source', description: 'IP address of the source (IPv4 or IPv6).', @@ -3791,7 +4214,9 @@ export const fieldsBeat: BeatFields = { }, 'source.mac': { category: 'source', - description: 'MAC address of the source.', + description: + 'MAC address of the source. The notation format from RFC 7042 is suggested: Each octet (that is, 8-bit byte) is represented by two [uppercase] hexadecimal digits giving the value of the octet as an unsigned integer. Successive octets are separated by a hyphen.', + example: '00-00-5E-00-53-23', name: 'source.mac', type: 'keyword', }, @@ -5437,6 +5862,12 @@ export const fieldsBeat: BeatFields = { name: 'kubernetes.pod.uid', type: 'keyword', }, + 'kubernetes.pod.ip': { + category: 'kubernetes', + description: 'Kubernetes Pod IP ', + name: 'kubernetes.pod.ip', + type: 'ip', + }, 'kubernetes.namespace': { category: 'kubernetes', description: 'Kubernetes namespace ', @@ -5467,10 +5898,10 @@ export const fieldsBeat: BeatFields = { name: 'kubernetes.annotations.*', type: 'object', }, - 'kubernetes.service.selectors.*': { + 'kubernetes.selectors.*': { category: 'kubernetes', - description: 'Kubernetes Service selectors map ', - name: 'kubernetes.service.selectors.*', + description: 'Kubernetes selectors map ', + name: 'kubernetes.selectors.*', type: 'object', }, 'kubernetes.replicaset.name': { @@ -5493,7 +5924,7 @@ export const fieldsBeat: BeatFields = { }, 'kubernetes.container.name': { category: 'kubernetes', - description: 'Kubernetes container name ', + description: 'Kubernetes container name (different than the name from the runtime) ', name: 'kubernetes.container.name', type: 'keyword', }, @@ -5501,7 +5932,7 @@ export const fieldsBeat: BeatFields = { category: 'kubernetes', description: 'Kubernetes container image ', name: 'kubernetes.container.image', - type: 'keyword', + type: 'alias', }, 'process.exe': { category: 'process', @@ -9407,6 +9838,13 @@ export const fieldsBeat: BeatFields = { name: 'mongodb.log.message', type: 'alias', }, + 'mongodb.log.id': { + category: 'mongodb', + description: 'Integer representing the unique identifier of the log statement ', + example: 4615611, + name: 'mongodb.log.id', + type: 'long', + }, 'mysql.thread_id': { category: 'mysql', description: 'The connection or thread ID for the query. ', @@ -11563,6 +12001,12 @@ export const fieldsBeat: BeatFields = { name: 'aws.vpcflow.type', type: 'keyword', }, + 'awsfargate.log': { + category: 'awsfargate', + description: 'Fields for Amazon Fargate container logs. ', + name: 'awsfargate.log', + type: 'group', + }, 'azure.subscription_id': { category: 'azure', description: 'Azure subscription ID ', @@ -11731,17 +12175,11 @@ export const fieldsBeat: BeatFields = { name: 'azure.activitylogs.event_category', type: 'keyword', }, - 'azure.activitylogs.properties.service_request_id': { - category: 'azure', - description: 'Service Request Id ', - name: 'azure.activitylogs.properties.service_request_id', - type: 'keyword', - }, - 'azure.activitylogs.properties.status_code': { + 'azure.activitylogs.properties': { category: 'azure', - description: 'Status code ', - name: 'azure.activitylogs.properties.status_code', - type: 'keyword', + description: 'Properties ', + name: 'azure.activitylogs.properties', + type: 'flattened', }, 'azure.auditlogs.category': { category: 'azure', @@ -12007,11 +12445,11 @@ export const fieldsBeat: BeatFields = { name: 'azure.platformlogs.ActivityId', type: 'keyword', }, - 'azure.platformlogs.properties.*': { + 'azure.platformlogs.properties': { category: 'azure', - description: 'Properties ', - name: 'azure.platformlogs.properties.*', - type: 'object', + description: 'Event inner properties ', + name: 'azure.platformlogs.properties', + type: 'flattened', }, 'azure.signinlogs.operation_name': { category: 'azure', @@ -17157,7 +17595,7 @@ export const fieldsBeat: BeatFields = { }, 'checkpoint.duration': { category: 'checkpoint', - description: 'Scan duration. ', + description: 'Scan duration. ', name: 'checkpoint.duration', type: 'keyword', }, @@ -17211,7 +17649,7 @@ export const fieldsBeat: BeatFields = { }, 'checkpoint.next_scheduled_scan_date': { category: 'checkpoint', - description: 'Next scan scheduled time according to time object. ', + description: 'Next scan scheduled time according to time object. ', name: 'checkpoint.next_scheduled_scan_date', type: 'keyword', }, @@ -18231,7 +18669,7 @@ export const fieldsBeat: BeatFields = { category: 'checkpoint', description: 'Matched object name on source column. ', name: 'checkpoint.source_object', - type: 'integer', + type: 'keyword', }, 'checkpoint.destination_object': { category: 'checkpoint', @@ -18288,6 +18726,12 @@ export const fieldsBeat: BeatFields = { name: 'checkpoint.action_reason', type: 'integer', }, + 'checkpoint.action_reason_msg': { + category: 'checkpoint', + description: 'Connection drop reason message. ', + name: 'checkpoint.action_reason_msg', + type: 'keyword', + }, 'checkpoint.c_bytes': { category: 'checkpoint', description: 'Boolean value indicates whether bytes sent from the client side are used. ', @@ -18916,10 +19360,10 @@ export const fieldsBeat: BeatFields = { name: 'cisco.amp.file.archived_file.identity.sha1', type: 'keyword', }, - 'cisco.amp.file.archived_file.identify.sha256': { + 'cisco.amp.file.archived_file.identity.sha256': { category: 'cisco', description: 'SHA256 hash of the archived file related to the malicious event. ', - name: 'cisco.amp.file.archived_file.identify.sha256', + name: 'cisco.amp.file.archived_file.identity.sha256', type: 'keyword', }, 'cisco.amp.file.attack_details.application': { @@ -19045,12 +19489,36 @@ export const fieldsBeat: BeatFields = { name: 'cisco.amp.tactics', type: 'flattened', }, + 'cisco.amp.mitre_tactics': { + category: 'cisco', + description: "Array of all related mitre tactic ID's ", + name: 'cisco.amp.mitre_tactics', + type: 'keyword', + }, 'cisco.amp.techniques': { category: 'cisco', description: 'List of all MITRE techniques related to the incident found. ', name: 'cisco.amp.techniques', type: 'flattened', }, + 'cisco.amp.mitre_techniques': { + category: 'cisco', + description: "Array of all related mitre technique ID's ", + name: 'cisco.amp.mitre_techniques', + type: 'keyword', + }, + 'cisco.amp.command_line.arguments': { + category: 'cisco', + description: 'The CLI arguments related to the Cloud Threat IOC reported by Cisco. ', + name: 'cisco.amp.command_line.arguments', + type: 'keyword', + }, + 'cisco.amp.bp_data': { + category: 'cisco', + description: 'Endpoint isolation information ', + name: 'cisco.amp.bp_data', + type: 'flattened', + }, 'cisco.asa.message_id': { category: 'cisco', description: 'The Cisco ASA message identifier. ', @@ -19240,6 +19708,18 @@ export const fieldsBeat: BeatFields = { name: 'cisco.asa.burst.cumulative_count', type: 'keyword', }, + 'cisco.asa.termination_user': { + category: 'cisco', + description: 'AAA name of user requesting termination ', + name: 'cisco.asa.termination_user', + type: 'keyword', + }, + 'cisco.asa.webvpn.group_name': { + category: 'cisco', + description: 'The WebVPN group name the user belongs to ', + name: 'cisco.asa.webvpn.group_name', + type: 'keyword', + }, 'cisco.ftd.message_id': { category: 'cisco', description: 'The Cisco FTD message identifier. ', @@ -19369,6 +19849,18 @@ export const fieldsBeat: BeatFields = { name: 'cisco.ftd.dap_records', type: 'keyword', }, + 'cisco.ftd.termination_user': { + category: 'cisco', + description: 'AAA name of user requesting termination ', + name: 'cisco.ftd.termination_user', + type: 'keyword', + }, + 'cisco.ftd.webvpn.group_name': { + category: 'cisco', + description: 'The WebVPN group name the user belongs to ', + name: 'cisco.ftd.webvpn.group_name', + type: 'keyword', + }, 'cisco.ios.access_list': { category: 'cisco', description: 'Name of the IP access list. ', @@ -20065,6 +20557,387 @@ export const fieldsBeat: BeatFields = { name: 'crowdstrike.event.Commands', type: 'keyword', }, + 'cyberarkpas.audit.action': { + category: 'cyberarkpas', + description: 'A description of the audit record.', + name: 'cyberarkpas.audit.action', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.address': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.address', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.cpm_disabled': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.cpm_disabled', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.cpm_error_details': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.cpm_error_details', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.cpm_status': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.cpm_status', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.creation_method': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.creation_method', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.customer': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.customer', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.database': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.database', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.device_type': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.device_type', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.dual_account_status': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.dual_account_status', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.group_name': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.group_name', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.in_process': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.in_process', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.index': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.index', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.last_fail_date': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.last_fail_date', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.last_success_change': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.last_success_change', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.last_success_reconciliation': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.last_success_reconciliation', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.last_success_verification': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.last_success_verification', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.last_task': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.last_task', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.logon_domain': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.logon_domain', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.policy_id': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.policy_id', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.port': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.port', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.privcloud': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.privcloud', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.reset_immediately': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.reset_immediately', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.retries_count': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.retries_count', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.sequence_id': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.sequence_id', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.tags': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.tags', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.user_dn': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.user_dn', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.user_name': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.user_name', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.virtual_username': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.virtual_username', + type: 'keyword', + }, + 'cyberarkpas.audit.ca_properties.other': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.ca_properties.other', + type: 'flattened', + }, + 'cyberarkpas.audit.category': { + category: 'cyberarkpas', + description: 'The category name (for category-related operations).', + name: 'cyberarkpas.audit.category', + type: 'keyword', + }, + 'cyberarkpas.audit.desc': { + category: 'cyberarkpas', + description: 'A static value that displays a description of the audit codes.', + name: 'cyberarkpas.audit.desc', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.ad_process_id': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.ad_process_id', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.ad_process_name': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.ad_process_name', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.application_type': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.application_type', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.command': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.command', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.connection_component_id': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.connection_component_id', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.dst_host': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.dst_host', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.logon_account': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.logon_account', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.managed_account': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.managed_account', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.process_id': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.process_id', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.process_name': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.process_name', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.protocol': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.protocol', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.psmid': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.psmid', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.session_duration': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.session_duration', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.session_id': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.session_id', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.src_host': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.src_host', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.username': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.username', + type: 'keyword', + }, + 'cyberarkpas.audit.extra_details.other': { + category: 'cyberarkpas', + name: 'cyberarkpas.audit.extra_details.other', + type: 'flattened', + }, + 'cyberarkpas.audit.file': { + category: 'cyberarkpas', + description: 'The name of the target file.', + name: 'cyberarkpas.audit.file', + type: 'keyword', + }, + 'cyberarkpas.audit.gateway_station': { + category: 'cyberarkpas', + description: 'The IP of the web application machine (PVWA).', + name: 'cyberarkpas.audit.gateway_station', + type: 'ip', + }, + 'cyberarkpas.audit.hostname': { + category: 'cyberarkpas', + description: 'The hostname, in upper case.', + example: 'MY-COMPUTER', + name: 'cyberarkpas.audit.hostname', + type: 'keyword', + }, + 'cyberarkpas.audit.iso_timestamp': { + category: 'cyberarkpas', + description: 'The timestamp, in ISO Timestamp format (RFC 3339).', + example: '"2013-06-25T10:47:19.000Z"', + name: 'cyberarkpas.audit.iso_timestamp', + type: 'date', + }, + 'cyberarkpas.audit.issuer': { + category: 'cyberarkpas', + description: + 'The Vault user who wrote the audit. This is usually the user who performed the operation.', + name: 'cyberarkpas.audit.issuer', + type: 'keyword', + }, + 'cyberarkpas.audit.location': { + category: 'cyberarkpas', + description: 'The target Location (for Location operations).', + name: 'cyberarkpas.audit.location', + type: 'keyword', + }, + 'cyberarkpas.audit.message': { + category: 'cyberarkpas', + description: 'A description of the audit records (same information as in the Desc field).', + name: 'cyberarkpas.audit.message', + type: 'keyword', + }, + 'cyberarkpas.audit.message_id': { + category: 'cyberarkpas', + description: 'The code ID of the audit records.', + name: 'cyberarkpas.audit.message_id', + type: 'keyword', + }, + 'cyberarkpas.audit.product': { + category: 'cyberarkpas', + description: 'A static value that represents the product.', + name: 'cyberarkpas.audit.product', + type: 'keyword', + }, + 'cyberarkpas.audit.pvwa_details': { + category: 'cyberarkpas', + description: 'Specific details of the PVWA audit records.', + name: 'cyberarkpas.audit.pvwa_details', + type: 'flattened', + }, + 'cyberarkpas.audit.raw': { + category: 'cyberarkpas', + description: + 'Raw XML for the original audit record. Only present when XSLT file has debugging enabled. ', + name: 'cyberarkpas.audit.raw', + type: 'keyword', + }, + 'cyberarkpas.audit.reason': { + category: 'cyberarkpas', + description: 'The reason entered by the user.', + name: 'cyberarkpas.audit.reason', + type: 'text', + }, + 'cyberarkpas.audit.rfc5424': { + category: 'cyberarkpas', + description: 'Whether the syslog format complies with RFC5424.', + example: 'yes', + name: 'cyberarkpas.audit.rfc5424', + type: 'boolean', + }, + 'cyberarkpas.audit.safe': { + category: 'cyberarkpas', + description: 'The name of the target Safe.', + name: 'cyberarkpas.audit.safe', + type: 'keyword', + }, + 'cyberarkpas.audit.severity': { + category: 'cyberarkpas', + description: 'The severity of the audit records.', + name: 'cyberarkpas.audit.severity', + type: 'keyword', + }, + 'cyberarkpas.audit.source_user': { + category: 'cyberarkpas', + description: 'The name of the Vault user who performed the operation.', + name: 'cyberarkpas.audit.source_user', + type: 'keyword', + }, + 'cyberarkpas.audit.station': { + category: 'cyberarkpas', + description: + 'The IP from where the operation was performed. For PVWA sessions, this will be the real client machine IP.', + name: 'cyberarkpas.audit.station', + type: 'ip', + }, + 'cyberarkpas.audit.target_user': { + category: 'cyberarkpas', + description: 'The name of the Vault user on which the operation was performed.', + name: 'cyberarkpas.audit.target_user', + type: 'keyword', + }, + 'cyberarkpas.audit.timestamp': { + category: 'cyberarkpas', + description: 'The timestamp, in MMM DD HH:MM:SS format.', + example: 'Jun 25 10:47:19', + name: 'cyberarkpas.audit.timestamp', + type: 'keyword', + }, + 'cyberarkpas.audit.vendor': { + category: 'cyberarkpas', + description: 'A static value that represents the vendor.', + name: 'cyberarkpas.audit.vendor', + type: 'keyword', + }, + 'cyberarkpas.audit.version': { + category: 'cyberarkpas', + description: 'A static value that represents the version of the Vault.', + name: 'cyberarkpas.audit.version', + type: 'keyword', + }, 'envoyproxy.log_type': { category: 'envoyproxy', description: 'Envoy log type, normally ACCESS ', @@ -21008,6 +21881,12 @@ export const fieldsBeat: BeatFields = { name: 'fortinet.firewall.esptransform', type: 'keyword', }, + 'fortinet.firewall.eventtype': { + category: 'fortinet', + description: 'UTM Event Type ', + name: 'fortinet.firewall.eventtype', + type: 'keyword', + }, 'fortinet.firewall.exch': { category: 'fortinet', description: 'Mail Exchanges from DNS response answer section ', @@ -22496,6 +23375,12 @@ export const fieldsBeat: BeatFields = { name: 'fortinet.firewall.utmaction', type: 'keyword', }, + 'fortinet.firewall.utmref': { + category: 'fortinet', + description: 'Reference to UTM ', + name: 'fortinet.firewall.utmref', + type: 'keyword', + }, 'fortinet.firewall.vap': { category: 'fortinet', description: 'Virtual AP ', @@ -22682,323 +23567,323 @@ export const fieldsBeat: BeatFields = { name: 'fortinet.firewall.xid', type: 'integer', }, - 'googlecloud.destination.instance.project_id': { - category: 'googlecloud', + 'gcp.destination.instance.project_id': { + category: 'gcp', description: 'ID of the project containing the VM. ', - name: 'googlecloud.destination.instance.project_id', + name: 'gcp.destination.instance.project_id', type: 'keyword', }, - 'googlecloud.destination.instance.region': { - category: 'googlecloud', + 'gcp.destination.instance.region': { + category: 'gcp', description: 'Region of the VM. ', - name: 'googlecloud.destination.instance.region', + name: 'gcp.destination.instance.region', type: 'keyword', }, - 'googlecloud.destination.instance.zone': { - category: 'googlecloud', + 'gcp.destination.instance.zone': { + category: 'gcp', description: 'Zone of the VM. ', - name: 'googlecloud.destination.instance.zone', + name: 'gcp.destination.instance.zone', type: 'keyword', }, - 'googlecloud.destination.vpc.project_id': { - category: 'googlecloud', + 'gcp.destination.vpc.project_id': { + category: 'gcp', description: 'ID of the project containing the VM. ', - name: 'googlecloud.destination.vpc.project_id', + name: 'gcp.destination.vpc.project_id', type: 'keyword', }, - 'googlecloud.destination.vpc.vpc_name': { - category: 'googlecloud', + 'gcp.destination.vpc.vpc_name': { + category: 'gcp', description: 'VPC on which the VM is operating. ', - name: 'googlecloud.destination.vpc.vpc_name', + name: 'gcp.destination.vpc.vpc_name', type: 'keyword', }, - 'googlecloud.destination.vpc.subnetwork_name': { - category: 'googlecloud', + 'gcp.destination.vpc.subnetwork_name': { + category: 'gcp', description: 'Subnetwork on which the VM is operating. ', - name: 'googlecloud.destination.vpc.subnetwork_name', + name: 'gcp.destination.vpc.subnetwork_name', type: 'keyword', }, - 'googlecloud.source.instance.project_id': { - category: 'googlecloud', + 'gcp.source.instance.project_id': { + category: 'gcp', description: 'ID of the project containing the VM. ', - name: 'googlecloud.source.instance.project_id', + name: 'gcp.source.instance.project_id', type: 'keyword', }, - 'googlecloud.source.instance.region': { - category: 'googlecloud', + 'gcp.source.instance.region': { + category: 'gcp', description: 'Region of the VM. ', - name: 'googlecloud.source.instance.region', + name: 'gcp.source.instance.region', type: 'keyword', }, - 'googlecloud.source.instance.zone': { - category: 'googlecloud', + 'gcp.source.instance.zone': { + category: 'gcp', description: 'Zone of the VM. ', - name: 'googlecloud.source.instance.zone', + name: 'gcp.source.instance.zone', type: 'keyword', }, - 'googlecloud.source.vpc.project_id': { - category: 'googlecloud', + 'gcp.source.vpc.project_id': { + category: 'gcp', description: 'ID of the project containing the VM. ', - name: 'googlecloud.source.vpc.project_id', + name: 'gcp.source.vpc.project_id', type: 'keyword', }, - 'googlecloud.source.vpc.vpc_name': { - category: 'googlecloud', + 'gcp.source.vpc.vpc_name': { + category: 'gcp', description: 'VPC on which the VM is operating. ', - name: 'googlecloud.source.vpc.vpc_name', + name: 'gcp.source.vpc.vpc_name', type: 'keyword', }, - 'googlecloud.source.vpc.subnetwork_name': { - category: 'googlecloud', + 'gcp.source.vpc.subnetwork_name': { + category: 'gcp', description: 'Subnetwork on which the VM is operating. ', - name: 'googlecloud.source.vpc.subnetwork_name', + name: 'gcp.source.vpc.subnetwork_name', type: 'keyword', }, - 'googlecloud.audit.type': { - category: 'googlecloud', + 'gcp.audit.type': { + category: 'gcp', description: 'Type property. ', - name: 'googlecloud.audit.type', + name: 'gcp.audit.type', type: 'keyword', }, - 'googlecloud.audit.authentication_info.principal_email': { - category: 'googlecloud', + 'gcp.audit.authentication_info.principal_email': { + category: 'gcp', description: 'The email address of the authenticated user making the request. ', - name: 'googlecloud.audit.authentication_info.principal_email', + name: 'gcp.audit.authentication_info.principal_email', type: 'keyword', }, - 'googlecloud.audit.authentication_info.authority_selector': { - category: 'googlecloud', + 'gcp.audit.authentication_info.authority_selector': { + category: 'gcp', description: 'The authority selector specified by the requestor, if any. It is not guaranteed that the principal was allowed to use this authority. ', - name: 'googlecloud.audit.authentication_info.authority_selector', + name: 'gcp.audit.authentication_info.authority_selector', type: 'keyword', }, - 'googlecloud.audit.authorization_info.permission': { - category: 'googlecloud', + 'gcp.audit.authorization_info.permission': { + category: 'gcp', description: 'The required IAM permission. ', - name: 'googlecloud.audit.authorization_info.permission', + name: 'gcp.audit.authorization_info.permission', type: 'keyword', }, - 'googlecloud.audit.authorization_info.granted': { - category: 'googlecloud', + 'gcp.audit.authorization_info.granted': { + category: 'gcp', description: 'Whether or not authorization for resource and permission was granted. ', - name: 'googlecloud.audit.authorization_info.granted', + name: 'gcp.audit.authorization_info.granted', type: 'boolean', }, - 'googlecloud.audit.authorization_info.resource_attributes.service': { - category: 'googlecloud', + 'gcp.audit.authorization_info.resource_attributes.service': { + category: 'gcp', description: 'The name of the service. ', - name: 'googlecloud.audit.authorization_info.resource_attributes.service', + name: 'gcp.audit.authorization_info.resource_attributes.service', type: 'keyword', }, - 'googlecloud.audit.authorization_info.resource_attributes.name': { - category: 'googlecloud', + 'gcp.audit.authorization_info.resource_attributes.name': { + category: 'gcp', description: 'The name of the resource. ', - name: 'googlecloud.audit.authorization_info.resource_attributes.name', + name: 'gcp.audit.authorization_info.resource_attributes.name', type: 'keyword', }, - 'googlecloud.audit.authorization_info.resource_attributes.type': { - category: 'googlecloud', + 'gcp.audit.authorization_info.resource_attributes.type': { + category: 'gcp', description: 'The type of the resource. ', - name: 'googlecloud.audit.authorization_info.resource_attributes.type', + name: 'gcp.audit.authorization_info.resource_attributes.type', type: 'keyword', }, - 'googlecloud.audit.method_name': { - category: 'googlecloud', + 'gcp.audit.method_name': { + category: 'gcp', description: "The name of the service method or operation. For API calls, this should be the name of the API method. For example, 'google.datastore.v1.Datastore.RunQuery'. ", - name: 'googlecloud.audit.method_name', + name: 'gcp.audit.method_name', type: 'keyword', }, - 'googlecloud.audit.num_response_items': { - category: 'googlecloud', + 'gcp.audit.num_response_items': { + category: 'gcp', description: 'The number of items returned from a List or Query API method, if applicable. ', - name: 'googlecloud.audit.num_response_items', + name: 'gcp.audit.num_response_items', type: 'long', }, - 'googlecloud.audit.request.proto_name': { - category: 'googlecloud', + 'gcp.audit.request.proto_name': { + category: 'gcp', description: 'Type property of the request. ', - name: 'googlecloud.audit.request.proto_name', + name: 'gcp.audit.request.proto_name', type: 'keyword', }, - 'googlecloud.audit.request.filter': { - category: 'googlecloud', + 'gcp.audit.request.filter': { + category: 'gcp', description: 'Filter of the request. ', - name: 'googlecloud.audit.request.filter', + name: 'gcp.audit.request.filter', type: 'keyword', }, - 'googlecloud.audit.request.name': { - category: 'googlecloud', + 'gcp.audit.request.name': { + category: 'gcp', description: 'Name of the request. ', - name: 'googlecloud.audit.request.name', + name: 'gcp.audit.request.name', type: 'keyword', }, - 'googlecloud.audit.request.resource_name': { - category: 'googlecloud', + 'gcp.audit.request.resource_name': { + category: 'gcp', description: 'Name of the request resource. ', - name: 'googlecloud.audit.request.resource_name', + name: 'gcp.audit.request.resource_name', type: 'keyword', }, - 'googlecloud.audit.request_metadata.caller_ip': { - category: 'googlecloud', + 'gcp.audit.request_metadata.caller_ip': { + category: 'gcp', description: 'The IP address of the caller. ', - name: 'googlecloud.audit.request_metadata.caller_ip', + name: 'gcp.audit.request_metadata.caller_ip', type: 'ip', }, - 'googlecloud.audit.request_metadata.caller_supplied_user_agent': { - category: 'googlecloud', + 'gcp.audit.request_metadata.caller_supplied_user_agent': { + category: 'gcp', description: 'The user agent of the caller. This information is not authenticated and should be treated accordingly. ', - name: 'googlecloud.audit.request_metadata.caller_supplied_user_agent', + name: 'gcp.audit.request_metadata.caller_supplied_user_agent', type: 'keyword', }, - 'googlecloud.audit.response.proto_name': { - category: 'googlecloud', + 'gcp.audit.response.proto_name': { + category: 'gcp', description: 'Type property of the response. ', - name: 'googlecloud.audit.response.proto_name', + name: 'gcp.audit.response.proto_name', type: 'keyword', }, - 'googlecloud.audit.response.details.group': { - category: 'googlecloud', + 'gcp.audit.response.details.group': { + category: 'gcp', description: 'The name of the group. ', - name: 'googlecloud.audit.response.details.group', + name: 'gcp.audit.response.details.group', type: 'keyword', }, - 'googlecloud.audit.response.details.kind': { - category: 'googlecloud', + 'gcp.audit.response.details.kind': { + category: 'gcp', description: 'The kind of the response details. ', - name: 'googlecloud.audit.response.details.kind', + name: 'gcp.audit.response.details.kind', type: 'keyword', }, - 'googlecloud.audit.response.details.name': { - category: 'googlecloud', + 'gcp.audit.response.details.name': { + category: 'gcp', description: 'The name of the response details. ', - name: 'googlecloud.audit.response.details.name', + name: 'gcp.audit.response.details.name', type: 'keyword', }, - 'googlecloud.audit.response.details.uid': { - category: 'googlecloud', + 'gcp.audit.response.details.uid': { + category: 'gcp', description: 'The uid of the response details. ', - name: 'googlecloud.audit.response.details.uid', + name: 'gcp.audit.response.details.uid', type: 'keyword', }, - 'googlecloud.audit.response.status': { - category: 'googlecloud', + 'gcp.audit.response.status': { + category: 'gcp', description: 'Status of the response. ', - name: 'googlecloud.audit.response.status', + name: 'gcp.audit.response.status', type: 'keyword', }, - 'googlecloud.audit.resource_name': { - category: 'googlecloud', + 'gcp.audit.resource_name': { + category: 'gcp', description: "The resource or collection that is the target of the operation. The name is a scheme-less URI, not including the API service name. For example, 'shelves/SHELF_ID/books'. ", - name: 'googlecloud.audit.resource_name', + name: 'gcp.audit.resource_name', type: 'keyword', }, - 'googlecloud.audit.resource_location.current_locations': { - category: 'googlecloud', + 'gcp.audit.resource_location.current_locations': { + category: 'gcp', description: 'Current locations of the resource. ', - name: 'googlecloud.audit.resource_location.current_locations', + name: 'gcp.audit.resource_location.current_locations', type: 'keyword', }, - 'googlecloud.audit.service_name': { - category: 'googlecloud', + 'gcp.audit.service_name': { + category: 'gcp', description: 'The name of the API service performing the operation. For example, datastore.googleapis.com. ', - name: 'googlecloud.audit.service_name', + name: 'gcp.audit.service_name', type: 'keyword', }, - 'googlecloud.audit.status.code': { - category: 'googlecloud', + 'gcp.audit.status.code': { + category: 'gcp', description: 'The status code, which should be an enum value of google.rpc.Code. ', - name: 'googlecloud.audit.status.code', + name: 'gcp.audit.status.code', type: 'integer', }, - 'googlecloud.audit.status.message': { - category: 'googlecloud', + 'gcp.audit.status.message': { + category: 'gcp', description: 'A developer-facing error message, which should be in English. Any user-facing error message should be localized and sent in the google.rpc.Status.details field, or localized by the client. ', - name: 'googlecloud.audit.status.message', + name: 'gcp.audit.status.message', type: 'keyword', }, - 'googlecloud.firewall.rule_details.priority': { - category: 'googlecloud', + 'gcp.firewall.rule_details.priority': { + category: 'gcp', description: 'The priority for the firewall rule.', - name: 'googlecloud.firewall.rule_details.priority', + name: 'gcp.firewall.rule_details.priority', type: 'long', }, - 'googlecloud.firewall.rule_details.action': { - category: 'googlecloud', + 'gcp.firewall.rule_details.action': { + category: 'gcp', description: 'Action that the rule performs on match.', - name: 'googlecloud.firewall.rule_details.action', + name: 'gcp.firewall.rule_details.action', type: 'keyword', }, - 'googlecloud.firewall.rule_details.direction': { - category: 'googlecloud', + 'gcp.firewall.rule_details.direction': { + category: 'gcp', description: 'Direction of traffic that matches this rule.', - name: 'googlecloud.firewall.rule_details.direction', + name: 'gcp.firewall.rule_details.direction', type: 'keyword', }, - 'googlecloud.firewall.rule_details.reference': { - category: 'googlecloud', + 'gcp.firewall.rule_details.reference': { + category: 'gcp', description: 'Reference to the firewall rule.', - name: 'googlecloud.firewall.rule_details.reference', + name: 'gcp.firewall.rule_details.reference', type: 'keyword', }, - 'googlecloud.firewall.rule_details.source_range': { - category: 'googlecloud', + 'gcp.firewall.rule_details.source_range': { + category: 'gcp', description: 'List of source ranges that the firewall rule applies to.', - name: 'googlecloud.firewall.rule_details.source_range', + name: 'gcp.firewall.rule_details.source_range', type: 'keyword', }, - 'googlecloud.firewall.rule_details.destination_range': { - category: 'googlecloud', + 'gcp.firewall.rule_details.destination_range': { + category: 'gcp', description: 'List of destination ranges that the firewall applies to.', - name: 'googlecloud.firewall.rule_details.destination_range', + name: 'gcp.firewall.rule_details.destination_range', type: 'keyword', }, - 'googlecloud.firewall.rule_details.source_tag': { - category: 'googlecloud', + 'gcp.firewall.rule_details.source_tag': { + category: 'gcp', description: 'List of all the source tags that the firewall rule applies to. ', - name: 'googlecloud.firewall.rule_details.source_tag', + name: 'gcp.firewall.rule_details.source_tag', type: 'keyword', }, - 'googlecloud.firewall.rule_details.target_tag': { - category: 'googlecloud', + 'gcp.firewall.rule_details.target_tag': { + category: 'gcp', description: 'List of all the target tags that the firewall rule applies to. ', - name: 'googlecloud.firewall.rule_details.target_tag', + name: 'gcp.firewall.rule_details.target_tag', type: 'keyword', }, - 'googlecloud.firewall.rule_details.ip_port_info': { - category: 'googlecloud', + 'gcp.firewall.rule_details.ip_port_info': { + category: 'gcp', description: 'List of ip protocols and applicable port ranges for rules. ', - name: 'googlecloud.firewall.rule_details.ip_port_info', + name: 'gcp.firewall.rule_details.ip_port_info', type: 'array', }, - 'googlecloud.firewall.rule_details.source_service_account': { - category: 'googlecloud', + 'gcp.firewall.rule_details.source_service_account': { + category: 'gcp', description: 'List of all the source service accounts that the firewall rule applies to. ', - name: 'googlecloud.firewall.rule_details.source_service_account', + name: 'gcp.firewall.rule_details.source_service_account', type: 'keyword', }, - 'googlecloud.firewall.rule_details.target_service_account': { - category: 'googlecloud', + 'gcp.firewall.rule_details.target_service_account': { + category: 'gcp', description: 'List of all the target service accounts that the firewall rule applies to. ', - name: 'googlecloud.firewall.rule_details.target_service_account', + name: 'gcp.firewall.rule_details.target_service_account', type: 'keyword', }, - 'googlecloud.vpcflow.reporter': { - category: 'googlecloud', + 'gcp.vpcflow.reporter': { + category: 'gcp', description: "The side which reported the flow. Can be either 'SRC' or 'DEST'. ", - name: 'googlecloud.vpcflow.reporter', + name: 'gcp.vpcflow.reporter', type: 'keyword', }, - 'googlecloud.vpcflow.rtt.ms': { - category: 'googlecloud', + 'gcp.vpcflow.rtt.ms': { + category: 'gcp', description: 'Latency as measured (for TCP flows only) during the time interval. This is the time elapsed between sending a SEQ and receiving a corresponding ACK and it contains the network RTT as well as the application related delay. ', - name: 'googlecloud.vpcflow.rtt.ms', + name: 'gcp.vpcflow.rtt.ms', type: 'long', }, 'google_workspace.actor.type': { @@ -23826,13 +24711,13 @@ export const fieldsBeat: BeatFields = { category: 'google_workspace', description: 'SAML status code. ', name: 'google_workspace.saml.status_code', - type: 'long', + type: 'keyword', }, 'google_workspace.saml.second_level_status_code': { category: 'google_workspace', description: 'SAML second level status code. ', name: 'google_workspace.saml.second_level_status_code', - type: 'long', + type: 'keyword', }, 'gsuite.actor.type': { category: 'gsuite', @@ -24659,13 +25544,13 @@ export const fieldsBeat: BeatFields = { category: 'gsuite', description: 'SAML status code. ', name: 'gsuite.saml.status_code', - type: 'long', + type: 'keyword', }, 'gsuite.saml.second_level_status_code': { category: 'gsuite', description: 'SAML second level status code. ', name: 'gsuite.saml.second_level_status_code', - type: 'long', + type: 'keyword', }, 'ibmmq.errorlog.installation': { category: 'ibmmq', @@ -27287,6 +28172,78 @@ export const fieldsBeat: BeatFields = { name: 'okta.debug_context.debug_data.url', type: 'keyword', }, + 'okta.debug_context.debug_data.suspicious_activity.browser': { + category: 'okta', + description: 'The browser used. ', + name: 'okta.debug_context.debug_data.suspicious_activity.browser', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_city': { + category: 'okta', + description: 'The city where the suspicious activity took place. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_city', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_country': { + category: 'okta', + description: 'The country where the suspicious activity took place. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_country', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_id': { + category: 'okta', + description: 'The event ID. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_id', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_ip': { + category: 'okta', + description: 'The IP of the suspicious event. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_ip', + type: 'ip', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_latitude': { + category: 'okta', + description: 'The latitude where the suspicious activity took place. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_latitude', + type: 'float', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_longitude': { + category: 'okta', + description: 'The longitude where the suspicious activity took place. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_longitude', + type: 'float', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_state': { + category: 'okta', + description: 'The state where the suspicious activity took place. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_state', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_transaction_id': { + category: 'okta', + description: 'The event transaction ID. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_transaction_id', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.event_type': { + category: 'okta', + description: 'The event type. ', + name: 'okta.debug_context.debug_data.suspicious_activity.event_type', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.os': { + category: 'okta', + description: 'The OS of the system from where the suspicious activity occured. ', + name: 'okta.debug_context.debug_data.suspicious_activity.os', + type: 'keyword', + }, + 'okta.debug_context.debug_data.suspicious_activity.timestamp': { + category: 'okta', + description: 'The timestamp of when the activity occurred. ', + name: 'okta.debug_context.debug_data.suspicious_activity.timestamp', + type: 'date', + }, 'okta.authentication_context.authentication_provider': { category: 'okta', description: @@ -27631,6 +28588,228 @@ export const fieldsBeat: BeatFields = { description: 'Specifies the sub type of the log', name: 'panw.panos.sub_type', }, + 'panw.panos.virtual_sys': { + category: 'panw', + description: 'Virtual system instance ', + name: 'panw.panos.virtual_sys', + type: 'keyword', + }, + 'panw.panos.client_os_ver': { + category: 'panw', + description: 'The client device’s OS version. ', + name: 'panw.panos.client_os_ver', + type: 'keyword', + }, + 'panw.panos.client_os': { + category: 'panw', + description: 'The client device’s OS version. ', + name: 'panw.panos.client_os', + type: 'keyword', + }, + 'panw.panos.client_ver': { + category: 'panw', + description: 'The client’s GlobalProtect app version. ', + name: 'panw.panos.client_ver', + type: 'keyword', + }, + 'panw.panos.stage': { + category: 'panw', + description: 'A string showing the stage of the connection ', + example: 'before-login', + name: 'panw.panos.stage', + type: 'keyword', + }, + 'panw.panos.actionflags': { + category: 'panw', + description: 'A bit field indicating if the log was forwarded to Panorama. ', + name: 'panw.panos.actionflags', + type: 'keyword', + }, + 'panw.panos.error': { + category: 'panw', + description: 'A string showing that error that has occurred in any event. ', + name: 'panw.panos.error', + type: 'keyword', + }, + 'panw.panos.error_code': { + category: 'panw', + description: 'An integer associated with any errors that occurred. ', + name: 'panw.panos.error_code', + type: 'integer', + }, + 'panw.panos.repeatcnt': { + category: 'panw', + description: + 'The number of sessions with the same source IP address, destination IP address, application, and subtype that GlobalProtect has detected within the last five seconds.An integer associated with any errors that occurred. ', + name: 'panw.panos.repeatcnt', + type: 'integer', + }, + 'panw.panos.serial_number': { + category: 'panw', + description: 'The serial number of the user’s machine or device. ', + name: 'panw.panos.serial_number', + type: 'keyword', + }, + 'panw.panos.auth_method': { + category: 'panw', + description: 'A string showing the authentication type ', + example: 'LDAP', + name: 'panw.panos.auth_method', + type: 'keyword', + }, + 'panw.panos.datasource': { + category: 'panw', + description: 'Source from which mapping information is collected. ', + name: 'panw.panos.datasource', + type: 'keyword', + }, + 'panw.panos.datasourcetype': { + category: 'panw', + description: 'Mechanism used to identify the IP/User mappings within a data source. ', + name: 'panw.panos.datasourcetype', + type: 'keyword', + }, + 'panw.panos.datasourcename': { + category: 'panw', + description: 'User-ID source that sends the IP (Port)-User Mapping. ', + name: 'panw.panos.datasourcename', + type: 'keyword', + }, + 'panw.panos.factorno': { + category: 'panw', + description: 'Indicates the use of primary authentication (1) or additional factors (2, 3). ', + name: 'panw.panos.factorno', + type: 'integer', + }, + 'panw.panos.factortype': { + category: 'panw', + description: 'Vendor used to authenticate a user when Multi Factor authentication is present. ', + name: 'panw.panos.factortype', + type: 'keyword', + }, + 'panw.panos.factorcompletiontime': { + category: 'panw', + description: 'Time the authentication was completed. ', + name: 'panw.panos.factorcompletiontime', + type: 'date', + }, + 'panw.panos.ugflags': { + category: 'panw', + description: + 'Displays whether the user group that was found during user group mapping. Supported values are: User Group Found—Indicates whether the user could be mapped to a group. Duplicate User—Indicates whether duplicate users were found in a user group. Displays N/A if no user group is found. ', + name: 'panw.panos.ugflags', + type: 'keyword', + }, + 'panw.panos.device_group_hierarchy.level_1': { + category: 'panw', + description: + 'A sequence of identification numbers that indicate the device group’s location within a device group hierarchy. The firewall (or virtual system) generating the log includes the identification number of each ancestor in its device group hierarchy. The shared device group (level 0) is not included in this structure. If the log values are 12, 34, 45, 0, it means that the log was generated by a firewall (or virtual system) that belongs to device group 45, and its ancestors are 34, and 12. ', + name: 'panw.panos.device_group_hierarchy.level_1', + type: 'keyword', + }, + 'panw.panos.device_group_hierarchy.level_2': { + category: 'panw', + description: + 'A sequence of identification numbers that indicate the device group’s location within a device group hierarchy. The firewall (or virtual system) generating the log includes the identification number of each ancestor in its device group hierarchy. The shared device group (level 0) is not included in this structure. If the log values are 12, 34, 45, 0, it means that the log was generated by a firewall (or virtual system) that belongs to device group 45, and its ancestors are 34, and 12. ', + name: 'panw.panos.device_group_hierarchy.level_2', + type: 'keyword', + }, + 'panw.panos.device_group_hierarchy.level_3': { + category: 'panw', + description: + 'A sequence of identification numbers that indicate the device group’s location within a device group hierarchy. The firewall (or virtual system) generating the log includes the identification number of each ancestor in its device group hierarchy. The shared device group (level 0) is not included in this structure. If the log values are 12, 34, 45, 0, it means that the log was generated by a firewall (or virtual system) that belongs to device group 45, and its ancestors are 34, and 12. ', + name: 'panw.panos.device_group_hierarchy.level_3', + type: 'keyword', + }, + 'panw.panos.device_group_hierarchy.level_4': { + category: 'panw', + description: + 'A sequence of identification numbers that indicate the device group’s location within a device group hierarchy. The firewall (or virtual system) generating the log includes the identification number of each ancestor in its device group hierarchy. The shared device group (level 0) is not included in this structure. If the log values are 12, 34, 45, 0, it means that the log was generated by a firewall (or virtual system) that belongs to device group 45, and its ancestors are 34, and 12. ', + name: 'panw.panos.device_group_hierarchy.level_4', + type: 'keyword', + }, + 'panw.panos.timeout': { + category: 'panw', + description: 'Timeout after which the IP/User Mappings are cleared. ', + name: 'panw.panos.timeout', + type: 'integer', + }, + 'panw.panos.vsys_id': { + category: 'panw', + description: 'A unique identifier for a virtual system on a Palo Alto Networks firewall. ', + name: 'panw.panos.vsys_id', + type: 'keyword', + }, + 'panw.panos.vsys_name': { + category: 'panw', + description: + 'The name of the virtual system associated with the session; only valid on firewalls enabled for multiple virtual systems. ', + name: 'panw.panos.vsys_name', + type: 'keyword', + }, + 'panw.panos.description': { + category: 'panw', + description: 'Additional information for any event that has occurred. ', + name: 'panw.panos.description', + type: 'keyword', + }, + 'panw.panos.tunnel_type': { + category: 'panw', + description: 'The type of tunnel (either SSLVPN or IPSec). ', + name: 'panw.panos.tunnel_type', + type: 'keyword', + }, + 'panw.panos.connect_method': { + category: 'panw', + description: 'A string showing the how the GlobalProtect app connects to Gateway ', + name: 'panw.panos.connect_method', + type: 'keyword', + }, + 'panw.panos.matchname': { + category: 'panw', + description: 'Name of the HIP object or profile. ', + name: 'panw.panos.matchname', + type: 'keyword', + }, + 'panw.panos.matchtype': { + category: 'panw', + description: 'Whether the hip field represents a HIP object or a HIP profile. ', + name: 'panw.panos.matchtype', + type: 'keyword', + }, + 'panw.panos.priority': { + category: 'panw', + description: + 'The priority order of the gateway that is based on highest (1), high (2), medium (3), low (4), or lowest (5) to which the GlobalProtect app can connect. ', + name: 'panw.panos.priority', + type: 'keyword', + }, + 'panw.panos.response_time': { + category: 'panw', + description: + 'The SSL response time of the selected gateway that is measured in milliseconds on the endpoint during tunnel setup. ', + name: 'panw.panos.response_time', + type: 'keyword', + }, + 'panw.panos.attempted_gateways': { + category: 'panw', + description: + 'The fields that are collected for each gateway connection attempt with the gateway name, SSL response time, and priority ', + name: 'panw.panos.attempted_gateways', + type: 'keyword', + }, + 'panw.panos.gateway': { + category: 'panw', + description: 'The name of the gateway that is specified on the portal configuration. ', + name: 'panw.panos.gateway', + type: 'keyword', + }, + 'panw.panos.selection_type': { + category: 'panw', + description: 'The connection method that is selected to connect to the gateway. ', + name: 'panw.panos.selection_type', + type: 'keyword', + }, 'rabbitmq.log.pid': { category: 'rabbitmq', description: 'The Erlang process id', @@ -28103,10 +29282,10 @@ export const fieldsBeat: BeatFields = { name: 'sophos.xg.recv_bytes', type: 'long', }, - 'sophos.xg.trans_src_ ip': { + 'sophos.xg.trans_src_ip': { category: 'sophos', description: 'Translated source IP address for outgoing traffic ', - name: 'sophos.xg.trans_src_ ip', + name: 'sophos.xg.trans_src_ip', type: 'ip', }, 'sophos.xg.trans_src_port': { @@ -28313,16 +29492,16 @@ export const fieldsBeat: BeatFields = { name: 'sophos.xg.virus', type: 'keyword', }, - 'sophos.xg.FTP_url': { + 'sophos.xg.ftp_url': { category: 'sophos', description: 'FTP URL from which virus was downloaded ', - name: 'sophos.xg.FTP_url', + name: 'sophos.xg.ftp_url', type: 'keyword', }, - 'sophos.xg.FTP_direction': { + 'sophos.xg.ftp_direction': { category: 'sophos', description: 'Direction of FTP transfer: Upload or Download ', - name: 'sophos.xg.FTP_direction', + name: 'sophos.xg.ftp_direction', type: 'keyword', }, 'sophos.xg.filesize': { @@ -28955,6 +30134,18 @@ export const fieldsBeat: BeatFields = { name: 'sophos.xg.clients_conn_ssid', type: 'keyword', }, + 'sophos.xg.sqli': { + category: 'sophos', + description: 'The related SQLI caught by the WAF ', + name: 'sophos.xg.sqli', + type: 'keyword', + }, + 'sophos.xg.xss': { + category: 'sophos', + description: 'The related XSS caught by the WAF ', + name: 'sophos.xg.xss', + type: 'keyword', + }, 'suricata.eve.event_type': { category: 'suricata', name: 'suricata.eve.event_type', @@ -29241,6 +30432,131 @@ export const fieldsBeat: BeatFields = { name: 'suricata.eve.alert.signature_id', type: 'long', }, + 'suricata.eve.alert.protocols': { + category: 'suricata', + name: 'suricata.eve.alert.protocols', + type: 'keyword', + }, + 'suricata.eve.alert.attack_target': { + category: 'suricata', + name: 'suricata.eve.alert.attack_target', + type: 'keyword', + }, + 'suricata.eve.alert.capec_id': { + category: 'suricata', + name: 'suricata.eve.alert.capec_id', + type: 'keyword', + }, + 'suricata.eve.alert.cwe_id': { + category: 'suricata', + name: 'suricata.eve.alert.cwe_id', + type: 'keyword', + }, + 'suricata.eve.alert.malware': { + category: 'suricata', + name: 'suricata.eve.alert.malware', + type: 'keyword', + }, + 'suricata.eve.alert.cve': { + category: 'suricata', + name: 'suricata.eve.alert.cve', + type: 'keyword', + }, + 'suricata.eve.alert.cvss_v2_base': { + category: 'suricata', + name: 'suricata.eve.alert.cvss_v2_base', + type: 'keyword', + }, + 'suricata.eve.alert.cvss_v2_temporal': { + category: 'suricata', + name: 'suricata.eve.alert.cvss_v2_temporal', + type: 'keyword', + }, + 'suricata.eve.alert.cvss_v3_base': { + category: 'suricata', + name: 'suricata.eve.alert.cvss_v3_base', + type: 'keyword', + }, + 'suricata.eve.alert.cvss_v3_temporal': { + category: 'suricata', + name: 'suricata.eve.alert.cvss_v3_temporal', + type: 'keyword', + }, + 'suricata.eve.alert.priority': { + category: 'suricata', + name: 'suricata.eve.alert.priority', + type: 'keyword', + }, + 'suricata.eve.alert.hostile': { + category: 'suricata', + name: 'suricata.eve.alert.hostile', + type: 'keyword', + }, + 'suricata.eve.alert.infected': { + category: 'suricata', + name: 'suricata.eve.alert.infected', + type: 'keyword', + }, + 'suricata.eve.alert.created_at': { + category: 'suricata', + name: 'suricata.eve.alert.created_at', + type: 'date', + }, + 'suricata.eve.alert.updated_at': { + category: 'suricata', + name: 'suricata.eve.alert.updated_at', + type: 'date', + }, + 'suricata.eve.alert.classtype': { + category: 'suricata', + name: 'suricata.eve.alert.classtype', + type: 'keyword', + }, + 'suricata.eve.alert.rule_source': { + category: 'suricata', + name: 'suricata.eve.alert.rule_source', + type: 'keyword', + }, + 'suricata.eve.alert.sid': { + category: 'suricata', + name: 'suricata.eve.alert.sid', + type: 'keyword', + }, + 'suricata.eve.alert.affected_product': { + category: 'suricata', + name: 'suricata.eve.alert.affected_product', + type: 'keyword', + }, + 'suricata.eve.alert.deployment': { + category: 'suricata', + name: 'suricata.eve.alert.deployment', + type: 'keyword', + }, + 'suricata.eve.alert.former_category': { + category: 'suricata', + name: 'suricata.eve.alert.former_category', + type: 'keyword', + }, + 'suricata.eve.alert.mitre_tool_id': { + category: 'suricata', + name: 'suricata.eve.alert.mitre_tool_id', + type: 'keyword', + }, + 'suricata.eve.alert.performance_impact': { + category: 'suricata', + name: 'suricata.eve.alert.performance_impact', + type: 'keyword', + }, + 'suricata.eve.alert.signature_severity': { + category: 'suricata', + name: 'suricata.eve.alert.signature_severity', + type: 'keyword', + }, + 'suricata.eve.alert.tag': { + category: 'suricata', + name: 'suricata.eve.alert.tag', + type: 'keyword', + }, 'suricata.eve.ssh.client.proto_version': { category: 'suricata', name: 'suricata.eve.ssh.client.proto_version', @@ -30001,7 +31317,7 @@ export const fieldsBeat: BeatFields = { description: 'The date and time when intelligence source first reported sighting this indicator. ', name: 'threatintel.indicator.first_seen', - type: 'keyword', + type: 'date', }, 'threatintel.indicator.last_seen': { category: 'threatintel', @@ -30156,46 +31472,53 @@ export const fieldsBeat: BeatFields = { name: 'threatintel.indicator.registry.key', type: 'keyword', }, - 'threatintel.indicator.geo.geo.city_name': { + 'threatintel.indicator.geo.city_name': { category: 'threatintel', description: 'City name.', example: 'Montreal', - name: 'threatintel.indicator.geo.geo.city_name', + name: 'threatintel.indicator.geo.city_name', + type: 'keyword', + }, + 'threatintel.indicator.geo.continent_name': { + category: 'threatintel', + description: 'Name of the continent.', + example: 'North America', + name: 'threatintel.indicator.geo.continent_name', type: 'keyword', }, - 'threatintel.indicator.geo.geo.country_iso_code': { + 'threatintel.indicator.geo.country_iso_code': { category: 'threatintel', description: 'Country ISO code.', example: 'CA', - name: 'threatintel.indicator.geo.geo.country_iso_code', + name: 'threatintel.indicator.geo.country_iso_code', type: 'keyword', }, - 'threatintel.indicator.geo.geo.country_name': { + 'threatintel.indicator.geo.country_name': { category: 'threatintel', description: 'Country name.', example: 'Canada', - name: 'threatintel.indicator.geo.geo.country_name', + name: 'threatintel.indicator.geo.country_name', type: 'keyword', }, - 'threatintel.indicator.geo.geo.location': { + 'threatintel.indicator.geo.location': { category: 'threatintel', description: 'Longitude and latitude.', example: '{ "lon": -73.614830, "lat": 45.505918 }', - name: 'threatintel.indicator.geo.geo.location', + name: 'threatintel.indicator.geo.location', type: 'geo_point', }, - 'threatintel.indicator.geo.geo.region_iso_code': { + 'threatintel.indicator.geo.region_iso_code': { category: 'threatintel', description: 'Region ISO code.', example: 'CA-QC', - name: 'threatintel.indicator.geo.geo.region_iso_code', + name: 'threatintel.indicator.geo.region_iso_code', type: 'keyword', }, - 'threatintel.indicator.geo.geo.region_name': { + 'threatintel.indicator.geo.region_name': { category: 'threatintel', description: 'Region name.', example: 'Quebec', - name: 'threatintel.indicator.geo.geo.region_name', + name: 'threatintel.indicator.geo.region_name', type: 'keyword', }, 'threatintel.indicator.file.pe.imphash': { @@ -30236,6 +31559,12 @@ export const fieldsBeat: BeatFields = { name: 'threatintel.indicator.file.hash.sha256', type: 'keyword', }, + 'threatintel.indicator.file.hash.sha384': { + category: 'threatintel', + description: "The file's sha384 hash, if available. ", + name: 'threatintel.indicator.file.hash.sha384', + type: 'keyword', + }, 'threatintel.indicator.file.hash.sha512': { category: 'threatintel', description: "The file's sha512 hash, if available. ", @@ -30244,22 +31573,34 @@ export const fieldsBeat: BeatFields = { }, 'threatintel.indicator.file.type': { category: 'threatintel', - description: 'The file type ', + description: 'The file type. ', name: 'threatintel.indicator.file.type', type: 'keyword', }, 'threatintel.indicator.file.size': { category: 'threatintel', - description: "The file's total size ", + description: "The file's total size. ", name: 'threatintel.indicator.file.size', type: 'long', }, 'threatintel.indicator.file.name': { category: 'threatintel', - description: "The file's name ", + description: "The file's name. ", name: 'threatintel.indicator.file.name', type: 'keyword', }, + 'threatintel.indicator.file.extension': { + category: 'threatintel', + description: "The file's extension. ", + name: 'threatintel.indicator.file.extension', + type: 'keyword', + }, + 'threatintel.indicator.file.mime_type': { + category: 'threatintel', + description: "The file's MIME type. ", + name: 'threatintel.indicator.file.mime_type', + type: 'keyword', + }, 'threatintel.indicator.url.domain': { category: 'threatintel', description: 'Domain of the url, such as "www.elastic.co". ', @@ -30383,6 +31724,12 @@ export const fieldsBeat: BeatFields = { name: 'threatintel.indicator.x509.alternative_names', type: 'keyword', }, + 'threatintel.indicator.signature': { + category: 'threatintel', + description: 'Malware family of sample (if available). ', + name: 'threatintel.indicator.signature', + type: 'keyword', + }, 'threatintel.abusemalware.file_type': { category: 'threatintel', description: 'File type guessed by URLhaus. ', @@ -30549,6 +31896,171 @@ export const fieldsBeat: BeatFields = { name: 'threatintel.anomali.object_marking_refs', type: 'keyword', }, + 'threatintel.anomalithreatstream.classification': { + category: 'threatintel', + description: + 'Indicates whether an indicator is private or from a public feed and available publicly. Possible values: private, public. ', + example: 'private', + name: 'threatintel.anomalithreatstream.classification', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.confidence': { + category: 'threatintel', + description: + "The measure of the accuracy (from 0 to 100) assigned by ThreatStream's predictive analytics technology to indicators. ", + name: 'threatintel.anomalithreatstream.confidence', + type: 'short', + }, + 'threatintel.anomalithreatstream.detail2': { + category: 'threatintel', + description: 'Detail text for indicator. ', + example: 'Imported by user 42.', + name: 'threatintel.anomalithreatstream.detail2', + type: 'text', + }, + 'threatintel.anomalithreatstream.id': { + category: 'threatintel', + description: 'The ID of the indicator. ', + name: 'threatintel.anomalithreatstream.id', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.import_session_id': { + category: 'threatintel', + description: 'ID of the import session that created the indicator on ThreatStream. ', + name: 'threatintel.anomalithreatstream.import_session_id', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.itype': { + category: 'threatintel', + description: + 'Indicator type. Possible values: "apt_domain", "apt_email", "apt_ip", "apt_url", "bot_ip", "c2_domain", "c2_ip", "c2_url", "i2p_ip", "mal_domain", "mal_email", "mal_ip", "mal_md5", "mal_url", "parked_ip", "phish_email", "phish_ip", "phish_url", "scan_ip", "spam_domain", "ssh_ip", "suspicious_domain", "tor_ip" and "torrent_tracker_url". ', + name: 'threatintel.anomalithreatstream.itype', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.maltype': { + category: 'threatintel', + description: + 'Information regarding a malware family, a CVE ID, or another attack or threat, associated with the indicator. ', + name: 'threatintel.anomalithreatstream.maltype', + type: 'wildcard', + }, + 'threatintel.anomalithreatstream.md5': { + category: 'threatintel', + description: 'Hash for the indicator. ', + name: 'threatintel.anomalithreatstream.md5', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.resource_uri': { + category: 'threatintel', + description: 'Relative URI for the indicator details. ', + name: 'threatintel.anomalithreatstream.resource_uri', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.severity': { + category: 'threatintel', + description: + 'Criticality associated with the threat feed that supplied the indicator. Possible values: low, medium, high, very-high. ', + name: 'threatintel.anomalithreatstream.severity', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.source': { + category: 'threatintel', + description: 'Source for the indicator. ', + example: 'Analyst', + name: 'threatintel.anomalithreatstream.source', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.source_feed_id': { + category: 'threatintel', + description: 'ID for the integrator source. ', + name: 'threatintel.anomalithreatstream.source_feed_id', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.state': { + category: 'threatintel', + description: 'State for this indicator. ', + example: 'active', + name: 'threatintel.anomalithreatstream.state', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.trusted_circle_ids': { + category: 'threatintel', + description: 'ID of the trusted circle that imported the indicator. ', + name: 'threatintel.anomalithreatstream.trusted_circle_ids', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.update_id': { + category: 'threatintel', + description: 'Update ID. ', + name: 'threatintel.anomalithreatstream.update_id', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.url': { + category: 'threatintel', + description: 'URL for the indicator. ', + name: 'threatintel.anomalithreatstream.url', + type: 'keyword', + }, + 'threatintel.anomalithreatstream.value_type': { + category: 'threatintel', + description: 'Data type of the indicator. Possible values: ip, domain, url, email, md5. ', + name: 'threatintel.anomalithreatstream.value_type', + type: 'keyword', + }, + 'threatintel.malwarebazaar.file_type': { + category: 'threatintel', + description: 'File type guessed by Malware Bazaar. ', + name: 'threatintel.malwarebazaar.file_type', + type: 'keyword', + }, + 'threatintel.malwarebazaar.signature': { + category: 'threatintel', + description: 'Malware familiy. ', + name: 'threatintel.malwarebazaar.signature', + type: 'keyword', + }, + 'threatintel.malwarebazaar.tags': { + category: 'threatintel', + description: 'A list of tags associated with the queried malware sample. ', + name: 'threatintel.malwarebazaar.tags', + type: 'keyword', + }, + 'threatintel.malwarebazaar.intelligence.downloads': { + category: 'threatintel', + description: 'Number of downloads from MalwareBazaar. ', + name: 'threatintel.malwarebazaar.intelligence.downloads', + type: 'long', + }, + 'threatintel.malwarebazaar.intelligence.uploads': { + category: 'threatintel', + description: 'Number of uploads from MalwareBazaar. ', + name: 'threatintel.malwarebazaar.intelligence.uploads', + type: 'long', + }, + 'threatintel.malwarebazaar.intelligence.mail.Generic': { + category: 'threatintel', + description: 'Malware seen in generic spam traffic. ', + name: 'threatintel.malwarebazaar.intelligence.mail.Generic', + type: 'keyword', + }, + 'threatintel.malwarebazaar.intelligence.mail.IT': { + category: 'threatintel', + description: 'Malware seen in IT spam traffic. ', + name: 'threatintel.malwarebazaar.intelligence.mail.IT', + type: 'keyword', + }, + 'threatintel.malwarebazaar.anonymous': { + category: 'threatintel', + description: 'Identifies if the sample was submitted anonymously. ', + name: 'threatintel.malwarebazaar.anonymous', + type: 'long', + }, + 'threatintel.malwarebazaar.code_sign': { + category: 'threatintel', + description: 'Code signing information for the sample. ', + name: 'threatintel.malwarebazaar.code_sign', + type: 'keyword', + }, 'threatintel.misp.id': { category: 'threatintel', description: 'Attribute ID. ', @@ -30828,6 +32340,85 @@ export const fieldsBeat: BeatFields = { name: 'threatintel.otx.type', type: 'keyword', }, + 'threatintel.recordedfuture.entity.id': { + category: 'threatintel', + description: 'Entity ID. ', + example: 'ip:192.0.2.13', + name: 'threatintel.recordedfuture.entity.id', + type: 'keyword', + }, + 'threatintel.recordedfuture.entity.name': { + category: 'threatintel', + description: 'Entity name. Value for the entity. ', + example: '192.0.2.13', + name: 'threatintel.recordedfuture.entity.name', + type: 'keyword', + }, + 'threatintel.recordedfuture.entity.type': { + category: 'threatintel', + description: 'Entity type. ', + example: 'IpAddress', + name: 'threatintel.recordedfuture.entity.type', + type: 'keyword', + }, + 'threatintel.recordedfuture.intelCard': { + category: 'threatintel', + description: 'Link to the Recorded Future Intelligence Card for to this indicator. ', + name: 'threatintel.recordedfuture.intelCard', + type: 'keyword', + }, + 'threatintel.recordedfuture.ip_range': { + category: 'threatintel', + description: 'Range of IPs for this indicator. ', + example: '192.0.2.0/16', + name: 'threatintel.recordedfuture.ip_range', + type: 'ip_range', + }, + 'threatintel.recordedfuture.risk.criticality': { + category: 'threatintel', + description: 'Risk criticality (0-4). ', + name: 'threatintel.recordedfuture.risk.criticality', + type: 'byte', + }, + 'threatintel.recordedfuture.risk.criticalityLabel': { + category: 'threatintel', + description: + 'Risk criticality label. One of None, Unusual, Suspicious, Malicious, Very Malicious. ', + name: 'threatintel.recordedfuture.risk.criticalityLabel', + type: 'keyword', + }, + 'threatintel.recordedfuture.risk.evidenceDetails': { + category: 'threatintel', + description: "Risk's evidence details. ", + name: 'threatintel.recordedfuture.risk.evidenceDetails', + type: 'flattened', + }, + 'threatintel.recordedfuture.risk.score': { + category: 'threatintel', + description: 'Risk score (0-99). ', + name: 'threatintel.recordedfuture.risk.score', + type: 'short', + }, + 'threatintel.recordedfuture.risk.riskString': { + category: 'threatintel', + description: 'Number of Risk Rules observed as a factor of total number of rules. ', + example: '1/54', + name: 'threatintel.recordedfuture.risk.riskString', + type: 'keyword', + }, + 'threatintel.recordedfuture.risk.riskSummary': { + category: 'threatintel', + description: 'Risk summary. ', + example: '1 of 54 Risk Rules currently observed.', + name: 'threatintel.recordedfuture.risk.riskSummary', + type: 'keyword', + }, + 'threatintel.recordedfuture.risk.rules': { + category: 'threatintel', + description: 'Number of rules observed. ', + name: 'threatintel.recordedfuture.risk.rules', + type: 'long', + }, 'zeek.session_id': { category: 'zeek', description: 'A unique identifier of the session ', @@ -32149,6 +33740,85 @@ export const fieldsBeat: BeatFields = { name: 'zeek.ntlm.server.name.tree', type: 'keyword', }, + 'zeek.ntp.version': { + category: 'zeek', + description: 'The NTP version number (1, 2, 3, 4). ', + name: 'zeek.ntp.version', + type: 'integer', + }, + 'zeek.ntp.mode': { + category: 'zeek', + description: 'The NTP mode being used. ', + name: 'zeek.ntp.mode', + type: 'integer', + }, + 'zeek.ntp.stratum': { + category: 'zeek', + description: 'The stratum (primary server, secondary server, etc.). ', + name: 'zeek.ntp.stratum', + type: 'integer', + }, + 'zeek.ntp.poll': { + category: 'zeek', + description: 'The maximum interval between successive messages in seconds. ', + name: 'zeek.ntp.poll', + type: 'double', + }, + 'zeek.ntp.precision': { + category: 'zeek', + description: 'The precision of the system clock in seconds. ', + name: 'zeek.ntp.precision', + type: 'double', + }, + 'zeek.ntp.root_delay': { + category: 'zeek', + description: 'Total round-trip delay to the reference clock in seconds. ', + name: 'zeek.ntp.root_delay', + type: 'double', + }, + 'zeek.ntp.root_disp': { + category: 'zeek', + description: 'Total dispersion to the reference clock in seconds. ', + name: 'zeek.ntp.root_disp', + type: 'double', + }, + 'zeek.ntp.ref_id': { + category: 'zeek', + description: + 'For stratum 0, 4 character string used for debugging. For stratum 1, ID assigned to the reference clock by IANA. Above stratum 1, when using IPv4, the IP address of the reference clock. Note that the NTP protocol did not originally specify a large enough field to represent IPv6 addresses, so they use the first four bytes of the MD5 hash of the reference clock’s IPv6 address (i.e. an IPv4 address here is not necessarily IPv4). ', + name: 'zeek.ntp.ref_id', + type: 'keyword', + }, + 'zeek.ntp.ref_time': { + category: 'zeek', + description: 'Time when the system clock was last set or correct. ', + name: 'zeek.ntp.ref_time', + type: 'date', + }, + 'zeek.ntp.org_time': { + category: 'zeek', + description: 'Time at the client when the request departed for the NTP server. ', + name: 'zeek.ntp.org_time', + type: 'date', + }, + 'zeek.ntp.rec_time': { + category: 'zeek', + description: 'Time at the server when the request arrived from the NTP client. ', + name: 'zeek.ntp.rec_time', + type: 'date', + }, + 'zeek.ntp.xmt_time': { + category: 'zeek', + description: 'Time at the server when the response departed for the NTP client. ', + name: 'zeek.ntp.xmt_time', + type: 'date', + }, + 'zeek.ntp.num_exts': { + category: 'zeek', + description: 'Number of extension fields (which are not currently parsed). ', + name: 'zeek.ntp.num_exts', + type: 'integer', + }, 'zeek.ocsp.file_id': { category: 'zeek', description: 'File id of the OCSP reply. ', @@ -33864,6 +35534,50 @@ export const fieldsBeat: BeatFields = { name: 'zeek.x509.log_cert', type: 'boolean', }, + 'zookeeper.audit.session': { + category: 'zookeeper', + description: 'Client session id ', + name: 'zookeeper.audit.session', + type: 'keyword', + }, + 'zookeeper.audit.znode': { + category: 'zookeeper', + description: 'Path of the znode ', + name: 'zookeeper.audit.znode', + type: 'keyword', + }, + 'zookeeper.audit.znode_type': { + category: 'zookeeper', + description: 'Type of znode in case of creation operation ', + name: 'zookeeper.audit.znode_type', + type: 'keyword', + }, + 'zookeeper.audit.acl': { + category: 'zookeeper', + description: + 'String representation of znode ACL like cdrwa(create, delete,read, write, admin). This is logged only for setAcl operation ', + name: 'zookeeper.audit.acl', + type: 'keyword', + }, + 'zookeeper.audit.result': { + category: 'zookeeper', + description: + 'Result of the operation. Possible values are (success/failure/invoked). Result "invoked" is used for serverStop operation because stop is logged before ensuring that server actually stopped. ', + name: 'zookeeper.audit.result', + type: 'keyword', + }, + 'zookeeper.audit.user': { + category: 'zookeeper', + description: 'Comma separated list of users who are associate with a client session ', + name: 'zookeeper.audit.user', + type: 'keyword', + }, + 'zookeeper.log': { + category: 'zookeeper', + description: 'ZooKeeper logs. ', + name: 'zookeeper.log', + type: 'group', + }, 'zoom.master_account_id': { category: 'zoom', description: 'Master Account related to a specific Sub Account ', @@ -34813,18 +36527,30 @@ export const fieldsBeat: BeatFields = { name: 'aws-cloudwatch.ingestion_time', type: 'keyword', }, - bucket_name: { - category: 'base', + 'bucket.name': { + category: 'bucket', description: 'Name of the S3 bucket that this log retrieved from. ', - name: 'bucket_name', + name: 'bucket.name', type: 'keyword', }, - object_key: { - category: 'base', + 'bucket.arn': { + category: 'bucket', + description: 'ARN of the S3 bucket that this log retrieved from. ', + name: 'bucket.arn', + type: 'keyword', + }, + 'object.key': { + category: 'object', description: 'Name of the S3 object that this log retrieved from. ', - name: 'object_key', + name: 'object.key', type: 'keyword', }, + metadata: { + category: 'base', + description: 'AWS S3 object metadata values.', + name: 'metadata', + type: 'flattened', + }, 'netflow.type': { category: 'netflow', description: 'The type of NetFlow record described by this event. ', @@ -41982,6 +43708,12 @@ export const fieldsBeat: BeatFields = { name: 'winlog.task', type: 'keyword', }, + 'winlog.time_created': { + category: 'winlog', + description: 'The event creation time. ', + name: 'winlog.time_created', + type: 'date', + }, 'winlog.process.thread.id': { category: 'winlog', name: 'winlog.process.thread.id', diff --git a/x-pack/plugins/snapshot_restore/kibana.json b/x-pack/plugins/snapshot_restore/kibana.json index a8a3881929f40..bd2a85126c0c6 100644 --- a/x-pack/plugins/snapshot_restore/kibana.json +++ b/x-pack/plugins/snapshot_restore/kibana.json @@ -3,21 +3,12 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": [ - "licensing", - "management", - "features" - ], - "optionalPlugins": [ - "usageCollection", - "security", - "cloud", - "home" - ], + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, + "requiredPlugins": ["licensing", "management", "features"], + "optionalPlugins": ["usageCollection", "security", "cloud", "home"], "configPath": ["xpack", "snapshot_restore"], - "requiredBundles": [ - "esUiShared", - "kibanaReact", - "home" - ] + "requiredBundles": ["esUiShared", "kibanaReact", "home"] } diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index ed9f7d4e635f4..1b4271328c2f9 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -1,9 +1,20 @@ { "id": "stackAlerts", + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["alerting", "features", "triggersActionsUi", "kibanaReact", "savedObjects", "data"], + "requiredPlugins": [ + "alerting", + "features", + "triggersActionsUi", + "kibanaReact", + "savedObjects", + "data" + ], "configPath": ["xpack", "stack_alerts"], "requiredBundles": ["esUiShared"], "ui": true diff --git a/x-pack/plugins/task_manager/kibana.json b/x-pack/plugins/task_manager/kibana.json index aab1cd0ab41a5..d0b847ce58d77 100644 --- a/x-pack/plugins/task_manager/kibana.json +++ b/x-pack/plugins/task_manager/kibana.json @@ -2,6 +2,10 @@ "id": "taskManager", "server": true, "version": "8.0.0", + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "kibanaVersion": "kibana", "configPath": ["xpack", "task_manager"], "optionalPlugins": ["usageCollection"], diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index e61361233cda6..7eb769c53068d 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -32,6 +32,8 @@ export interface ActionProps { isEventPinned?: boolean; isEventViewer?: boolean; rowIndex: number; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; refetch?: () => void; onRuleChange?: () => void; showNotes?: boolean; @@ -40,6 +42,25 @@ export interface ActionProps { toggleShowNotes?: () => void; } +export type SetEventsLoading = (params: { eventIds: string[]; isLoading: boolean }) => void; +export type SetEventsDeleted = (params: { eventIds: string[]; isDeleted: boolean }) => void; +export type OnUpdateAlertStatusSuccess = ( + updated: number, + conflicts: number, + status: AlertStatus +) => void; +export type OnUpdateAlertStatusError = (status: AlertStatus, error: Error) => void; + +export interface StatusBulkActionsProps { + eventIds: string[]; + currentStatus?: AlertStatus; + query?: string; + indexName: string; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; + onUpdateSuccess?: OnUpdateAlertStatusSuccess; + onUpdateFailure?: OnUpdateAlertStatusError; +} export interface HeaderActionProps { width: number; browserFields: BrowserFields; @@ -90,13 +111,10 @@ export type ControlColumnProps = Omit< keyof AdditionalControlColumnProps > & Partial; - -export type OnAlertStatusActionSuccess = (status: AlertStatus) => void; -export type OnAlertStatusActionFailure = (status: AlertStatus, error: string) => void; export interface BulkActionsObjectProp { alertStatusActions?: boolean; - onAlertStatusActionSuccess?: OnAlertStatusActionSuccess; - onAlertStatusActionFailure?: OnAlertStatusActionFailure; + onAlertStatusActionSuccess?: OnUpdateAlertStatusSuccess; + onAlertStatusActionFailure?: OnUpdateAlertStatusError; } export type BulkActionsProp = boolean | BulkActionsObjectProp; diff --git a/x-pack/plugins/timelines/common/utils/field_formatters.ts b/x-pack/plugins/timelines/common/utils/field_formatters.ts index b436f8e616122..a48f03b90af6b 100644 --- a/x-pack/plugins/timelines/common/utils/field_formatters.ts +++ b/x-pack/plugins/timelines/common/utils/field_formatters.ts @@ -43,7 +43,7 @@ export const getDataFromSourceHits = ( category?: string, path?: string ): TimelineEventsDetailsItem[] => - Object.keys(sources).reduce((accumulator, source) => { + Object.keys(sources ?? {}).reduce((accumulator, source) => { const item: EventSource = get(source, sources); if (Array.isArray(item) || isString(item) || isNumber(item)) { const field = path ? `${path}.${source}` : source; diff --git a/x-pack/plugins/timelines/kibana.json b/x-pack/plugins/timelines/kibana.json index bc9fba2c4a1bb..0239dcdd8f166 100644 --- a/x-pack/plugins/timelines/kibana.json +++ b/x-pack/plugins/timelines/kibana.json @@ -1,5 +1,9 @@ { "id": "timelines", + "owner": { + "name": "Security solution", + "githubTeam": "security-solution" + }, "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "timelines"], diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx index 338d7d1809074..fb7899165bb3d 100644 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx +++ b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.test.tsx @@ -100,7 +100,7 @@ describe('AddToCaseAction', () => { {...props} event={{ _id: 'test-id', - data: [], + data: [{ field: 'kibana.alert.rule.id', value: ['rule-id'] }], ecs: { _id: 'test-id', _index: 'test-index', @@ -112,7 +112,7 @@ describe('AddToCaseAction', () => { {...props} event={{ _id: 'test-id', - data: [], + data: [{ field: 'kibana.alert.rule.id', value: ['rule-id'] }], ecs: { _id: 'test-id', _index: 'test-index', diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx index a292999ec75eb..b6d581f52cbe5 100644 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx +++ b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_case_action.tsx @@ -9,7 +9,7 @@ import React, { memo, useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { CaseStatuses, StatusAll } from '../../../../../../cases/common'; import { TimelineItem } from '../../../../../common/'; -import { useAddToCase } from '../../../../hooks/use_add_to_case'; +import { useAddToCase, normalizedEventFields } from '../../../../hooks/use_add_to_case'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { TimelinesStartServices } from '../../../../types'; import { CreateCaseFlyout } from './create/flyout'; @@ -38,7 +38,6 @@ const AddToCaseActionComponent: React.FC = ({ }) => { const eventId = event?.ecs._id ?? ''; const eventIndex = event?.ecs._index ?? ''; - const rule = event?.ecs.signal?.rule; const dispatch = useDispatch(); const { cases } = useKibana().services; const { @@ -52,13 +51,14 @@ const AddToCaseActionComponent: React.FC = ({ } = useAddToCase({ event, useInsertTimeline, casePermissions, appId, onClose }); const getAllCasesSelectorModalProps = useMemo(() => { + const { ruleId, ruleName } = normalizedEventFields(event); return { alertData: { alertId: eventId, index: eventIndex ?? '', rule: { - id: rule?.id != null ? rule.id[0] : null, - name: rule?.name != null ? rule.name[0] : null, + id: ruleId, + name: ruleName, }, owner: appId, }, @@ -85,11 +85,10 @@ const AddToCaseActionComponent: React.FC = ({ goToCreateCase, eventId, eventIndex, - rule?.id, - rule?.name, appId, dispatch, useInsertTimeline, + event, ]); const closeCaseFlyoutOpen = useCallback(() => { diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.test.tsx index e263be11c0dcc..c5aba4506f39d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.test.tsx @@ -30,6 +30,8 @@ describe('checkbox control column', () => { rowIndex: 1, showNotes: true, timelineId: 'test-timelineId', + setEventsLoading: jest.fn(), + setEventsDeleted: jest.fn(), }; test('displays loader when id is included on loadingEventIds', () => { const { getByTestId } = render( diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx index e8459fa99d8c8..be7114be67b04 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx @@ -49,6 +49,8 @@ describe('Columns', () => { onRowSelected={jest.fn()} leadingControlColumns={[]} trailingControlColumns={[]} + setEventsLoading={jest.fn()} + setEventsDeleted={jest.fn()} /> ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx index 216e97d02a32a..597968679bd95 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -22,7 +22,11 @@ import { import { StatefulCell } from './stateful_cell'; import * as i18n from './translations'; -import { TimelineTabs } from '../../../../../common/types/timeline'; +import { + SetEventsDeleted, + SetEventsLoading, + TimelineTabs, +} from '../../../../../common/types/timeline'; import type { ActionProps, CellValueElementProps, @@ -69,6 +73,8 @@ interface DataDrivenColumnProps { timelineId: string; trailingControlColumns: ControlColumnProps[]; leadingControlColumns: ControlColumnProps[]; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; } const SPACE = ' '; @@ -140,6 +146,8 @@ const TgridActionTdCell = ({ tabType, timelineId, toggleShowNotes, + setEventsLoading, + setEventsDeleted, }: ActionProps & { columnId: string; hasRowRenderers: boolean; @@ -189,6 +197,8 @@ const TgridActionTdCell = ({ showNotes={showNotes} timelineId={timelineId} toggleShowNotes={toggleShowNotes} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> )} @@ -272,6 +282,8 @@ export const DataDrivenColumns = React.memo( timelineId, trailingControlColumns, leadingControlColumns, + setEventsLoading, + setEventsDeleted, }) => { const trailingActionCells = useMemo( () => @@ -312,6 +324,8 @@ export const DataDrivenColumns = React.memo( selectedEventIds={selectedEventIds} tabType={tabType} timelineId={timelineId} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> ) ); @@ -336,6 +350,8 @@ export const DataDrivenColumns = React.memo( tabType, timelineId, trailingActionCells, + setEventsLoading, + setEventsDeleted, ] ); const ColumnHeaders = useMemo( diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx index 23a66c9e18f7d..886c84dd32fb9 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx @@ -60,6 +60,8 @@ describe('EventColumnView', () => { isEventPinned: false, leadingControlColumns: [], trailingControlColumns: [], + setEventsLoading: jest.fn(), + setEventsDeleted: jest.fn(), }; // TODO: next 3 tests will be re-enabled in the future. diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx index aacb8dab32830..31123476a9a38 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -16,6 +16,8 @@ import type { ColumnHeaderOptions, ControlColumnProps, RowCellRender, + SetEventsDeleted, + SetEventsLoading, } from '../../../../../common/types/timeline'; import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; import type { Ecs } from '../../../../../common/ecs'; @@ -40,6 +42,8 @@ interface Props { timelineId: string; leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; } export const EventColumnView = React.memo( @@ -63,6 +67,8 @@ export const EventColumnView = React.memo( timelineId, leadingControlColumns, trailingControlColumns, + setEventsLoading, + setEventsDeleted, }) => { // Each action button shall announce itself to screen readers via an `aria-label` // in the following format: @@ -120,6 +126,8 @@ export const EventColumnView = React.memo( onRuleChange={onRuleChange} tabType={tabType} timelineId={timelineId} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> )} @@ -143,6 +151,8 @@ export const EventColumnView = React.memo( showCheckboxes, tabType, timelineId, + setEventsLoading, + setEventsDeleted, ] ); return ( @@ -171,6 +181,8 @@ export const EventColumnView = React.memo( isEventViewer={isEventViewer} onRuleChange={onRuleChange} selectedEventIds={selectedEventIds} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx index cdd4a0a46656f..a5bc438ab251b 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx @@ -18,7 +18,11 @@ import { StatefulRowRenderer } from './stateful_row_renderer'; import { getMappedNonEcsValue } from '../data_driven_columns'; import { StatefulEventContext } from './stateful_event_context'; import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; -import { TimelineTabs } from '../../../../../common/types/timeline'; +import { + SetEventsDeleted, + SetEventsLoading, + TimelineTabs, +} from '../../../../../common/types/timeline'; import type { CellValueElementProps, ColumnHeaderOptions, @@ -137,6 +141,20 @@ const StatefulEventComponent: React.FC = ({ ); }, [dispatch, event._id, event._index, tabType, timelineId]); + const setEventsLoading = useCallback( + ({ eventIds, isLoading }) => { + dispatch(tGridActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); + }, + [dispatch, timelineId] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }) => { + dispatch(tGridActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); + }, + [dispatch, timelineId] + ); + const RowRendererContent = useMemo( () => ( @@ -195,6 +213,8 @@ const StatefulEventComponent: React.FC = ({ timelineId={timelineId} leadingControlColumns={leadingControlColumns} trailingControlColumns={trailingControlColumns} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} />
{RowRendererContent}
diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx index 4c1c2635004c2..2ab5a86fa7ddd 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx @@ -84,8 +84,8 @@ describe('Body', () => { trailingControlColumns: [], filterStatus: 'open', filterQuery: '', - indexNames: [''], refetch: jest.fn(), + indexNames: [''], }; describe('rendering', () => { diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index b25c8f49acbac..12a0f6bfc2b64 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -43,6 +43,8 @@ import { SortColumnTimeline, TimelineId, TimelineTabs, + SetEventsLoading, + SetEventsDeleted, } from '../../../../common/types/timeline'; import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; @@ -138,6 +140,8 @@ const transformControlColumns = ({ browserFields, sort, theme, + setEventsLoading, + setEventsDeleted, }: { actionColumnsWidth: number; columnHeaders: ColumnHeaderOptions[]; @@ -156,6 +160,8 @@ const transformControlColumns = ({ onSelectPage: OnSelectAll; sort: SortColumnTimeline[]; theme: EuiTheme; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; }): EuiDataGridControlColumn[] => controlColumns.map( ({ id: columnId, headerCellRender = EmptyHeaderCellRender, rowCellRender, width }, i) => ({ @@ -215,6 +221,8 @@ const transformControlColumns = ({ tabType={tabType} timelineId={timelineId} width={width ?? MIN_ACTION_COLUMN_WIDTH} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> ); }, @@ -485,6 +493,20 @@ export const BodyComponent = React.memo( setVisibleColumns(columnHeaders.map(({ id: cid }) => cid)); }, [columnHeaders]); + const setEventsLoading = useCallback( + ({ eventIds, isLoading }) => { + dispatch(tGridActions.setEventsLoading({ id, eventIds, isLoading })); + }, + [dispatch, id] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }) => { + dispatch(tGridActions.setEventsDeleted({ id, eventIds, isDeleted })); + }, + [dispatch, id] + ); + const [leadingTGridControlColumns, trailingTGridControlColumns] = useMemo(() => { return [ showCheckboxes ? [checkBoxControlColumn, ...leadingControlColumns] : leadingControlColumns, @@ -514,6 +536,8 @@ export const BodyComponent = React.memo( browserFields, onSelectPage, theme, + setEventsLoading, + setEventsDeleted, }) ); }, [ @@ -534,6 +558,8 @@ export const BodyComponent = React.memo( onSelectPage, sort, theme, + setEventsLoading, + setEventsDeleted, ]); const columnsWithCellActions: EuiDataGridColumn[] = useMemo( diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx index 001578b01f09f..da3152509f5cf 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx @@ -10,13 +10,15 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; -import { +import type { ColumnHeaderOptions, ControlColumnProps, OnRowSelected, + SetEventsLoading, + SetEventsDeleted, TimelineExpandedDetailType, - TimelineTabs, } from '../../../../../common/types/timeline'; +import { TimelineTabs } from '../../../../../common/types/timeline'; import { getMappedNonEcsValue } from '../data_driven_columns'; import { tGridActions } from '../../../../store/t_grid'; @@ -34,6 +36,8 @@ type Props = EuiDataGridCellValueElementProps & { tabType?: TimelineTabs; timelineId: string; width: number; + setEventsLoading: SetEventsLoading; + setEventsDeleted: SetEventsDeleted; }; const RowActionComponent = ({ @@ -50,6 +54,8 @@ const RowActionComponent = ({ showCheckboxes, tabType, timelineId, + setEventsLoading, + setEventsDeleted, width, }: Props) => { const { data: timelineNonEcsData, ecs: ecsData, _id: eventId, _index: indexName } = useMemo( @@ -120,6 +126,8 @@ const RowActionComponent = ({ tabType={tabType} timelineId={timelineId} width={width} + setEventsLoading={setEventsLoading} + setEventsDeleted={setEventsDeleted} /> )} diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index 1befc8cacf984..9062e8b14228d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -232,6 +232,7 @@ const TGridIntegratedComponent: React.FC = ({ loading, { events, loadPage, pageInfo, refetch, totalCount = 0, inspect }, ] = useTimelineEvents({ + // We rely on entityType to determine Events vs Alerts alertConsumers: SECURITY_ALERTS_CONSUMERS, docValueFields, entityType, @@ -280,7 +281,13 @@ const TGridIntegratedComponent: React.FC = ({ return ( - + {loading && } {canQueryTimeline ? ( diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 29d70531a2a5b..9867cd834802e 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ import type { AlertConsumers } from '@kbn/rule-data-utils'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; @@ -53,18 +53,9 @@ const TitleText = styled.span` margin-right: 12px; `; -const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>` - display: flex; - flex-direction: column; - - ${({ $isFullScreen }) => - $isFullScreen && - ` - border: 0; - box-shadow: none; - padding-top: 0; - padding-bottom: 0; - `} +const AlertsTableWrapper = styled.div` + width: 100%; + height: 100%; `; const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({ @@ -315,8 +306,8 @@ const TGridStandaloneComponent: React.FC = ({ }, []); return ( - - + + {canQueryTimeline ? ( <> = ({ ) : null} - + ); }; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx index a2e91734d6f05..e4ccf1b72529f 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_status_bulk_actions.tsx @@ -9,19 +9,15 @@ import React, { useCallback, useEffect, useState } from 'react'; import { connect, ConnectedProps, useDispatch } from 'react-redux'; import type { AlertStatus, - OnAlertStatusActionSuccess, - OnAlertStatusActionFailure, + SetEventsLoading, + SetEventsDeleted, + OnUpdateAlertStatusSuccess, + OnUpdateAlertStatusError, } from '../../../../../common'; import type { Refetch } from '../../../../store/t_grid/inputs'; import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../../store/t_grid'; import { BulkActions } from './'; -import { useAppToasts } from '../../../../hooks/use_app_toasts'; -import * as i18n from '../../translations'; -import { - SetEventsDeletedProps, - SetEventsLoadingProps, - useStatusBulkActionItems, -} from '../../../../hooks/use_status_bulk_action_items'; +import { useStatusBulkActionItems } from '../../../../hooks/use_status_bulk_action_items'; interface OwnProps { id: string; @@ -29,8 +25,8 @@ interface OwnProps { filterStatus?: AlertStatus; query: string; indexName: string; - onActionSuccess?: OnAlertStatusActionSuccess; - onActionFailure?: OnAlertStatusActionFailure; + onActionSuccess?: OnUpdateAlertStatusSuccess; + onActionFailure?: OnUpdateAlertStatusError; refetch: Refetch; } @@ -54,7 +50,6 @@ export const AlertStatusBulkActionsComponent = React.memo { const dispatch = useDispatch(); - const { addSuccess, addError, addWarning } = useAppToasts(); const [showClearSelection, setShowClearSelection] = useState(false); @@ -82,67 +77,35 @@ export const AlertStatusBulkActionsComponent = React.memo { - if (conflicts > 0) { - // Partial failure - addWarning({ - title: i18n.UPDATE_ALERT_STATUS_FAILED(conflicts), - text: i18n.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), - }); - } else { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); - } - addSuccess({ title }); - } refetch(); if (onActionSuccess) { - onActionSuccess(newStatus); + onActionSuccess(updated, conflicts, newStatus); } }, - [addSuccess, addWarning, onActionSuccess, refetch] + [refetch, onActionSuccess] ); - const onAlertStatusUpdateFailure = useCallback( + const onUpdateFailure = useCallback( (newStatus: AlertStatus, error: Error) => { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_FAILED_TOAST; - break; - case 'open': - title = i18n.OPENED_ALERT_FAILED_TOAST; - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; - } - addError(error.message, { title }); refetch(); if (onActionFailure) { - onActionFailure(newStatus, error.message); + onActionFailure(newStatus, error); } }, - [addError, onActionFailure, refetch] + [refetch, onActionFailure] ); - const setEventsLoading = useCallback( - ({ eventIds, isLoading }: SetEventsLoadingProps) => { + const setEventsLoading = useCallback( + ({ eventIds, isLoading }) => { dispatch(tGridActions.setEventsLoading({ id, eventIds, isLoading })); }, [dispatch, id] ); - const setEventsDeleted = useCallback( - ({ eventIds, isDeleted }: SetEventsDeletedProps) => { + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }) => { dispatch(tGridActions.setEventsDeleted({ id, eventIds, isDeleted })); }, [dispatch, id] @@ -155,8 +118,8 @@ export const AlertStatusBulkActionsComponent = React.memo { const eventId = event?.ecs._id ?? ''; const eventIndex = event?.ecs._index ?? ''; - const rule = event?.ecs.signal?.rule; const dispatch = useDispatch(); // TODO: use correct value in standalone or integrated. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -154,6 +155,7 @@ export const useAddToCase = ({ updateCase?: (newCase: Case) => void ) => { dispatch(tGridActions.setOpenAddToNewCase({ id: eventId, isOpen: false })); + const { ruleId, ruleName } = normalizedEventFields(event); if (postComment) { await postComment({ caseId: theCase.id, @@ -162,8 +164,8 @@ export const useAddToCase = ({ alertId: eventId, index: eventIndex ?? '', rule: { - id: rule?.id != null ? rule.id[0] : null, - name: rule?.name != null ? rule.name[0] : null, + id: ruleId, + name: ruleName, }, owner: appId, }, @@ -171,7 +173,7 @@ export const useAddToCase = ({ }); } }, - [eventId, eventIndex, rule, appId, dispatch] + [eventId, eventIndex, appId, dispatch, event] ); const onCaseSuccess = useCallback( async (theCase: Case) => { @@ -239,3 +241,17 @@ export const useAddToCase = ({ isCreateCaseFlyoutOpen, }; }; + +export function normalizedEventFields(event?: TimelineItem) { + const ruleId = event && event.data.find(({ field }) => field === ALERT_RULE_ID); + const ruleUuid = event && event.data.find(({ field }) => field === ALERT_RULE_UUID); + const ruleName = event && event.data.find(({ field }) => field === ALERT_RULE_NAME); + const ruleIdValue = ruleId && ruleId.value && ruleId.value[0]; + const ruleUuidValue = ruleUuid && ruleUuid.value && ruleUuid.value[0]; + const ruleNameValue = ruleName && ruleName.value && ruleName.value[0]; + const idToUse = ruleIdValue ? ruleIdValue : ruleUuidValue; + return { + ruleId: idToUse ?? null, + ruleName: ruleNameValue ?? null, + }; +} diff --git a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx index 91d4be96199fc..fd8880ca468b6 100644 --- a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx +++ b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx @@ -9,29 +9,9 @@ import React, { useMemo, useCallback } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; import { FILTER_CLOSED, FILTER_IN_PROGRESS, FILTER_OPEN } from '../../common/constants'; import * as i18n from '../components/t_grid/translations'; -import type { AlertStatus } from '../../common/types/timeline'; +import type { AlertStatus, StatusBulkActionsProps } from '../../common/types/timeline'; import { useUpdateAlertsStatus } from '../container/use_update_alerts'; - -export interface SetEventsLoadingProps { - eventIds: string[]; - isLoading: boolean; -} - -export interface SetEventsDeletedProps { - eventIds: string[]; - isDeleted: boolean; -} - -export interface StatusBulkActionsProps { - eventIds: string[]; - currentStatus?: AlertStatus; - query?: string; - indexName: string; - setEventsLoading: (param: SetEventsLoadingProps) => void; - setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; - onUpdateSuccess: (updated: number, conflicts: number, status: AlertStatus) => void; - onUpdateFailure: (status: AlertStatus, error: Error) => void; -} +import { useAppToasts } from './use_app_toasts'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { bool: { filter: { terms: { _id: eventIds } } } }; @@ -48,6 +28,57 @@ export const useStatusBulkActionItems = ({ onUpdateFailure, }: StatusBulkActionsProps) => { const { updateAlertStatus } = useUpdateAlertsStatus(); + const { addSuccess, addError, addWarning } = useAppToasts(); + + const onAlertStatusUpdateSuccess = useCallback( + (updated: number, conflicts: number, newStatus: AlertStatus) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18n.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18n.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + addSuccess({ title }); + } + if (onUpdateSuccess) { + onUpdateSuccess(updated, conflicts, newStatus); + } + }, + [addSuccess, addWarning, onUpdateSuccess] + ); + + const onAlertStatusUpdateFailure = useCallback( + (newStatus: AlertStatus, error: Error) => { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_FAILED_TOAST; + break; + case 'open': + title = i18n.OPENED_ALERT_FAILED_TOAST; + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; + } + addError(error.message, { title }); + if (onUpdateFailure) { + onUpdateFailure(newStatus, error); + } + }, + [addError, onUpdateFailure] + ); const onClickUpdate = useCallback( async (status: AlertStatus) => { @@ -67,9 +98,9 @@ export const useStatusBulkActionItems = ({ throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT); } - onUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0, status); + onAlertStatusUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0, status); } catch (error) { - onUpdateFailure(status, error); + onAlertStatusUpdateFailure(status, error); } finally { setEventsLoading({ eventIds, isLoading: false }); } @@ -81,8 +112,8 @@ export const useStatusBulkActionItems = ({ indexName, query, setEventsDeleted, - onUpdateSuccess, - onUpdateFailure, + onAlertStatusUpdateSuccess, + onAlertStatusUpdateFailure, ] ); @@ -94,6 +125,7 @@ export const useStatusBulkActionItems = ({ key="open" data-test-subj="open-alert-status" onClick={() => onClickUpdate(FILTER_OPEN)} + size="s" > {i18n.BULK_ACTION_OPEN_SELECTED} @@ -105,6 +137,7 @@ export const useStatusBulkActionItems = ({ key="progress" data-test-subj="in-progress-alert-status" onClick={() => onClickUpdate(FILTER_IN_PROGRESS)} + size="s" > {i18n.BULK_ACTION_IN_PROGRESS_SELECTED} @@ -116,6 +149,7 @@ export const useStatusBulkActionItems = ({ key="close" data-test-subj="close-alert-status" onClick={() => onClickUpdate(FILTER_CLOSED)} + size="s" > {i18n.BULK_ACTION_CLOSE_SELECTED} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx index eddfca5ecae29..4a9e8bcfc6b8d 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_pivot_editor/advanced_pivot_editor.tsx @@ -8,10 +8,12 @@ import { isEqual } from 'lodash'; import React, { memo, FC } from 'react'; -import { EuiCodeEditor, EuiFormRow } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../../../src/plugins/kibana_react/public'; + import { StepDefineFormHook } from '../step_define'; export const AdvancedPivotEditor: FC = memo( @@ -25,14 +27,11 @@ export const AdvancedPivotEditor: FC label={i18n.translate('xpack.transform.stepDefineForm.advancedEditorLabel', { defaultMessage: 'Pivot configuration object', })} + data-test-subj="transformAdvancedPivotEditor" > - { setAdvancedEditorConfig(d); @@ -51,13 +50,21 @@ export const AdvancedPivotEditor: FC setAdvancedPivotEditorApplyButtonEnabled(false); } }} - setOptions={{ - fontSize: '12px', + options={{ + ariaLabel: i18n.translate('xpack.transform.stepDefineForm.advancedEditorAriaLabel', { + defaultMessage: 'Advanced pivot editor', + }), + automaticLayout: true, + fontSize: 12, + scrollBeyondLastLine: false, + quickSuggestions: true, + minimap: { + enabled: false, + }, + wordWrap: 'on', + wrappingIndent: 'indent', }} - theme="textmate" - aria-label={i18n.translate('xpack.transform.stepDefineForm.advancedEditorAriaLabel', { - defaultMessage: 'Advanced pivot editor', - })} + value={advancedEditorConfig} /> ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_editor/advanced_runtime_mappings_editor.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_editor/advanced_runtime_mappings_editor.tsx index 1e6e6a971a81a..cad258d192061 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_editor/advanced_runtime_mappings_editor.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_editor/advanced_runtime_mappings_editor.tsx @@ -8,10 +8,10 @@ import { isEqual } from 'lodash'; import React, { memo, FC } from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../../../src/plugins/kibana_react/public'; + import { isRuntimeMappings } from '../../../../../../common/shared_imports'; import { StepDefineFormHook } from '../step_define'; @@ -26,42 +26,50 @@ export const AdvancedRuntimeMappingsEditor: FC { return ( - { - setAdvancedRuntimeMappingsConfig(d); +
+ { + setAdvancedRuntimeMappingsConfig(d); - // Disable the "Apply"-Button if the config hasn't changed. - if (advancedEditorRuntimeMappingsLastApplied === d) { - setRuntimeMappingsEditorApplyButtonEnabled(false); - return; - } + // Disable the "Apply"-Button if the config hasn't changed. + if (advancedEditorRuntimeMappingsLastApplied === d) { + setRuntimeMappingsEditorApplyButtonEnabled(false); + return; + } - // Try to parse the string passed on from the editor. - // If parsing fails, the "Apply"-Button will be disabled - try { - // if the user deletes the json in the editor - // they should still be able to apply changes - const isEmptyStr = d === ''; - const parsedJson = isEmptyStr ? {} : JSON.parse(convertToJson(d)); - setRuntimeMappingsEditorApplyButtonEnabled(isEmptyStr || isRuntimeMappings(parsedJson)); - } catch (e) { - setRuntimeMappingsEditorApplyButtonEnabled(false); - } - }} - setOptions={{ - fontSize: '12px', - }} - theme="textmate" - aria-label={i18n.translate('xpack.transform.stepDefineForm.advancedEditorAriaLabel', { - defaultMessage: 'Advanced pivot editor', - })} - /> + // Try to parse the string passed on from the editor. + // If parsing fails, the "Apply"-Button will be disabled + try { + // if the user deletes the json in the editor + // they should still be able to apply changes + const isEmptyStr = d === ''; + const parsedJson = isEmptyStr ? {} : JSON.parse(convertToJson(d)); + setRuntimeMappingsEditorApplyButtonEnabled( + isEmptyStr || isRuntimeMappings(parsedJson) + ); + } catch (e) { + setRuntimeMappingsEditorApplyButtonEnabled(false); + } + }} + options={{ + ariaLabel: i18n.translate('xpack.transform.stepDefineForm.advancedEditorAriaLabel', { + defaultMessage: 'Advanced pivot editor', + }), + automaticLayout: true, + fontSize: 12, + scrollBeyondLastLine: false, + quickSuggestions: true, + minimap: { + enabled: false, + }, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + value={advancedRuntimeMappingsConfig} + /> +
); }, (prevProps, nextProps) => isEqual(pickProps(prevProps), pickProps(nextProps)) diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx index 1c7c58be48be6..b711a5a0cbb81 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_source_editor/advanced_source_editor.tsx @@ -7,10 +7,10 @@ import React, { FC } from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../../../src/plugins/kibana_react/public'; + import { StepDefineFormHook } from '../step_define'; export const AdvancedSourceEditor: FC = ({ @@ -23,38 +23,48 @@ export const AdvancedSourceEditor: FC = ({ }, }) => { return ( - { - setSearchString(undefined); - setAdvancedEditorSourceConfig(d); +
+ { + setSearchString(undefined); + setAdvancedEditorSourceConfig(d); - // Disable the "Apply"-Button if the config hasn't changed. - if (advancedEditorSourceConfigLastApplied === d) { - setAdvancedSourceEditorApplyButtonEnabled(false); - return; - } + // Disable the "Apply"-Button if the config hasn't changed. + if (advancedEditorSourceConfigLastApplied === d) { + setAdvancedSourceEditorApplyButtonEnabled(false); + return; + } - // Try to parse the string passed on from the editor. - // If parsing fails, the "Apply"-Button will be disabled - try { - JSON.parse(d); - setAdvancedSourceEditorApplyButtonEnabled(true); - } catch (e) { - setAdvancedSourceEditorApplyButtonEnabled(false); - } - }} - setOptions={{ - fontSize: '12px', - }} - theme="textmate" - aria-label={i18n.translate('xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel', { - defaultMessage: 'Advanced query editor', - })} - /> + // Try to parse the string passed on from the editor. + // If parsing fails, the "Apply"-Button will be disabled + try { + JSON.parse(d); + setAdvancedSourceEditorApplyButtonEnabled(true); + } catch (e) { + setAdvancedSourceEditorApplyButtonEnabled(false); + } + }} + options={{ + ariaLabel: i18n.translate( + 'xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel', + { + defaultMessage: 'Advanced query editor', + } + ), + automaticLayout: true, + fontSize: 12, + scrollBeyondLastLine: false, + quickSuggestions: true, + minimap: { + enabled: false, + }, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + value={advancedEditorSourceConfig} + /> +
); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 831ee17371910..53f2716551289 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, - EuiCodeEditor, + EuiCodeBlock, EuiComboBox, EuiFieldText, EuiForm, @@ -326,16 +326,17 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha )} {isUnsupportedAgg && ( - + + {JSON.stringify(getEsAggFromAggConfig(defaultData), null, 2)} + )} = ({ defaultData, otherAggNames, onCha {isUnsupportedAgg && ( <> - + + {JSON.stringify(getEsAggFromGroupByConfig(defaultData), null, 2)} + )} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx index edb43afdd90d4..8a8c12dfc5583 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/editor_form.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; -import { EuiCodeEditor, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '../../../../../../../../../../../../src/plugins/kibana_react/public'; import { FilterAggConfigEditor } from '../types'; export const FilterEditorForm: FilterAggConfigEditor['aggTypeConfig']['FilterAggFormComponent'] = ({ @@ -16,15 +17,24 @@ export const FilterEditorForm: FilterAggConfigEditor['aggTypeConfig']['FilterAgg return ( <> - { onChange({ config: d }); }} - mode="json" - style={{ width: '100%' }} - theme="textmate" - height="300px" + options={{ + automaticLayout: true, + fontSize: 12, + scrollBeyondLastLine: false, + quickSuggestions: true, + minimap: { + enabled: false, + }, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + value={config || ''} /> ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap index dea6f57bcaab0..7a640389e2915 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap @@ -9,50 +9,53 @@ exports[`Transform: Transform List Expanded Row Minimal - + value= + { + "id": "fq_date_histogram_1m_1441", + "source": { + "index": [ + "farequote-2019" ], - \\"query\\": { - \\"match_all\\": {} + "query": { + "match_all": {} } }, - \\"dest\\": { - \\"index\\": \\"fq_date_histogram_1m_1441\\" + "dest": { + "index": "fq_date_histogram_1m_1441" }, - \\"pivot\\": { - \\"group_by\\": { - \\"@timestamp\\": { - \\"date_histogram\\": { - \\"field\\": \\"@timestamp\\", - \\"calendar_interval\\": \\"1m\\" + "pivot": { + "group_by": { + "@timestamp": { + "date_histogram": { + "field": "@timestamp", + "calendar_interval": "1m" } } }, - \\"aggregations\\": { - \\"responsetime.avg\\": { - \\"avg\\": { - \\"field\\": \\"responsetime\\" + "aggregations": { + "responsetime.avg": { + "avg": { + "field": "responsetime" } } } }, - \\"version\\": \\"8.0.0\\", - \\"create_time\\": 1564388146667 -}" - /> + "version": "8.0.0", + "create_time": 1564388146667 +} + = ({ json }) => { - + isCopyable + > + value={JSON.stringify(json, null, 2)} +   diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8829bc66d0fc7..40a26a7895789 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5499,21 +5499,13 @@ "xpack.apm.chart.memorySeries.systemAverageLabel": "平均", "xpack.apm.chart.memorySeries.systemMaxLabel": "最高", "xpack.apm.clearFilters": "フィルターを消去", - "xpack.apm.correlations.betaDescription": "相関関係がGAではありません。不具合が発生したら報告してください。", - "xpack.apm.correlations.betaLabel": "ベータ", - "xpack.apm.correlations.buttonLabel": "相関関係を表示", - "xpack.apm.correlations.clearFiltersLabel": "クリア", "xpack.apm.correlations.correlationsTable.actionsLabel": "フィルター", "xpack.apm.correlations.correlationsTable.excludeDescription": "値を除外", "xpack.apm.correlations.correlationsTable.excludeLabel": "除外", - "xpack.apm.correlations.correlationsTable.fieldNameLabel": "フィールド名", - "xpack.apm.correlations.correlationsTable.fieldValueLabel": "フィールド値", "xpack.apm.correlations.correlationsTable.filterDescription": "値でフィルタリング", "xpack.apm.correlations.correlationsTable.filterLabel": "フィルター", - "xpack.apm.correlations.correlationsTable.impactLabel": "インパクト", "xpack.apm.correlations.correlationsTable.loadingText": "読み込み中", "xpack.apm.correlations.correlationsTable.noDataText": "データなし", - "xpack.apm.correlations.correlationsTable.percentageLabel": "割合 (%) ", "xpack.apm.correlations.customize.buttonLabel": "フィールドのカスタマイズ", "xpack.apm.correlations.customize.fieldHelpText": "相関関係を分析するフィールドをカスタマイズまたは{reset}します。{docsLink}", "xpack.apm.correlations.customize.fieldHelpTextDocsLink": "デフォルトフィールドの詳細。", @@ -5522,21 +5514,7 @@ "xpack.apm.correlations.customize.fieldPlaceholder": "オプションを選択または作成", "xpack.apm.correlations.customize.thresholdLabel": "しきい値", "xpack.apm.correlations.customize.thresholdPercentile": "{percentile}パーセンタイル", - "xpack.apm.correlations.environmentLabel": "環境", - "xpack.apm.correlations.error.chart.overallErrorRateLabel": "全体のエラー率", - "xpack.apm.correlations.error.chart.selectedTermErrorRateLabel": "{fieldName}:{fieldValue}", - "xpack.apm.correlations.error.chart.title": "経時的なエラー率", - "xpack.apm.correlations.error.description": "一部のトランザクションが失敗してエラーが返される理由。相関関係は、データの特定のコホートで想定される原因を検出するのに役立ちます。ホスト、バージョン、または他のカスタムフィールドのいずれか。", - "xpack.apm.correlations.error.percentageColumnName": "失敗したトランザクションの%", - "xpack.apm.correlations.filteringByLabel": "フィルタリング条件", - "xpack.apm.correlations.latency.chart.numberOfTransactionsLabel": "# トランザクション", - "xpack.apm.correlations.latency.chart.overallLatencyDistributionLabel": "全体のレイテンシ分布", - "xpack.apm.correlations.latency.chart.selectedTermLatencyDistributionLabel": "{fieldName}:{fieldValue}", - "xpack.apm.correlations.latency.chart.title": "レイテンシ分布", - "xpack.apm.correlations.latency.description": "サービスが低速になっている原因。相関関係は、データの特定のコホートにあるパフォーマンス低下を特定するのに役立ちます。ホスト、バージョン、または他のカスタムフィールドのいずれか。", - "xpack.apm.correlations.latency.percentageColumnName": "低速なトランザクションの%", "xpack.apm.correlations.latencyCorrelations.cancelButtonTitle": "キャンセル", - "xpack.apm.correlations.latencyCorrelations.chartTitle": "{name}の遅延分布", "xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel": "フィルター", "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription": "サービスの遅延に対するフィールドの影響。0~1の範囲。", "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel": "相関関係", @@ -5551,12 +5529,6 @@ "xpack.apm.correlations.latencyCorrelations.progressAriaLabel": "進捗", "xpack.apm.correlations.latencyCorrelations.progressTitle": "進捗状況: {progress}%", "xpack.apm.correlations.latencyCorrelations.refreshButtonTitle": "更新", - "xpack.apm.correlations.licenseCheckText": "相関関係を使用するには、Elastic Platinumライセンスのサブスクリプションが必要です。使用すると、パフォーマンスの低下に関連しているフィールドを検出できます。", - "xpack.apm.correlations.serviceLabel": "サービス", - "xpack.apm.correlations.tabs.errorRateLabel": "エラー率", - "xpack.apm.correlations.tabs.latencyLabel": "レイテンシ", - "xpack.apm.correlations.title": "相関関係", - "xpack.apm.correlations.transactionLabel": "トランザクション", "xpack.apm.csm.breakdownFilter.browser": "ブラウザー", "xpack.apm.csm.breakdownFilter.device": "デバイス", "xpack.apm.csm.breakdownFilter.location": "場所", @@ -6049,7 +6021,6 @@ "xpack.apm.transactionCardinalityWarning.body": "一意のトランザクション名の数が構成された値{bucketSize}を超えています。エージェントを再構成し、類似したトランザクションをグループ化するか、{codeBlock}の値を増やしてください。", "xpack.apm.transactionCardinalityWarning.docsLink": "詳細はドキュメントをご覧ください", "xpack.apm.transactionCardinalityWarning.title": "このビューには、報告されたトランザクションのサブセットが表示されます。", - "xpack.apm.transactionDetails.notFoundLabel": "トランザクションが見つかりませんでした。", "xpack.apm.transactionDetails.noTraceParentButtonTooltip": "トレースの親が見つかりませんでした", "xpack.apm.transactionDetails.percentOfTraceLabelExplanation": "{parentType, select, transaction {トランザクション} trace {トレース} }の割合が100%を超えています。これは、この{childType, select, span {スパン} transaction {トランザクション} }がルートトランザクションよりも時間がかかるためです。", "xpack.apm.transactionDetails.requestMethodLabel": "リクエストメソッド", @@ -6072,11 +6043,6 @@ "xpack.apm.transactionDetails.traceNotFound": "選択されたトレースが見つかりません", "xpack.apm.transactionDetails.traceSampleTitle": "トレースのサンプル", "xpack.apm.transactionDetails.transactionLabel": "トランザクション", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSamplesAvailable": "サンプルがありません", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel": "{transCount} 件のトランザクション", - "xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle": "レイテンシ分布", - "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingDescription": "各バケットはサンプルトランザクションを示します。利用可能なサンプルがない場合、おそらくエージェントの構成で設定されたサンプリング制限が原因です。", - "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingLabel": "サンプリング", "xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage": "このトランザクションを報告した APM エージェントが、構成に基づき {dropped} 個以上のスパンをドロップしました。", "xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText": "ドロップされたスパンの詳細。", "xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle": "トランザクションの詳細", @@ -11302,7 +11268,6 @@ "xpack.indexLifecycleMgmt.editPolicy.deletePhase.waitForSnapshotTitle": "スナップショットポリシーを待機", "xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError": "ポリシー名は異なるものである必要があります。", "xpack.indexLifecycleMgmt.editPolicy.documentationLinkText": "ドキュメント", - "xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyExplanationMessage": "すべての変更はこのポリシーに関連付けられているインデックスに影響を及ぼします。代わりに、これらの変更を新規ポリシーに保存することもできます。", "xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyMessage": "既存のポリシーを編集しています", "xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage": "ポリシー{originalPolicyName}の編集", "xpack.indexLifecycleMgmt.editPolicy.errors.integerRequiredError": "整数のみを使用できます。", @@ -11330,7 +11295,6 @@ "xpack.indexLifecycleMgmt.editPolicy.fullyMountedSearchableSnapshotField.description": "データの完全なコピーを含み、スナップショットでバックアップされる完全にマウントされたインデックスに変換します。レプリカ数を減らし、スナップショットにより障害回復力を実現できます。{learnMoreLink}", "xpack.indexLifecycleMgmt.editPolicy.fullyMountedSearchableSnapshotField.title": "検索可能スナップショット", "xpack.indexLifecycleMgmt.editPolicy.fullyMountedSearchableSnapshotField.toggleLabel": "完全にマウントされたインデックスに変換", - "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "リクエストを非表示", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription": "最新の最も検索頻度が高いデータをホットティアに格納します。ホットティアでは、最も強力で高価なハードウェアを使用して、最高のインデックスおよび検索パフォーマンスを実現します。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseTitle": "ホットフェーズ", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "詳細", @@ -11408,7 +11372,6 @@ "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError": "スナップショットリポジトリ名が必要です。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotStorageFieldLabel": "検索可能スナップショットストレージ", "xpack.indexLifecycleMgmt.editPolicy.secondsOptionLabel": "秒", - "xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto": "リクエストを表示", "xpack.indexLifecycleMgmt.editPolicy.shrinkIndexExplanationText": "インデックス情報をプライマリシャードの少ない新規インデックスに縮小します。", "xpack.indexLifecycleMgmt.editPolicy.shrinkText": "縮小", "xpack.indexLifecycleMgmt.editPolicy.successfulSaveMessage": "ライフサイクルポリシー「{lifecycleName}」を{verb}", @@ -18916,7 +18879,6 @@ "xpack.reporting.diagnostic.browserErrored": "ブラウザープロセスは起動中にエラーが発生しました", "xpack.reporting.diagnostic.browserMissingDependency": "システム依存関係が不足しているため、ブラウザーを正常に起動できませんでした。{url}を参照してください", "xpack.reporting.diagnostic.browserMissingFonts": "ブラウザーはデフォルトフォントを検索できませんでした。この問題を修正するには、{url}を参照してください。", - "xpack.reporting.diagnostic.configSizeMismatch": "xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) はElasticSearchの{ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes}) を超えています。ElasticSearchで一致する{ES_MAX_SIZE_BYTES_PATH}を設定してください。あるいは、Kibanaでxpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH}を低くしてください。", "xpack.reporting.diagnostic.noUsableSandbox": "Chromiumサンドボックスを使用できません。これは「xpack.reporting.capture.browser.chromium.disableSandbox」で無効にすることができます。この作業はご自身の責任で行ってください。{url}を参照してください", "xpack.reporting.diagnostic.screenshotFailureMessage": "Kibanaインストールのスクリーンショットを作成できませんでした。", "xpack.reporting.exportTypes.common.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました。{encryptionKey}が設定されていることを確認してこのレポートを再生成してください。{err}", @@ -18939,9 +18901,6 @@ "xpack.reporting.listing.diagnosticBrowserMessage": "レポートはヘッドレスブラウザーを使用して、PDFとPNGを生成します。ブラウザーを正常に起動できることを確認してください。", "xpack.reporting.listing.diagnosticBrowserTitle": "ブラウザーを確認", "xpack.reporting.listing.diagnosticButton": "レポート診断を実行", - "xpack.reporting.listing.diagnosticConfigButton": "構成を検証", - "xpack.reporting.listing.diagnosticConfigMessage": "Kibana構成がレポート用に正しく設定されていることを確認してください。", - "xpack.reporting.listing.diagnosticConfigTitle": "Kibana構成を検証", "xpack.reporting.listing.diagnosticDescription": "診断を実行し、一般的なレポートの問題を自動的にトラブルシューティングします。", "xpack.reporting.listing.diagnosticFailureDescription": "次に、一部の問題の詳細を示します。", "xpack.reporting.listing.diagnosticFailureTitle": "正常に動作していない項目があります。", @@ -20140,7 +20099,6 @@ "xpack.securitySolution.cases.pageTitle": "ケース", "xpack.securitySolution.certificate.fingerprint.clientCertLabel": "クライアント証明書", "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "サーバー証明書", - "xpack.securitySolution.chart.allOthersGroupingLabel": "その他すべて", "xpack.securitySolution.chart.dataAllValuesZerosTitle": "すべての値はゼロを返します", "xpack.securitySolution.chart.dataNotAvailableTitle": "チャートデータが利用できません", "xpack.securitySolution.chrome.help.appName": "セキュリティ", @@ -25175,7 +25133,6 @@ "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.tags.label": "タグ", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.tcp.hosts": "ホスト:ポート", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.tcp.hosts.error": "ホストとポートは必須です", - "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.timeout.error": "タイムアウトは0以上で、スケジュール間隔未満でなければなりません", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.timeout.helpText": "接続のテストとデータの交換に許可された合計時間。", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.timeout.label": "タイムアウト (秒) ", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.URL": "URL", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 95683e7e38885..172685f337150 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5524,21 +5524,13 @@ "xpack.apm.chart.memorySeries.systemAverageLabel": "平均值", "xpack.apm.chart.memorySeries.systemMaxLabel": "最大值", "xpack.apm.clearFilters": "清除筛选", - "xpack.apm.correlations.betaDescription": "相关性不是 GA 版。请通过报告错误来帮助我们。", - "xpack.apm.correlations.betaLabel": "公测版", - "xpack.apm.correlations.buttonLabel": "查看相关性", - "xpack.apm.correlations.clearFiltersLabel": "清除", "xpack.apm.correlations.correlationsTable.actionsLabel": "筛选", "xpack.apm.correlations.correlationsTable.excludeDescription": "筛除值", "xpack.apm.correlations.correlationsTable.excludeLabel": "排除", - "xpack.apm.correlations.correlationsTable.fieldNameLabel": "字段名称", - "xpack.apm.correlations.correlationsTable.fieldValueLabel": "字段值", "xpack.apm.correlations.correlationsTable.filterDescription": "按值筛选", "xpack.apm.correlations.correlationsTable.filterLabel": "筛选", - "xpack.apm.correlations.correlationsTable.impactLabel": "影响", "xpack.apm.correlations.correlationsTable.loadingText": "正在加载", "xpack.apm.correlations.correlationsTable.noDataText": "无数据", - "xpack.apm.correlations.correlationsTable.percentageLabel": "百分比", "xpack.apm.correlations.customize.buttonLabel": "定制字段", "xpack.apm.correlations.customize.fieldHelpText": "定制或{reset}要针对相关性分析的字段。{docsLink}", "xpack.apm.correlations.customize.fieldHelpTextDocsLink": "详细了解默认字段。", @@ -5547,21 +5539,7 @@ "xpack.apm.correlations.customize.fieldPlaceholder": "选择或创建选项", "xpack.apm.correlations.customize.thresholdLabel": "阈值", "xpack.apm.correlations.customize.thresholdPercentile": "第 {percentile} 个百分位数", - "xpack.apm.correlations.environmentLabel": "环境", - "xpack.apm.correlations.error.chart.overallErrorRateLabel": "总错误率", - "xpack.apm.correlations.error.chart.selectedTermErrorRateLabel": "{fieldName}:{fieldValue}", - "xpack.apm.correlations.error.chart.title": "时移错误率", - "xpack.apm.correlations.error.description": "为什么某些事务失败并返回错误?相关性将有助于在您数据的特定群组中发现可能的原因。按主机、版本或其他定制字段。", - "xpack.apm.correlations.error.percentageColumnName": "失败事务 %", - "xpack.apm.correlations.filteringByLabel": "筛选依据", - "xpack.apm.correlations.latency.chart.numberOfTransactionsLabel": "事务数", - "xpack.apm.correlations.latency.chart.overallLatencyDistributionLabel": "总体延迟分布", - "xpack.apm.correlations.latency.chart.selectedTermLatencyDistributionLabel": "{fieldName}:{fieldValue}", - "xpack.apm.correlations.latency.chart.title": "延迟分布", - "xpack.apm.correlations.latency.description": "什么在拖慢我的服务?相关性将有助于在您数据的特定群组中发现较慢的性能。按主机、版本或其他定制字段。", - "xpack.apm.correlations.latency.percentageColumnName": "缓慢事务 %", "xpack.apm.correlations.latencyCorrelations.cancelButtonTitle": "取消", - "xpack.apm.correlations.latencyCorrelations.chartTitle": "{name} 的延迟分布", "xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel": "筛选", "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription": "字段对服务延迟的影响,范围从 0 到 1。", "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel": "相关性", @@ -5576,12 +5554,6 @@ "xpack.apm.correlations.latencyCorrelations.progressAriaLabel": "进度", "xpack.apm.correlations.latencyCorrelations.progressTitle": "进度:{progress}%", "xpack.apm.correlations.latencyCorrelations.refreshButtonTitle": "刷新", - "xpack.apm.correlations.licenseCheckText": "要使用相关性,必须订阅 Elastic 白金级许可证。使用相关性,将能够发现哪些字段与性能差相关。", - "xpack.apm.correlations.serviceLabel": "服务", - "xpack.apm.correlations.tabs.errorRateLabel": "错误率", - "xpack.apm.correlations.tabs.latencyLabel": "延迟", - "xpack.apm.correlations.title": "相关性", - "xpack.apm.correlations.transactionLabel": "事务", "xpack.apm.csm.breakdownFilter.browser": "浏览器", "xpack.apm.csm.breakdownFilter.device": "设备", "xpack.apm.csm.breakdownFilter.location": "位置", @@ -6082,7 +6054,6 @@ "xpack.apm.transactionCardinalityWarning.title": "此视图显示已报告事务的子集。", "xpack.apm.transactionDetails.errorCount": "{errorCount, number} 个 {errorCount, plural, other {错误}}", "xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {查看 1 个相关错误} other {查看 # 个相关错误}}", - "xpack.apm.transactionDetails.notFoundLabel": "未找到任何事务。", "xpack.apm.transactionDetails.noTraceParentButtonTooltip": "找不到上级追溯", "xpack.apm.transactionDetails.percentOfTraceLabelExplanation": "{parentType, select, transaction {事务} trace {追溯} }的百分比超过 100%,因为此{childType, select, span {跨度} transaction {事务} }比根事务花费更长的时间。", "xpack.apm.transactionDetails.requestMethodLabel": "请求方法", @@ -6105,12 +6076,6 @@ "xpack.apm.transactionDetails.traceNotFound": "找不到所选跟踪", "xpack.apm.transactionDetails.traceSampleTitle": "跟踪样例", "xpack.apm.transactionDetails.transactionLabel": "事务", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSamplesAvailable": "没有可用样本", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel": "{transCount, plural, other {事务}}", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel": "{transCount} 个事务", - "xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle": "延迟分布", - "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingDescription": "每个存储桶将显示一个样例事务。如果没有可用的样例,很可能是在代理配置设置了采样限制。", - "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingLabel": "采样", "xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage": "报告此事务的 APM 代理基于其配置丢弃了 {dropped} 个跨度。", "xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText": "详细了解丢弃的跨度。", "xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle": "事务详情", @@ -11613,7 +11578,6 @@ "xpack.indexLifecycleMgmt.editPolicy.deletePhase.waitForSnapshotTitle": "等候快照策略", "xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError": "策略名称必须不同。", "xpack.indexLifecycleMgmt.editPolicy.documentationLinkText": "文档", - "xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyExplanationMessage": "所做的任何更改将影响附加到此策略的索引。或者,您可以在新策略中保存这些更改。", "xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyMessage": "您正在编辑现有策略", "xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage": "编辑策略 {originalPolicyName}", "xpack.indexLifecycleMgmt.editPolicy.errors.integerRequiredError": "仅允许使用整数。", @@ -11641,7 +11605,6 @@ "xpack.indexLifecycleMgmt.editPolicy.fullyMountedSearchableSnapshotField.description": "转换为完全安装的索引,其包含数据的完整副本,并由快照支持。您可以减少副本分片的数目并依赖快照的弹性。{learnMoreLink}", "xpack.indexLifecycleMgmt.editPolicy.fullyMountedSearchableSnapshotField.title": "可搜索快照", "xpack.indexLifecycleMgmt.editPolicy.fullyMountedSearchableSnapshotField.toggleLabel": "转换为完全安装的索引", - "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "隐藏请求", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription": "将最近的、搜索最频繁的数据存储在热层中。热层通过使用最强劲且价格不菲的硬件提供最佳的索引和搜索性能。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseTitle": "热阶段", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "了解详情", @@ -11719,7 +11682,6 @@ "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError": "快照存储库名称必填。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotStorageFieldLabel": "可搜索快照存储", "xpack.indexLifecycleMgmt.editPolicy.secondsOptionLabel": "秒", - "xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto": "显示请求", "xpack.indexLifecycleMgmt.editPolicy.shrinkIndexExplanationText": "将索引缩小成具有较少主分片的新索引。", "xpack.indexLifecycleMgmt.editPolicy.shrinkText": "缩小", "xpack.indexLifecycleMgmt.editPolicy.successfulSaveMessage": "{verb}生命周期策略“{lifecycleName}”", @@ -19333,7 +19295,6 @@ "xpack.reporting.diagnostic.browserErrored": "启动时浏览器进程引发了错误", "xpack.reporting.diagnostic.browserMissingDependency": "由于缺少系统依赖项,浏览器无法正常启动。请参见 {url}", "xpack.reporting.diagnostic.browserMissingFonts": "浏览器找不到默认字体。请参见 {url} 以解决此问题。", - "xpack.reporting.diagnostic.configSizeMismatch": "xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) 大于 ElasticSearch 的 {ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes})。请在 ElasticSearch 中将 {ES_MAX_SIZE_BYTES_PATH} 设置为匹配或减小 Kibana 中的 xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH}。", "xpack.reporting.diagnostic.noUsableSandbox": "无法使用 Chromium 沙盒。您自行承担使用“xpack.reporting.capture.browser.chromium.disableSandbox”禁用此项的风险。请参见 {url}", "xpack.reporting.diagnostic.screenshotFailureMessage": "我们无法拍摄 Kibana 安装的屏幕截图。", "xpack.reporting.exportTypes.common.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", @@ -19356,9 +19317,6 @@ "xpack.reporting.listing.diagnosticBrowserMessage": "报告使用无界面浏览器生成 PDF 和 PNG。验证浏览器是否可以成功启动。", "xpack.reporting.listing.diagnosticBrowserTitle": "检查浏览器", "xpack.reporting.listing.diagnosticButton": "运行报告诊断", - "xpack.reporting.listing.diagnosticConfigButton": "验证配置", - "xpack.reporting.listing.diagnosticConfigMessage": "确保是否为报告正确设置 Kibana 配置。", - "xpack.reporting.listing.diagnosticConfigTitle": "验证 Kibana 配置", "xpack.reporting.listing.diagnosticDescription": "运行诊断以自动解决常见报告问题。", "xpack.reporting.listing.diagnosticFailureDescription": "下面是一些有关该问题的详细信息:", "xpack.reporting.listing.diagnosticFailureTitle": "某些功能无法正常运行。", @@ -20585,7 +20543,6 @@ "xpack.securitySolution.cases.pageTitle": "案例", "xpack.securitySolution.certificate.fingerprint.clientCertLabel": "客户端证书", "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "服务器证书", - "xpack.securitySolution.chart.allOthersGroupingLabel": "所有其他", "xpack.securitySolution.chart.dataAllValuesZerosTitle": "所有值返回了零", "xpack.securitySolution.chart.dataNotAvailableTitle": "图表数据不可用", "xpack.securitySolution.chrome.help.appName": "安全", @@ -25730,7 +25687,6 @@ "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.tags.label": "标签", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.tcp.hosts": "主机:端口", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.tcp.hosts.error": "主机和端口必填", - "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.timeout.error": "超时必须等于或大于 0 且小于计划时间间隔", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.timeout.helpText": "允许用于测试连接并交换数据的总时间。", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.timeout.label": "超时(秒)", "xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.URL": "URL", diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index a302673c2ec08..4033889d9811e 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -1,5 +1,9 @@ { "id": "triggersActionsUi", + "owner": { + "name": "Kibana Alerting", + "githubTeam": "kibana-alerting-services" + }, "version": "kibana", "server": true, "ui": true, diff --git a/x-pack/plugins/ui_actions_enhanced/kibana.json b/x-pack/plugins/ui_actions_enhanced/kibana.json index dbc136a258884..6fcac67c5a66b 100644 --- a/x-pack/plugins/ui_actions_enhanced/kibana.json +++ b/x-pack/plugins/ui_actions_enhanced/kibana.json @@ -1,17 +1,13 @@ { "id": "uiActionsEnhanced", + "owner": { + "name": "Kibana App Services", + "githubTeam": "kibana-app-services" + }, "version": "kibana", "configPath": ["xpack", "ui_actions_enhanced"], "server": true, "ui": true, - "requiredPlugins": [ - "embeddable", - "uiActions", - "licensing" - ], - "requiredBundles": [ - "kibanaUtils", - "kibanaReact", - "data" - ] + "requiredPlugins": ["embeddable", "uiActions", "licensing"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "data"] } diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index d013c16837b77..e69e352104f35 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -3,6 +3,10 @@ "version": "kibana", "server": true, "ui": true, + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing", "features"], "optionalPlugins": ["usageCollection"], diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx new file mode 100644 index 0000000000000..aa1f7ca07e3d8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { BrowserAdvancedFields } from './advanced_fields'; +import { ConfigKeys, IBrowserAdvancedFields } from '../types'; +import { + BrowserAdvancedFieldsContextProvider, + defaultBrowserAdvancedFields as defaultConfig, +} from '../contexts'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +describe('', () => { + const WrappedComponent = ({ defaultValues }: { defaultValues?: IBrowserAdvancedFields }) => { + return ( + + + + ); + }; + + it('renders BrowserAdvancedFields', () => { + const { getByLabelText } = render(); + + const syntheticsArgs = getByLabelText('Synthetics args'); + const screenshots = getByLabelText('Screenshot options') as HTMLInputElement; + expect(screenshots.value).toEqual(defaultConfig[ConfigKeys.SCREENSHOTS]); + expect(syntheticsArgs).toBeInTheDocument(); + }); + + it('handles changing fields', () => { + const { getByLabelText } = render(); + + const screenshots = getByLabelText('Screenshot options') as HTMLInputElement; + + fireEvent.change(screenshots, { target: { value: 'off' } }); + + expect(screenshots.value).toEqual('off'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx new file mode 100644 index 0000000000000..28e2e39c79554 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiAccordion, + EuiSelect, + EuiFormRow, + EuiDescribedFormGroup, + EuiSpacer, +} from '@elastic/eui'; +import { ComboBox } from '../combo_box'; + +import { useBrowserAdvancedFieldsContext } from '../contexts'; + +import { ConfigKeys, ScreenshotOption } from '../types'; + +import { OptionalLabel } from '../optional_label'; + +export const BrowserAdvancedFields = () => { + const { fields, setFields } = useBrowserAdvancedFieldsContext(); + + const handleInputChange = useCallback( + ({ value, configKey }: { value: unknown; configKey: ConfigKeys }) => { + setFields((prevFields) => ({ ...prevFields, [configKey]: value })); + }, + [setFields] + ); + + return ( + + + + +

+ } + description={ + + } + > + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.SCREENSHOTS, + }) + } + data-test-subj="syntheticsBrowserScreenshots" + /> + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ value, configKey: ConfigKeys.SYNTHETICS_ARGS }) + } + data-test-subj="syntheticsBrowserSyntheticsArgs" + /> + + + + ); +}; + +const requestMethodOptions = Object.values(ScreenshotOption).map((option) => ({ + value: option, + text: option.replace(/-/g, ' '), +})); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts new file mode 100644 index 0000000000000..722b1625f023d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BrowserFields, ConfigKeys } from '../types'; +import { Formatter, commonFormatters, arrayToJsonFormatter } from '../common/formatters'; + +export type BrowserFormatMap = Record; + +export const browserFormatters: BrowserFormatMap = { + [ConfigKeys.SOURCE_ZIP_URL]: null, + [ConfigKeys.SOURCE_ZIP_USERNAME]: null, + [ConfigKeys.SOURCE_ZIP_PASSWORD]: null, + [ConfigKeys.SOURCE_ZIP_FOLDER]: null, + [ConfigKeys.SOURCE_INLINE]: null, + [ConfigKeys.PARAMS]: null, + [ConfigKeys.SCREENSHOTS]: null, + [ConfigKeys.SYNTHETICS_ARGS]: (fields) => + arrayToJsonFormatter(fields[ConfigKeys.SYNTHETICS_ARGS]), + ...commonFormatters, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts new file mode 100644 index 0000000000000..2b742a188782a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BrowserFields, ConfigKeys } from '../types'; +import { + Normalizer, + commonNormalizers, + getNormalizer, + getJsonToArrayOrObjectNormalizer, +} from '../common/normalizers'; + +import { defaultBrowserSimpleFields, defaultBrowserAdvancedFields } from '../contexts'; + +export type BrowserNormalizerMap = Record; + +const defaultBrowserFields = { + ...defaultBrowserSimpleFields, + ...defaultBrowserAdvancedFields, +}; + +export const getBrowserNormalizer = (key: ConfigKeys) => { + return getNormalizer(key, defaultBrowserFields); +}; + +export const getBrowserJsonToArrayOrObjectNormalizer = (key: ConfigKeys) => { + return getJsonToArrayOrObjectNormalizer(key, defaultBrowserFields); +}; + +export const browserNormalizers: BrowserNormalizerMap = { + [ConfigKeys.SOURCE_ZIP_URL]: getBrowserNormalizer(ConfigKeys.SOURCE_ZIP_URL), + [ConfigKeys.SOURCE_ZIP_USERNAME]: getBrowserNormalizer(ConfigKeys.SOURCE_ZIP_USERNAME), + [ConfigKeys.SOURCE_ZIP_PASSWORD]: getBrowserNormalizer(ConfigKeys.SOURCE_ZIP_PASSWORD), + [ConfigKeys.SOURCE_ZIP_FOLDER]: getBrowserNormalizer(ConfigKeys.SOURCE_ZIP_FOLDER), + [ConfigKeys.SOURCE_INLINE]: getBrowserNormalizer(ConfigKeys.SOURCE_INLINE), + [ConfigKeys.PARAMS]: getBrowserNormalizer(ConfigKeys.PARAMS), + [ConfigKeys.SCREENSHOTS]: getBrowserNormalizer(ConfigKeys.SCREENSHOTS), + [ConfigKeys.SYNTHETICS_ARGS]: getBrowserJsonToArrayOrObjectNormalizer(ConfigKeys.SYNTHETICS_ARGS), + ...commonNormalizers, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx new file mode 100644 index 0000000000000..34f56a65df3e8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiFieldText, EuiFieldNumber } from '@elastic/eui'; +import { ConfigKeys, Validation } from '../types'; +import { useBrowserSimpleFieldsContext } from '../contexts'; +import { ComboBox } from '../combo_box'; +import { OptionalLabel } from '../optional_label'; +import { ScheduleField } from '../schedule_field'; +import { SourceField } from './source_field'; + +interface Props { + validate: Validation; +} + +export const BrowserSimpleFields = memo(({ validate }) => { + const { fields, setFields, defaultValues } = useBrowserSimpleFieldsContext(); + const handleInputChange = ({ value, configKey }: { value: unknown; configKey: ConfigKeys }) => { + setFields((prevFields) => ({ ...prevFields, [configKey]: value })); + }; + const onChangeSourceField = useCallback( + ({ zipUrl, folder, username, password, inlineScript, params }) => { + setFields((prevFields) => ({ + ...prevFields, + [ConfigKeys.SOURCE_ZIP_URL]: zipUrl, + [ConfigKeys.SOURCE_ZIP_FOLDER]: folder, + [ConfigKeys.SOURCE_ZIP_USERNAME]: username, + [ConfigKeys.SOURCE_ZIP_PASSWORD]: password, + [ConfigKeys.SOURCE_INLINE]: inlineScript, + [ConfigKeys.PARAMS]: params, + })); + }, + [setFields] + ); + + return ( + <> + + } + isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields)} + error={ + + } + > + + handleInputChange({ + value: schedule, + configKey: ConfigKeys.SCHEDULE, + }) + } + number={fields[ConfigKeys.SCHEDULE].number} + unit={fields[ConfigKeys.SCHEDULE].unit} + /> + + + } + > + ({ + zipUrl: defaultValues[ConfigKeys.SOURCE_ZIP_URL], + folder: defaultValues[ConfigKeys.SOURCE_ZIP_FOLDER], + username: defaultValues[ConfigKeys.SOURCE_ZIP_USERNAME], + password: defaultValues[ConfigKeys.SOURCE_ZIP_PASSWORD], + inlineScript: defaultValues[ConfigKeys.SOURCE_INLINE], + params: defaultValues[ConfigKeys.PARAMS], + }), + [defaultValues] + )} + /> + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.APM_SERVICE_NAME, + }) + } + data-test-subj="syntheticsAPMServiceName" + /> + + + } + isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} + error={ + parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( + + ) : ( + + ) + } + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.TIMEOUT, + }) + } + step={'any'} + /> + + + } + labelAppend={} + helpText={ + + } + > + handleInputChange({ value, configKey: ConfigKeys.TAGS })} + data-test-subj="syntheticsTags" + /> + + + ); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx new file mode 100644 index 0000000000000..eca354f30c973 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiTabbedContent, + EuiFormRow, + EuiFieldText, + EuiFieldPassword, + EuiSpacer, +} from '@elastic/eui'; +import { OptionalLabel } from '../optional_label'; +import { CodeEditor } from '../code_editor'; +import { MonacoEditorLangId } from '../types'; + +enum SourceType { + INLINE = 'syntheticsBrowserInlineConfig', + ZIP = 'syntheticsBrowserZipURLConfig', +} + +interface SourceConfig { + zipUrl: string; + folder: string; + username: string; + password: string; + inlineScript: string; + params: string; +} + +interface Props { + onChange: (sourceConfig: SourceConfig) => void; + defaultConfig: SourceConfig; +} + +const defaultValues = { + zipUrl: '', + folder: '', + username: '', + password: '', + inlineScript: '', + params: '', +}; + +export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) => { + const [sourceType, setSourceType] = useState( + defaultConfig.inlineScript ? SourceType.INLINE : SourceType.ZIP + ); + const [config, setConfig] = useState(defaultConfig); + + useEffect(() => { + onChange(config); + }, [config, onChange]); + + const zipUrlLabel = ( + + ); + + const tabs = [ + { + id: 'syntheticsBrowserZipURLConfig', + name: zipUrlLabel, + content: ( + <> + + + } + helpText={ + + } + > + + setConfig((prevConfig) => ({ ...prevConfig, zipUrl: value })) + } + value={config.zipUrl} + /> + + + } + labelAppend={} + helpText={ + + } + > + + setConfig((prevConfig) => ({ ...prevConfig, folder: value })) + } + value={config.folder} + /> + + + } + labelAppend={} + helpText={ + + } + > + setConfig((prevConfig) => ({ ...prevConfig, params: code }))} + value={config.params} + /> + + + } + labelAppend={} + helpText={ + + } + > + + setConfig((prevConfig) => ({ ...prevConfig, username: value })) + } + value={config.username} + /> + + + } + labelAppend={} + helpText={ + + } + > + + setConfig((prevConfig) => ({ ...prevConfig, password: value })) + } + value={config.password} + /> + + + ), + }, + { + id: 'syntheticsBrowserInlineConfig', + name: ( + + ), + content: ( + + } + helpText={ + + } + > + setConfig((prevConfig) => ({ ...prevConfig, inlineScript: code }))} + value={config.inlineScript} + /> + + ), + }, + ]; + + return ( + tab.id === sourceType)} + autoFocus="selected" + onTabClick={(tab) => { + setSourceType(tab.id as SourceType); + if (tab.id !== sourceType) { + setConfig(defaultValues); + } + }} + /> + ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/default_values.ts b/x-pack/plugins/uptime/public/components/fleet_package/common/default_values.ts new file mode 100644 index 0000000000000..bba8cefd749ee --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/default_values.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ICommonFields, ConfigKeys, ScheduleUnit, DataStream } from '../types'; + +export const defaultValues: ICommonFields = { + [ConfigKeys.MONITOR_TYPE]: DataStream.HTTP, + [ConfigKeys.SCHEDULE]: { + number: '3', + unit: ScheduleUnit.MINUTES, + }, + [ConfigKeys.APM_SERVICE_NAME]: '', + [ConfigKeys.TAGS]: [], + [ConfigKeys.TIMEOUT]: '16', +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/formatters.test.ts b/x-pack/plugins/uptime/public/components/fleet_package/common/formatters.test.ts new file mode 100644 index 0000000000000..9f4d8320e4048 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/formatters.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { arrayToJsonFormatter, objectToJsonFormatter, secondsToCronFormatter } from './formatters'; + +describe('formatters', () => { + describe('cronToSecondsNormalizer', () => { + it('takes a number of seconds and converts it to cron format', () => { + expect(secondsToCronFormatter('3')).toEqual('3s'); + }); + }); + + describe('arrayToJsonFormatter', () => { + it('takes an array and converts it to json', () => { + expect(arrayToJsonFormatter(['tag1', 'tag2'])).toEqual('["tag1","tag2"]'); + }); + + it('returns null if the array has length of 0', () => { + expect(arrayToJsonFormatter([])).toEqual(null); + }); + }); + + describe('objectToJsonFormatter', () => { + it('takes a json object string and returns an object', () => { + expect(objectToJsonFormatter({ key: 'value' })).toEqual('{"key":"value"}'); + }); + + it('returns null if the object has no keys', () => { + expect(objectToJsonFormatter({})).toEqual(null); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/formatters.ts b/x-pack/plugins/uptime/public/components/fleet_package/common/formatters.ts new file mode 100644 index 0000000000000..311fa7da13498 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/formatters.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ICommonFields, ICustomFields, ConfigKeys } from '../types'; + +export type Formatter = null | ((fields: Partial) => string | null); + +export type CommonFormatMap = Record; + +export const commonFormatters: CommonFormatMap = { + [ConfigKeys.NAME]: null, + [ConfigKeys.MONITOR_TYPE]: null, + [ConfigKeys.SCHEDULE]: (fields) => + JSON.stringify( + `@every ${fields[ConfigKeys.SCHEDULE]?.number}${fields[ConfigKeys.SCHEDULE]?.unit}` + ), + [ConfigKeys.APM_SERVICE_NAME]: null, + [ConfigKeys.TAGS]: (fields) => arrayToJsonFormatter(fields[ConfigKeys.TAGS]), + [ConfigKeys.TIMEOUT]: (fields) => secondsToCronFormatter(fields[ConfigKeys.TIMEOUT]), +}; + +export const arrayToJsonFormatter = (value: string[] = []) => + value.length ? JSON.stringify(value) : null; + +export const secondsToCronFormatter = (value: string = '') => (value ? `${value}s` : null); + +export const objectToJsonFormatter = (value: Record = {}) => + Object.keys(value).length ? JSON.stringify(value) : null; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/normalizers.test.ts b/x-pack/plugins/uptime/public/components/fleet_package/common/normalizers.test.ts new file mode 100644 index 0000000000000..055e829858a16 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/normalizers.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cronToSecondsNormalizer, jsonToArrayOrObjectNormalizer } from './normalizers'; + +describe('normalizers', () => { + describe('cronToSecondsNormalizer', () => { + it('returns number of seconds from cron formatted seconds', () => { + expect(cronToSecondsNormalizer('3s')).toEqual('3'); + }); + }); + + describe('jsonToArrayOrObjectNormalizer', () => { + it('takes a json object string and returns an object', () => { + expect(jsonToArrayOrObjectNormalizer('{\n "key": "value"\n}')).toEqual({ + key: 'value', + }); + }); + + it('takes a json array string and returns an array', () => { + expect(jsonToArrayOrObjectNormalizer('["tag1","tag2"]')).toEqual(['tag1', 'tag2']); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/common/normalizers.ts new file mode 100644 index 0000000000000..69121ca4bd70e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/normalizers.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ICommonFields, ConfigKeys } from '../types'; +import { NewPackagePolicyInput } from '../../../../../fleet/common'; +import { defaultValues as commonDefaultValues } from './default_values'; + +// TO DO: create a standard input format that all fields resolve to +export type Normalizer = (fields: NewPackagePolicyInput['vars']) => unknown; + +// create a type of all the common policy fields, as well as the fleet managed 'name' field +export type CommonNormalizerMap = Record; + +/** + * Takes a cron formatted seconds and returns just the number of seconds. Assumes that cron is already in seconds format. + * @params {string} value (Ex '3s') + * @return {string} (Ex '3') + */ +export const cronToSecondsNormalizer = (value: string) => + value ? value.slice(0, value.length - 1) : null; + +export const jsonToArrayOrObjectNormalizer = (value: string) => (value ? JSON.parse(value) : null); + +export function getNormalizer(key: string, defaultValues: Fields): Normalizer { + return (fields: NewPackagePolicyInput['vars']) => + fields?.[key]?.value ?? defaultValues[key as keyof Fields]; +} + +export function getJsonToArrayOrObjectNormalizer( + key: string, + defaultValues: Fields +): Normalizer { + return (fields: NewPackagePolicyInput['vars']) => + jsonToArrayOrObjectNormalizer(fields?.[key]?.value) ?? defaultValues[key as keyof Fields]; +} + +export function getCronNormalizer(key: string, defaultValues: Fields): Normalizer { + return (fields: NewPackagePolicyInput['vars']) => + cronToSecondsNormalizer(fields?.[key]?.value) ?? defaultValues[key as keyof Fields]; +} + +export const getCommonNormalizer = (key: ConfigKeys) => { + return getNormalizer(key, commonDefaultValues); +}; + +export const getCommonJsonToArrayOrObjectNormalizer = (key: ConfigKeys) => { + return getJsonToArrayOrObjectNormalizer(key, commonDefaultValues); +}; + +export const getCommonCronToSecondsNormalizer = (key: ConfigKeys) => { + return getCronNormalizer(key, commonDefaultValues); +}; + +export const commonNormalizers: CommonNormalizerMap = { + [ConfigKeys.NAME]: (fields) => fields?.[ConfigKeys.NAME]?.value ?? '', + [ConfigKeys.MONITOR_TYPE]: getCommonNormalizer(ConfigKeys.MONITOR_TYPE), + [ConfigKeys.SCHEDULE]: (fields) => { + const value = fields?.[ConfigKeys.SCHEDULE]?.value; + if (value) { + const fullString = JSON.parse(fields?.[ConfigKeys.SCHEDULE]?.value); + const fullSchedule = fullString.replace('@every ', ''); + const unit = fullSchedule.slice(-1); + const number = fullSchedule.slice(0, fullSchedule.length - 1); + return { + unit, + number, + }; + } else { + return commonDefaultValues[ConfigKeys.SCHEDULE]; + } + }, + [ConfigKeys.APM_SERVICE_NAME]: getCommonNormalizer(ConfigKeys.APM_SERVICE_NAME), + [ConfigKeys.TAGS]: getCommonJsonToArrayOrObjectNormalizer(ConfigKeys.TAGS), + [ConfigKeys.TIMEOUT]: getCommonCronToSecondsNormalizer(ConfigKeys.TIMEOUT), +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx index b51aa6cbf3a7c..11796050a545b 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx @@ -25,7 +25,7 @@ interface IHTTPAdvancedFieldsContextProvider { defaultValues?: IHTTPAdvancedFields; } -export const initialValues = { +export const initialValues: IHTTPAdvancedFields = { [ConfigKeys.PASSWORD]: '', [ConfigKeys.PROXY_URL]: '', [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: [], diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx index 6e4f46111c283..ef821b7e39dca 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx @@ -19,7 +19,7 @@ interface ITCPAdvancedFieldsContextProvider { defaultValues?: ITCPAdvancedFields; } -export const initialValues = { +export const initialValues: ITCPAdvancedFields = { [ConfigKeys.PROXY_URL]: '', [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: false, [ConfigKeys.RESPONSE_RECEIVE_CHECK]: '', diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx new file mode 100644 index 0000000000000..1d1493178b944 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useMemo, useState } from 'react'; +import { IBrowserSimpleFields, ConfigKeys, DataStream } from '../types'; +import { defaultValues as commonDefaultValues } from '../common/default_values'; + +interface IBrowserSimpleFieldsContext { + setFields: React.Dispatch>; + fields: IBrowserSimpleFields; + defaultValues: IBrowserSimpleFields; +} + +interface IBrowserSimpleFieldsContextProvider { + children: React.ReactNode; + defaultValues?: IBrowserSimpleFields; +} + +export const initialValues: IBrowserSimpleFields = { + ...commonDefaultValues, + [ConfigKeys.MONITOR_TYPE]: DataStream.BROWSER, + [ConfigKeys.SOURCE_ZIP_URL]: '', + [ConfigKeys.SOURCE_ZIP_USERNAME]: '', + [ConfigKeys.SOURCE_ZIP_PASSWORD]: '', + [ConfigKeys.SOURCE_ZIP_FOLDER]: '', + [ConfigKeys.SOURCE_INLINE]: '', + [ConfigKeys.PARAMS]: '', +}; + +const defaultContext: IBrowserSimpleFieldsContext = { + setFields: (_fields: React.SetStateAction) => { + throw new Error( + 'setFields was not initialized for Browser Simple Fields, set it when you invoke the context' + ); + }, + fields: initialValues, // mutable + defaultValues: initialValues, // immutable +}; + +export const BrowserSimpleFieldsContext = createContext(defaultContext); + +export const BrowserSimpleFieldsContextProvider = ({ + children, + defaultValues = initialValues, +}: IBrowserSimpleFieldsContextProvider) => { + const [fields, setFields] = useState(defaultValues); + + const value = useMemo(() => { + return { fields, setFields, defaultValues }; + }, [fields, defaultValues]); + + return ; +}; + +export const useBrowserSimpleFieldsContext = () => useContext(BrowserSimpleFieldsContext); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx new file mode 100644 index 0000000000000..3f3bb8f14c269 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useMemo, useState } from 'react'; +import { IBrowserAdvancedFields, ConfigKeys, ScreenshotOption } from '../types'; + +interface IBrowserAdvancedFieldsContext { + setFields: React.Dispatch>; + fields: IBrowserAdvancedFields; + defaultValues: IBrowserAdvancedFields; +} + +interface IBrowserAdvancedFieldsContextProvider { + children: React.ReactNode; + defaultValues?: IBrowserAdvancedFields; +} + +export const initialValues: IBrowserAdvancedFields = { + [ConfigKeys.SCREENSHOTS]: ScreenshotOption.ON, + [ConfigKeys.SYNTHETICS_ARGS]: [], +}; + +const defaultContext: IBrowserAdvancedFieldsContext = { + setFields: (_fields: React.SetStateAction) => { + throw new Error( + 'setFields was not initialized for Browser Advanced Fields, set it when you invoke the context' + ); + }, + fields: initialValues, // mutable + defaultValues: initialValues, // immutable +}; + +export const BrowserAdvancedFieldsContext = createContext(defaultContext); + +export const BrowserAdvancedFieldsContextProvider = ({ + children, + defaultValues = initialValues, +}: IBrowserAdvancedFieldsContextProvider) => { + const [fields, setFields] = useState(defaultValues); + + const value = useMemo(() => { + return { fields, setFields, defaultValues }; + }, [fields, defaultValues]); + + return ; +}; + +export const useBrowserAdvancedFieldsContext = () => useContext(BrowserAdvancedFieldsContext); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_provider.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_provider.tsx new file mode 100644 index 0000000000000..e2ce88f84f702 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_provider.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { BrowserFields, IBrowserSimpleFields, IBrowserAdvancedFields } from '../types'; +import { + BrowserSimpleFieldsContextProvider, + BrowserAdvancedFieldsContextProvider, + defaultBrowserSimpleFields, + defaultBrowserAdvancedFields, +} from '.'; +import { formatDefaultValues } from '../helpers/context_helpers'; + +interface BrowserContextProviderProps { + defaultValues?: BrowserFields; + children: ReactNode; +} + +export const BrowserContextProvider = ({ + defaultValues, + children, +}: BrowserContextProviderProps) => { + const simpleKeys = Object.keys(defaultBrowserSimpleFields) as Array; + const advancedKeys = Object.keys(defaultBrowserAdvancedFields) as Array< + keyof IBrowserAdvancedFields + >; + const formattedDefaultSimpleFields = formatDefaultValues( + simpleKeys, + defaultValues || {} + ); + const formattedDefaultAdvancedFields = formatDefaultValues( + advancedKeys, + defaultValues || {} + ); + const simpleFields: IBrowserSimpleFields | undefined = defaultValues + ? formattedDefaultSimpleFields + : undefined; + const advancedFields: IBrowserAdvancedFields | undefined = defaultValues + ? formattedDefaultAdvancedFields + : undefined; + return ( + + + {children} + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_context.tsx index d1306836afa9c..d8b89a1dfc4d0 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_context.tsx @@ -6,7 +6,8 @@ */ import React, { createContext, useContext, useMemo, useState } from 'react'; -import { IHTTPSimpleFields, ConfigKeys, ScheduleUnit, DataStream } from '../types'; +import { IHTTPSimpleFields, ConfigKeys, DataStream } from '../types'; +import { defaultValues as commonDefaultValues } from '../common/default_values'; interface IHTTPSimpleFieldsContext { setFields: React.Dispatch>; @@ -19,17 +20,11 @@ interface IHTTPSimpleFieldsContextProvider { defaultValues?: IHTTPSimpleFields; } -export const initialValues = { +export const initialValues: IHTTPSimpleFields = { + ...commonDefaultValues, [ConfigKeys.URLS]: '', [ConfigKeys.MAX_REDIRECTS]: '0', [ConfigKeys.MONITOR_TYPE]: DataStream.HTTP, - [ConfigKeys.SCHEDULE]: { - number: '3', - unit: ScheduleUnit.MINUTES, - }, - [ConfigKeys.APM_SERVICE_NAME]: '', - [ConfigKeys.TAGS]: [], - [ConfigKeys.TIMEOUT]: '16', }; const defaultContext: IHTTPSimpleFieldsContext = { diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_provider.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_provider.tsx index e48de76862e24..ea577f3336936 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_provider.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_provider.tsx @@ -6,63 +6,39 @@ */ import React, { ReactNode } from 'react'; -import { IHTTPSimpleFields, IHTTPAdvancedFields, ITLSFields, ConfigKeys } from '../types'; +import { HTTPFields, IHTTPSimpleFields, IHTTPAdvancedFields } from '../types'; import { HTTPSimpleFieldsContextProvider, HTTPAdvancedFieldsContextProvider, - TLSFieldsContextProvider, + defaultHTTPSimpleFields, + defaultHTTPAdvancedFields, } from '.'; +import { formatDefaultValues } from '../helpers/context_helpers'; interface HTTPContextProviderProps { - defaultValues?: any; + defaultValues?: HTTPFields; children: ReactNode; } export const HTTPContextProvider = ({ defaultValues, children }: HTTPContextProviderProps) => { - const httpAdvancedFields: IHTTPAdvancedFields | undefined = defaultValues - ? { - [ConfigKeys.USERNAME]: defaultValues[ConfigKeys.USERNAME], - [ConfigKeys.PASSWORD]: defaultValues[ConfigKeys.PASSWORD], - [ConfigKeys.PROXY_URL]: defaultValues[ConfigKeys.PROXY_URL], - [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: - defaultValues[ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE], - [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: - defaultValues[ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE], - [ConfigKeys.RESPONSE_BODY_INDEX]: defaultValues[ConfigKeys.RESPONSE_BODY_INDEX], - [ConfigKeys.RESPONSE_HEADERS_CHECK]: defaultValues[ConfigKeys.RESPONSE_HEADERS_CHECK], - [ConfigKeys.RESPONSE_HEADERS_INDEX]: defaultValues[ConfigKeys.RESPONSE_HEADERS_INDEX], - [ConfigKeys.RESPONSE_STATUS_CHECK]: defaultValues[ConfigKeys.RESPONSE_STATUS_CHECK], - [ConfigKeys.REQUEST_BODY_CHECK]: defaultValues[ConfigKeys.REQUEST_BODY_CHECK], - [ConfigKeys.REQUEST_HEADERS_CHECK]: defaultValues[ConfigKeys.REQUEST_HEADERS_CHECK], - [ConfigKeys.REQUEST_METHOD_CHECK]: defaultValues[ConfigKeys.REQUEST_METHOD_CHECK], - } - : undefined; + const simpleKeys = Object.keys(defaultHTTPSimpleFields) as Array; + const advancedKeys = Object.keys(defaultHTTPAdvancedFields) as Array; + const formattedDefaultHTTPSimpleFields = formatDefaultValues( + simpleKeys, + defaultValues || {} + ); + const formattedDefaultHTTPAdvancedFields = formatDefaultValues( + advancedKeys, + defaultValues || {} + ); + const httpAdvancedFields = defaultValues ? formattedDefaultHTTPAdvancedFields : undefined; const httpSimpleFields: IHTTPSimpleFields | undefined = defaultValues - ? { - [ConfigKeys.APM_SERVICE_NAME]: defaultValues[ConfigKeys.APM_SERVICE_NAME], - [ConfigKeys.MAX_REDIRECTS]: defaultValues[ConfigKeys.MAX_REDIRECTS], - [ConfigKeys.MONITOR_TYPE]: defaultValues[ConfigKeys.MONITOR_TYPE], - [ConfigKeys.SCHEDULE]: defaultValues[ConfigKeys.SCHEDULE], - [ConfigKeys.TAGS]: defaultValues[ConfigKeys.TAGS], - [ConfigKeys.TIMEOUT]: defaultValues[ConfigKeys.TIMEOUT], - [ConfigKeys.URLS]: defaultValues[ConfigKeys.URLS], - } - : undefined; - const tlsFields: ITLSFields | undefined = defaultValues - ? { - [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: - defaultValues[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES], - [ConfigKeys.TLS_CERTIFICATE]: defaultValues[ConfigKeys.TLS_CERTIFICATE], - [ConfigKeys.TLS_KEY]: defaultValues[ConfigKeys.TLS_KEY], - [ConfigKeys.TLS_KEY_PASSPHRASE]: defaultValues[ConfigKeys.TLS_KEY_PASSPHRASE], - [ConfigKeys.TLS_VERIFICATION_MODE]: defaultValues[ConfigKeys.TLS_VERIFICATION_MODE], - [ConfigKeys.TLS_VERSION]: defaultValues[ConfigKeys.TLS_VERSION], - } + ? formattedDefaultHTTPSimpleFields : undefined; return ( - {children} + {children} ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/icmp_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/icmp_context.tsx index 93c67c6133ce9..eb7227ebceb07 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/icmp_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/icmp_context.tsx @@ -6,7 +6,8 @@ */ import React, { createContext, useContext, useMemo, useState } from 'react'; -import { IICMPSimpleFields, ConfigKeys, ScheduleUnit, DataStream } from '../types'; +import { IICMPSimpleFields, ConfigKeys, DataStream } from '../types'; +import { defaultValues as commonDefaultValues } from '../common/default_values'; interface IICMPSimpleFieldsContext { setFields: React.Dispatch>; @@ -19,17 +20,10 @@ interface IICMPSimpleFieldsContextProvider { defaultValues?: IICMPSimpleFields; } -export const initialValues = { +export const initialValues: IICMPSimpleFields = { + ...commonDefaultValues, [ConfigKeys.HOSTS]: '', - [ConfigKeys.MAX_REDIRECTS]: '0', [ConfigKeys.MONITOR_TYPE]: DataStream.ICMP, - [ConfigKeys.SCHEDULE]: { - number: '3', - unit: ScheduleUnit.MINUTES, - }, - [ConfigKeys.APM_SERVICE_NAME]: '', - [ConfigKeys.TAGS]: [], - [ConfigKeys.TIMEOUT]: '16', [ConfigKeys.WAIT]: '1', }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts index f84a4e75df922..e955d2d7d4d50 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - export { MonitorTypeContext, MonitorTypeContextProvider, @@ -17,6 +16,12 @@ export { initialValues as defaultHTTPSimpleFields, useHTTPSimpleFieldsContext, } from './http_context'; +export { + HTTPAdvancedFieldsContext, + HTTPAdvancedFieldsContextProvider, + initialValues as defaultHTTPAdvancedFields, + useHTTPAdvancedFieldsContext, +} from './advanced_fields_http_context'; export { TCPSimpleFieldsContext, TCPSimpleFieldsContextProvider, @@ -36,11 +41,17 @@ export { useTCPAdvancedFieldsContext, } from './advanced_fields_tcp_context'; export { - HTTPAdvancedFieldsContext, - HTTPAdvancedFieldsContextProvider, - initialValues as defaultHTTPAdvancedFields, - useHTTPAdvancedFieldsContext, -} from './advanced_fields_http_context'; + BrowserSimpleFieldsContext, + BrowserSimpleFieldsContextProvider, + initialValues as defaultBrowserSimpleFields, + useBrowserSimpleFieldsContext, +} from './browser_context'; +export { + BrowserAdvancedFieldsContext, + BrowserAdvancedFieldsContextProvider, + initialValues as defaultBrowserAdvancedFields, + useBrowserAdvancedFieldsContext, +} from './browser_context_advanced'; export { TLSFieldsContext, TLSFieldsContextProvider, @@ -49,3 +60,4 @@ export { } from './tls_fields_context'; export { HTTPContextProvider } from './http_provider'; export { TCPContextProvider } from './tcp_provider'; +export { BrowserContextProvider } from './browser_provider'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_context.tsx index 6020a7ff2bff8..a1e01cb7faab7 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_context.tsx @@ -6,7 +6,8 @@ */ import React, { createContext, useContext, useMemo, useState } from 'react'; -import { ITCPSimpleFields, ConfigKeys, ScheduleUnit, DataStream } from '../types'; +import { ITCPSimpleFields, ConfigKeys, DataStream } from '../types'; +import { defaultValues as commonDefaultValues } from '../common/default_values'; interface ITCPSimpleFieldsContext { setFields: React.Dispatch>; @@ -19,17 +20,10 @@ interface ITCPSimpleFieldsContextProvider { defaultValues?: ITCPSimpleFields; } -export const initialValues = { +export const initialValues: ITCPSimpleFields = { + ...commonDefaultValues, [ConfigKeys.HOSTS]: '', - [ConfigKeys.MAX_REDIRECTS]: '0', [ConfigKeys.MONITOR_TYPE]: DataStream.TCP, - [ConfigKeys.SCHEDULE]: { - number: '3', - unit: ScheduleUnit.MINUTES, - }, - [ConfigKeys.APM_SERVICE_NAME]: '', - [ConfigKeys.TAGS]: [], - [ConfigKeys.TIMEOUT]: '16', }; const defaultContext: ITCPSimpleFieldsContext = { diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_provider.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_provider.tsx index 666839803f4d6..b62e87a566b97 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_provider.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_provider.tsx @@ -6,56 +6,41 @@ */ import React, { ReactNode } from 'react'; -import { ConfigKeys, ITCPSimpleFields, ITCPAdvancedFields, ITLSFields } from '../types'; +import { TCPFields, ITCPSimpleFields, ITCPAdvancedFields } from '../types'; import { TCPSimpleFieldsContextProvider, TCPAdvancedFieldsContextProvider, - TLSFieldsContextProvider, + defaultTCPSimpleFields, + defaultTCPAdvancedFields, } from '.'; +import { formatDefaultValues } from '../helpers/context_helpers'; interface TCPContextProviderProps { - defaultValues?: any; + defaultValues?: TCPFields; children: ReactNode; } -/** - * Exports Synthetics-specific package policy instructions - * for use in the Ingest app create / edit package policy - */ export const TCPContextProvider = ({ defaultValues, children }: TCPContextProviderProps) => { - const tcpSimpleFields: ITCPSimpleFields | undefined = defaultValues - ? { - [ConfigKeys.APM_SERVICE_NAME]: defaultValues[ConfigKeys.APM_SERVICE_NAME], - [ConfigKeys.HOSTS]: defaultValues[ConfigKeys.HOSTS], - [ConfigKeys.MONITOR_TYPE]: defaultValues[ConfigKeys.MONITOR_TYPE], - [ConfigKeys.SCHEDULE]: defaultValues[ConfigKeys.SCHEDULE], - [ConfigKeys.TAGS]: defaultValues[ConfigKeys.TAGS], - [ConfigKeys.TIMEOUT]: defaultValues[ConfigKeys.TIMEOUT], - } - : undefined; - const tcpAdvancedFields: ITCPAdvancedFields | undefined = defaultValues - ? { - [ConfigKeys.PROXY_URL]: defaultValues[ConfigKeys.PROXY_URL], - [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: defaultValues[ConfigKeys.PROXY_USE_LOCAL_RESOLVER], - [ConfigKeys.RESPONSE_RECEIVE_CHECK]: defaultValues[ConfigKeys.RESPONSE_RECEIVE_CHECK], - [ConfigKeys.REQUEST_SEND_CHECK]: defaultValues[ConfigKeys.REQUEST_SEND_CHECK], - } + const simpleKeys = Object.keys(defaultTCPSimpleFields) as Array; + const advancedKeys = Object.keys(defaultTCPAdvancedFields) as Array; + const formattedDefaultSimpleFields = formatDefaultValues( + simpleKeys, + defaultValues || {} + ); + const formattedDefaultAdvancedFields = formatDefaultValues( + advancedKeys, + defaultValues || {} + ); + const simpleFields: ITCPSimpleFields | undefined = defaultValues + ? formattedDefaultSimpleFields : undefined; - const tlsFields: ITLSFields | undefined = defaultValues - ? { - [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: - defaultValues[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES], - [ConfigKeys.TLS_CERTIFICATE]: defaultValues[ConfigKeys.TLS_CERTIFICATE], - [ConfigKeys.TLS_KEY]: defaultValues[ConfigKeys.TLS_KEY], - [ConfigKeys.TLS_KEY_PASSPHRASE]: defaultValues[ConfigKeys.TLS_KEY_PASSPHRASE], - [ConfigKeys.TLS_VERIFICATION_MODE]: defaultValues[ConfigKeys.TLS_VERIFICATION_MODE], - [ConfigKeys.TLS_VERSION]: defaultValues[ConfigKeys.TLS_VERSION], - } + const advancedFields: ITCPAdvancedFields | undefined = defaultValues + ? formattedDefaultAdvancedFields : undefined; return ( - - - {children} + + + {children} ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx index eaeb995654448..2a88b8c88e96c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tls_fields_context.tsx @@ -19,7 +19,7 @@ interface ITLSFieldsContextProvider { defaultValues?: ITLSFields; } -export const initialValues = { +export const initialValues: ITLSFields = { [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { value: '', isEnabled: false, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx index e114ea72b8f49..5bcd235b9b60e 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import 'jest-canvas-mock'; import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; @@ -11,8 +12,10 @@ import { render } from '../../lib/helper/rtl_helpers'; import { TCPContextProvider, HTTPContextProvider, + BrowserContextProvider, ICMPSimpleFieldsContextProvider, MonitorTypeContextProvider, + TLSFieldsContextProvider, } from './contexts'; import { CustomFields } from './custom_fields'; import { ConfigKeys, DataStream, ScheduleUnit } from './types'; @@ -30,14 +33,26 @@ const defaultHTTPConfig = defaultConfig[DataStream.HTTP]; const defaultTCPConfig = defaultConfig[DataStream.TCP]; describe('', () => { - const WrappedComponent = ({ validate = defaultValidation, typeEditable = false }) => { + const WrappedComponent = ({ + validate = defaultValidation, + typeEditable = false, + dataStreams = [DataStream.HTTP, DataStream.TCP, DataStream.ICMP, DataStream.BROWSER], + }) => { return ( - - - + + + + + + + @@ -149,7 +164,7 @@ describe('', () => { }); it('handles switching monitor type', () => { - const { getByText, getByLabelText, queryByLabelText } = render( + const { getByText, getByLabelText, queryByLabelText, getAllByLabelText } = render( ); const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; @@ -168,7 +183,7 @@ describe('', () => { expect(queryByLabelText('Max redirects')).not.toBeInTheDocument(); // ensure at least one tcp advanced option is present - const advancedOptionsButton = getByText('Advanced TCP options'); + let advancedOptionsButton = getByText('Advanced TCP options'); fireEvent.click(advancedOptionsButton); expect(queryByLabelText('Request method')).not.toBeInTheDocument(); @@ -181,6 +196,21 @@ describe('', () => { // expect TCP fields not to be in the DOM expect(queryByLabelText('Proxy URL')).not.toBeInTheDocument(); + + fireEvent.change(monitorType, { target: { value: DataStream.BROWSER } }); + + // expect browser fields to be in the DOM + getAllByLabelText('Zip URL').forEach((node) => { + expect(node).toBeInTheDocument(); + }); + + // ensure at least one browser advanced option is present + advancedOptionsButton = getByText('Advanced Browser options'); + fireEvent.click(advancedOptionsButton); + expect(getByLabelText('Screenshot options')).toBeInTheDocument(); + + // expect ICMP fields not to be in the DOM + expect(queryByLabelText('Wait in seconds')).not.toBeInTheDocument(); }); it('shows resolve hostnames locally field when proxy url is filled for tcp monitors', () => { @@ -213,7 +243,7 @@ describe('', () => { const urlError = getByText('URL is required'); const monitorIntervalError = getByText('Monitor interval is required'); const maxRedirectsError = getByText('Max redirects must be 0 or greater'); - const timeoutError = getByText('Timeout must be 0 or greater and less than schedule interval'); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); expect(urlError).toBeInTheDocument(); expect(monitorIntervalError).toBeInTheDocument(); @@ -229,16 +259,35 @@ describe('', () => { expect(queryByText('URL is required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); // create more errors fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); // 1 minute - fireEvent.change(timeout, { target: { value: '61' } }); // timeout cannot be more than monitor interval + fireEvent.change(timeout, { target: { value: '611' } }); // timeout cannot be more than monitor interval - const timeoutError2 = getByText('Timeout must be 0 or greater and less than schedule interval'); + const timeoutError2 = getByText('Timeout must be less than the monitor interval'); expect(timeoutError2).toBeInTheDocument(); }); + + it('does not show monitor options that are not contained in datastreams', async () => { + const { getByText, queryByText, queryByLabelText } = render( + + ); + + const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; + + // resolve errors + fireEvent.click(monitorType); + + waitFor(() => { + expect(getByText('http')).toBeInTheDocument(); + expect(getByText('tcp')).toBeInTheDocument(); + expect(getByText('icmp')).toBeInTheDocument(); + expect(queryByText('browser')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx index 0d9291261b82d..87f7a98aa4a6f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import React, { useState, memo } from 'react'; +import React, { useState, useMemo, memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, @@ -25,20 +24,39 @@ import { HTTPAdvancedFields } from './http/advanced_fields'; import { TCPSimpleFields } from './tcp/simple_fields'; import { TCPAdvancedFields } from './tcp/advanced_fields'; import { ICMPSimpleFields } from './icmp/simple_fields'; +import { BrowserSimpleFields } from './browser/simple_fields'; +import { BrowserAdvancedFields } from './browser/advanced_fields'; interface Props { typeEditable?: boolean; isTLSEnabled?: boolean; validate: Validation; + dataStreams?: DataStream[]; } export const CustomFields = memo( - ({ typeEditable = false, isTLSEnabled: defaultIsTLSEnabled = false, validate }) => { + ({ + typeEditable = false, + isTLSEnabled: defaultIsTLSEnabled = false, + validate, + dataStreams = [], + }) => { const [isTLSEnabled, setIsTLSEnabled] = useState(defaultIsTLSEnabled); const { monitorType, setMonitorType } = useMonitorTypeContext(); const isHTTP = monitorType === DataStream.HTTP; const isTCP = monitorType === DataStream.TCP; + const isBrowser = monitorType === DataStream.BROWSER; + + const dataStreamOptions = useMemo(() => { + const dataStreamToString = [ + { value: DataStream.HTTP, text: 'HTTP' }, + { value: DataStream.TCP, text: 'TCP' }, + { value: DataStream.ICMP, text: 'ICMP' }, + { value: DataStream.BROWSER, text: 'Browser' }, + ]; + return dataStreamToString.filter((dataStream) => dataStreams.includes(dataStream.value)); + }, [dataStreams]); const renderSimpleFields = (type: DataStream) => { switch (type) { @@ -48,6 +66,8 @@ export const CustomFields = memo( return ; case DataStream.TCP: return ; + case DataStream.BROWSER: + return ; default: return null; } @@ -82,7 +102,11 @@ export const CustomFields = memo( defaultMessage="Monitor Type" /> } - isInvalid={!!validate[ConfigKeys.MONITOR_TYPE]?.(monitorType)} + isInvalid={ + !!validate[ConfigKeys.MONITOR_TYPE]?.({ + [ConfigKeys.MONITOR_TYPE]: monitorType, + }) + } error={ ( {isHTTP && } {isTCP && } + {isBrowser && } ); } ); - -const dataStreamOptions = [ - { value: DataStream.HTTP, text: 'HTTP' }, - { value: DataStream.TCP, text: 'TCP' }, - { value: DataStream.ICMP, text: 'ICMP' }, -]; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/helpers/context_helpers.ts b/x-pack/plugins/uptime/public/components/fleet_package/helpers/context_helpers.ts new file mode 100644 index 0000000000000..acd8bdf95ce85 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/helpers/context_helpers.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function formatDefaultValues( + keys: Array, + defaultValues: Partial +) { + return keys.reduce((acc: any, currentValue) => { + const key = currentValue as keyof Fields; + acc[key] = defaultValues?.[key]; + return acc; + }, {}) as Fields; +} diff --git a/x-pack/plugins/uptime/public/components/fleet_package/helpers/formatters.ts b/x-pack/plugins/uptime/public/components/fleet_package/helpers/formatters.ts new file mode 100644 index 0000000000000..8ca10516a6200 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/helpers/formatters.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DataStream } from '../types'; + +import { httpFormatters, HTTPFormatMap } from '../http/formatters'; +import { tcpFormatters, TCPFormatMap } from '../tcp/formatters'; +import { icmpFormatters, ICMPFormatMap } from '../icmp/formatters'; +import { browserFormatters, BrowserFormatMap } from '../browser/formatters'; +import { commonFormatters, CommonFormatMap } from '../common/formatters'; + +type Formatters = HTTPFormatMap & TCPFormatMap & ICMPFormatMap & BrowserFormatMap & CommonFormatMap; + +interface FormatterMap { + [DataStream.HTTP]: HTTPFormatMap; + [DataStream.ICMP]: ICMPFormatMap; + [DataStream.TCP]: TCPFormatMap; + [DataStream.BROWSER]: BrowserFormatMap; +} + +export const formattersMap: FormatterMap = { + [DataStream.HTTP]: httpFormatters, + [DataStream.ICMP]: icmpFormatters, + [DataStream.TCP]: tcpFormatters, + [DataStream.BROWSER]: browserFormatters, +}; + +export const formatters: Formatters = { + ...httpFormatters, + ...icmpFormatters, + ...tcpFormatters, + ...browserFormatters, + ...commonFormatters, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/helpers/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/helpers/normalizers.ts new file mode 100644 index 0000000000000..60aa607aebe61 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/helpers/normalizers.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DataStream } from '../types'; + +import { httpNormalizers, HTTPNormalizerMap } from '../http/normalizers'; +import { tcpNormalizers, TCPNormalizerMap } from '../tcp/normalizers'; +import { icmpNormalizers, ICMPNormalizerMap } from '../icmp/normalizers'; +import { browserNormalizers, BrowserNormalizerMap } from '../browser/normalizers'; +import { commonNormalizers, CommonNormalizerMap } from '../common/normalizers'; + +type Normalizers = HTTPNormalizerMap & + ICMPNormalizerMap & + TCPNormalizerMap & + BrowserNormalizerMap & + CommonNormalizerMap; + +interface NormalizerMap { + [DataStream.HTTP]: HTTPNormalizerMap; + [DataStream.ICMP]: ICMPNormalizerMap; + [DataStream.TCP]: TCPNormalizerMap; + [DataStream.BROWSER]: BrowserNormalizerMap; +} + +export const normalizersMap: NormalizerMap = { + [DataStream.HTTP]: httpNormalizers, + [DataStream.ICMP]: icmpNormalizers, + [DataStream.TCP]: tcpNormalizers, + [DataStream.BROWSER]: browserNormalizers, +}; + +export const normalizers: Normalizers = { + ...httpNormalizers, + ...icmpNormalizers, + ...tcpNormalizers, + ...browserNormalizers, + ...commonNormalizers, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx index 267ccd678ddad..c38ac509e377e 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx @@ -186,18 +186,12 @@ export const HTTPAdvancedFields = memo(({ validate }) => { /> } labelAppend={} - isInvalid={ - !!validate[ConfigKeys.REQUEST_HEADERS_CHECK]?.(fields[ConfigKeys.REQUEST_HEADERS_CHECK]) - } + isInvalid={!!validate[ConfigKeys.REQUEST_HEADERS_CHECK]?.(fields)} error={ - !!validate[ConfigKeys.REQUEST_HEADERS_CHECK]?.( - fields[ConfigKeys.REQUEST_HEADERS_CHECK] - ) ? ( - - ) : undefined + } helpText={ (({ validate }) => { /> } labelAppend={} - isInvalid={ - !!validate[ConfigKeys.RESPONSE_STATUS_CHECK]?.(fields[ConfigKeys.RESPONSE_STATUS_CHECK]) - } + isInvalid={!!validate[ConfigKeys.RESPONSE_STATUS_CHECK]?.(fields)} error={ (({ validate }) => { /> } labelAppend={} - isInvalid={ - !!validate[ConfigKeys.RESPONSE_HEADERS_CHECK]?.( - fields[ConfigKeys.RESPONSE_HEADERS_CHECK] - ) - } - error={ - !!validate[ConfigKeys.RESPONSE_HEADERS_CHECK]?.( - fields[ConfigKeys.RESPONSE_HEADERS_CHECK] - ) - ? [ - , - ] - : undefined - } + isInvalid={!!validate[ConfigKeys.RESPONSE_HEADERS_CHECK]?.(fields)} + error={[ + , + ]} helpText={ ; + +export const httpFormatters: HTTPFormatMap = { + [ConfigKeys.URLS]: null, + [ConfigKeys.MAX_REDIRECTS]: null, + [ConfigKeys.USERNAME]: null, + [ConfigKeys.PASSWORD]: null, + [ConfigKeys.PROXY_URL]: null, + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: (fields) => + arrayToJsonFormatter(fields[ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]), + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: (fields) => + arrayToJsonFormatter(fields[ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]), + [ConfigKeys.RESPONSE_BODY_INDEX]: null, + [ConfigKeys.RESPONSE_HEADERS_CHECK]: (fields) => + objectToJsonFormatter(fields[ConfigKeys.RESPONSE_HEADERS_CHECK]), + [ConfigKeys.RESPONSE_HEADERS_INDEX]: null, + [ConfigKeys.RESPONSE_STATUS_CHECK]: (fields) => + arrayToJsonFormatter(fields[ConfigKeys.RESPONSE_STATUS_CHECK]), + [ConfigKeys.REQUEST_BODY_CHECK]: (fields) => + fields[ConfigKeys.REQUEST_BODY_CHECK]?.value + ? JSON.stringify(fields[ConfigKeys.REQUEST_BODY_CHECK]?.value) + : null, + [ConfigKeys.REQUEST_HEADERS_CHECK]: (fields) => + objectToJsonFormatter(fields[ConfigKeys.REQUEST_HEADERS_CHECK]), + [ConfigKeys.REQUEST_METHOD_CHECK]: null, + ...tlsFormatters, + ...commonFormatters, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/http/normalizers.ts new file mode 100644 index 0000000000000..10c52c295c9c4 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/http/normalizers.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HTTPFields, ConfigKeys, ContentType, contentTypesToMode } from '../types'; +import { + Normalizer, + commonNormalizers, + getNormalizer, + getJsonToArrayOrObjectNormalizer, +} from '../common/normalizers'; +import { tlsNormalizers } from '../tls/normalizers'; +import { defaultHTTPSimpleFields, defaultHTTPAdvancedFields } from '../contexts'; + +export type HTTPNormalizerMap = Record; + +const defaultHTTPValues = { + ...defaultHTTPSimpleFields, + ...defaultHTTPAdvancedFields, +}; + +export const getHTTPNormalizer = (key: ConfigKeys) => { + return getNormalizer(key, defaultHTTPValues); +}; + +export const getHTTPJsonToArrayOrObjectNormalizer = (key: ConfigKeys) => { + return getJsonToArrayOrObjectNormalizer(key, defaultHTTPValues); +}; + +export const httpNormalizers: HTTPNormalizerMap = { + [ConfigKeys.URLS]: getHTTPNormalizer(ConfigKeys.URLS), + [ConfigKeys.MAX_REDIRECTS]: getHTTPNormalizer(ConfigKeys.MAX_REDIRECTS), + [ConfigKeys.USERNAME]: getHTTPNormalizer(ConfigKeys.USERNAME), + [ConfigKeys.PASSWORD]: getHTTPNormalizer(ConfigKeys.PASSWORD), + [ConfigKeys.PROXY_URL]: getHTTPNormalizer(ConfigKeys.PROXY_URL), + [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: getHTTPJsonToArrayOrObjectNormalizer( + ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE + ), + [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: getHTTPJsonToArrayOrObjectNormalizer( + ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE + ), + [ConfigKeys.RESPONSE_BODY_INDEX]: getHTTPNormalizer(ConfigKeys.RESPONSE_BODY_INDEX), + [ConfigKeys.RESPONSE_HEADERS_CHECK]: getHTTPJsonToArrayOrObjectNormalizer( + ConfigKeys.RESPONSE_HEADERS_CHECK + ), + [ConfigKeys.RESPONSE_HEADERS_INDEX]: getHTTPNormalizer(ConfigKeys.RESPONSE_HEADERS_INDEX), + [ConfigKeys.RESPONSE_STATUS_CHECK]: getHTTPJsonToArrayOrObjectNormalizer( + ConfigKeys.RESPONSE_STATUS_CHECK + ), + [ConfigKeys.REQUEST_BODY_CHECK]: (fields) => { + const requestBody = fields?.[ConfigKeys.REQUEST_BODY_CHECK]?.value; + const requestHeaders = fields?.[ConfigKeys.REQUEST_HEADERS_CHECK]?.value; + if (requestBody) { + const headers = requestHeaders + ? JSON.parse(fields?.[ConfigKeys.REQUEST_HEADERS_CHECK]?.value) + : defaultHTTPAdvancedFields[ConfigKeys.REQUEST_HEADERS_CHECK]; + const requestBodyValue = + requestBody !== null && requestBody !== undefined + ? JSON.parse(requestBody) + : defaultHTTPAdvancedFields[ConfigKeys.REQUEST_BODY_CHECK]?.value; + let requestBodyType = defaultHTTPAdvancedFields[ConfigKeys.REQUEST_BODY_CHECK]?.type; + Object.keys(headers || []).some((headerKey) => { + if (headerKey === 'Content-Type' && contentTypesToMode[headers[headerKey] as ContentType]) { + requestBodyType = contentTypesToMode[headers[headerKey] as ContentType]; + return true; + } + }); + return { + value: requestBodyValue, + type: requestBodyType, + }; + } else { + return defaultHTTPAdvancedFields[ConfigKeys.REQUEST_BODY_CHECK]; + } + }, + [ConfigKeys.REQUEST_HEADERS_CHECK]: getHTTPJsonToArrayOrObjectNormalizer( + ConfigKeys.REQUEST_HEADERS_CHECK + ), + [ConfigKeys.REQUEST_METHOD_CHECK]: getHTTPNormalizer(ConfigKeys.REQUEST_METHOD_CHECK), + ...commonNormalizers, + ...tlsNormalizers, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx index d17b8c997e9e8..8eb81eb92f7b4 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx @@ -33,7 +33,7 @@ export const HTTPSimpleFields = memo(({ validate }) => { defaultMessage="URL" /> } - isInvalid={!!validate[ConfigKeys.URLS]?.(fields[ConfigKeys.URLS])} + isInvalid={!!validate[ConfigKeys.URLS]?.(fields)} error={ (({ validate }) => { defaultMessage="Monitor interval" /> } - isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields[ConfigKeys.SCHEDULE])} + isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields)} error={ (({ validate }) => { defaultMessage="Max redirects" /> } - isInvalid={!!validate[ConfigKeys.MAX_REDIRECTS]?.(fields[ConfigKeys.MAX_REDIRECTS])} + isInvalid={!!validate[ConfigKeys.MAX_REDIRECTS]?.(fields)} error={ (({ validate }) => { defaultMessage="Timeout in seconds" /> } - isInvalid={ - !!validate[ConfigKeys.TIMEOUT]?.( - fields[ConfigKeys.TIMEOUT], - fields[ConfigKeys.SCHEDULE].number, - fields[ConfigKeys.SCHEDULE].unit - ) - } + isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} error={ - + parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( + + ) : ( + + ) } helpText={ ; + +export const icmpFormatters: ICMPFormatMap = { + [ConfigKeys.HOSTS]: null, + [ConfigKeys.WAIT]: (fields) => secondsToCronFormatter(fields[ConfigKeys.WAIT]), + ...commonFormatters, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/icmp/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/icmp/normalizers.ts new file mode 100644 index 0000000000000..18ce1da00e117 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/icmp/normalizers.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ICMPFields, ConfigKeys } from '../types'; +import { + Normalizer, + commonNormalizers, + getNormalizer, + getCronNormalizer, +} from '../common/normalizers'; +import { defaultICMPSimpleFields } from '../contexts'; + +export type ICMPNormalizerMap = Record; + +export const getICMPNormalizer = (key: ConfigKeys) => { + return getNormalizer(key, defaultICMPSimpleFields); +}; + +export const getICMPCronToSecondsNormalizer = (key: ConfigKeys) => { + return getCronNormalizer(key, defaultICMPSimpleFields); +}; + +export const icmpNormalizers: ICMPNormalizerMap = { + [ConfigKeys.HOSTS]: getICMPNormalizer(ConfigKeys.HOSTS), + [ConfigKeys.WAIT]: getICMPCronToSecondsNormalizer(ConfigKeys.WAIT), + ...commonNormalizers, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx index 3ca07c7067367..420f218429e40 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx @@ -33,7 +33,7 @@ export const ICMPSimpleFields = memo(({ validate }) => { defaultMessage="Host" /> } - isInvalid={!!validate[ConfigKeys.HOSTS]?.(fields[ConfigKeys.HOSTS])} + isInvalid={!!validate[ConfigKeys.HOSTS]?.(fields)} error={ (({ validate }) => { defaultMessage="Monitor interval" /> } - isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields[ConfigKeys.SCHEDULE])} + isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields)} error={ (({ validate }) => { defaultMessage="Wait in seconds" /> } - isInvalid={!!validate[ConfigKeys.WAIT]?.(fields[ConfigKeys.WAIT])} + isInvalid={!!validate[ConfigKeys.WAIT]?.(fields)} error={ (({ validate }) => { defaultMessage="Timeout in seconds" /> } - isInvalid={ - !!validate[ConfigKeys.TIMEOUT]?.( - fields[ConfigKeys.TIMEOUT], - fields[ConfigKeys.SCHEDULE].number, - fields[ConfigKeys.SCHEDULE].unit - ) - } + isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} error={ - + parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( + + ) : ( + + ) } helpText={ ( ({ newPolicy, onChange }) => { - const { monitorType } = useContext(MonitorTypeContext); - const { fields: httpSimpleFields } = useContext(HTTPSimpleFieldsContext); - const { fields: tcpSimpleFields } = useContext(TCPSimpleFieldsContext); - const { fields: icmpSimpleFields } = useContext(ICMPSimpleFieldsContext); - const { fields: httpAdvancedFields } = useContext(HTTPAdvancedFieldsContext); - const { fields: tcpAdvancedFields } = useContext(TCPAdvancedFieldsContext); - const { fields: tlsFields } = useContext(TLSFieldsContext); + const { monitorType } = useMonitorTypeContext(); + const { fields: httpSimpleFields } = useHTTPSimpleFieldsContext(); + const { fields: tcpSimpleFields } = useTCPSimpleFieldsContext(); + const { fields: icmpSimpleFields } = useICMPSimpleFieldsContext(); + const { fields: browserSimpleFields } = useBrowserSimpleFieldsContext(); + const { fields: httpAdvancedFields } = useHTTPAdvancedFieldsContext(); + const { fields: tcpAdvancedFields } = useTCPAdvancedFieldsContext(); + const { fields: browserAdvancedFields } = useBrowserAdvancedFieldsContext(); + const { fields: tlsFields } = useTLSFieldsContext(); + + const policyConfig: PolicyConfig = { + [DataStream.HTTP]: { + ...httpSimpleFields, + ...httpAdvancedFields, + ...tlsFields, + [ConfigKeys.NAME]: newPolicy.name, + } as HTTPFields, + [DataStream.TCP]: { + ...tcpSimpleFields, + ...tcpAdvancedFields, + ...tlsFields, + [ConfigKeys.NAME]: newPolicy.name, + } as TCPFields, + [DataStream.ICMP]: { + ...icmpSimpleFields, + [ConfigKeys.NAME]: newPolicy.name, + } as ICMPFields, + [DataStream.BROWSER]: { + ...browserSimpleFields, + ...browserAdvancedFields, + [ConfigKeys.NAME]: newPolicy.name, + } as BrowserFields, + }; + useTrackPageview({ app: 'fleet', path: 'syntheticsCreate' }); useTrackPageview({ app: 'fleet', path: 'syntheticsCreate', delay: 15000 }); - const { setConfig } = useUpdatePolicy({ + + const dataStreams: DataStream[] = useMemo(() => { + return newPolicy.inputs.map((input) => { + return input.type.replace(/synthetics\//g, '') as DataStream; + }); + }, [newPolicy]); + + useUpdatePolicy({ monitorType, - defaultConfig, + defaultConfig: defaultConfig[monitorType], + config: policyConfig[monitorType], newPolicy, onChange, validate, @@ -80,42 +130,7 @@ export const SyntheticsPolicyCreateExtension = memo { - setConfig(() => { - switch (monitorType) { - case DataStream.HTTP: - return { - ...httpSimpleFields, - ...httpAdvancedFields, - ...tlsFields, - }; - case DataStream.TCP: - return { - ...tcpSimpleFields, - ...tcpAdvancedFields, - ...tlsFields, - }; - case DataStream.ICMP: - return { - ...icmpSimpleFields, - }; - } - }); - }, - 250, - [ - setConfig, - httpSimpleFields, - tcpSimpleFields, - icmpSimpleFields, - httpAdvancedFields, - tcpAdvancedFields, - tlsFields, - ] - ); - - return ; + return ; } ); SyntheticsPolicyCreateExtension.displayName = 'SyntheticsPolicyCreateExtension'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx index 395b5d67abeb0..e642ea44ab58d 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import 'jest-canvas-mock'; + import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../lib/helper/rtl_helpers'; @@ -18,6 +20,10 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => `id-${Math.random()}`, })); +jest.mock('./code_editor', () => ({ + CodeEditor: () =>
code editor mock
, +})); + const defaultNewPolicy: NewPackagePolicy = { name: 'samplePolicyName', description: '', @@ -148,6 +154,7 @@ const defaultNewPolicy: NewPackagePolicy = { type: 'text', }, name: { + value: 'Sample name', type: 'text', }, schedule: { @@ -217,6 +224,7 @@ const defaultNewPolicy: NewPackagePolicy = { type: 'text', }, name: { + value: 'Sample name', type: 'text', }, schedule: { @@ -236,8 +244,53 @@ const defaultNewPolicy: NewPackagePolicy = { timeout: { type: 'text', }, - max_redirects: { - type: 'integer', + tags: { + type: 'yaml', + }, + }, + }, + ], + }, + { + type: 'synthetics/browser', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'browser', + }, + vars: { + type: { + value: 'browser', + type: 'text', + }, + name: { + value: 'Sample name', + type: 'text', + }, + schedule: { + value: '10s', + type: 'text', + }, + 'source.zip_url.url': { + type: 'text', + }, + 'source.zip_url.username': { + type: 'text', + }, + 'source.zip_url.password': { + type: 'password', + }, + 'source.zip_url.folder': { + type: 'text', + }, + 'source.inline.script': { + type: 'yaml', + }, + timeout: { + type: 'text', }, tags: { type: 'yaml', @@ -263,6 +316,10 @@ describe('', () => { return ; }; + beforeEach(() => { + onChange.mockClear(); + }); + it('renders SyntheticsPolicyCreateExtension', async () => { const { getByText, getByLabelText, queryByLabelText } = render(); const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; @@ -371,6 +428,7 @@ describe('', () => { }, defaultNewPolicy.inputs[1], defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); @@ -406,6 +464,7 @@ describe('', () => { }, defaultNewPolicy.inputs[1], defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); @@ -434,6 +493,7 @@ describe('', () => { enabled: true, }, defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); @@ -481,7 +541,7 @@ describe('', () => { const urlError = getByText('URL is required'); const monitorIntervalError = getByText('Monitor interval is required'); const maxRedirectsError = getByText('Max redirects must be 0 or greater'); - const timeoutError = getByText('Timeout must be 0 or greater and less than schedule interval'); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); expect(urlError).toBeInTheDocument(); expect(monitorIntervalError).toBeInTheDocument(); @@ -508,9 +568,7 @@ describe('', () => { expect(queryByText('URL is required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); expect(onChange).toBeCalledWith( expect.objectContaining({ isValid: true, @@ -537,9 +595,7 @@ describe('', () => { await waitFor(() => { const hostError = getByText('Host and port are required'); const monitorIntervalError = getByText('Monitor interval is required'); - const timeoutError = getByText( - 'Timeout must be 0 or greater and less than schedule interval' - ); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); expect(hostError).toBeInTheDocument(); expect(monitorIntervalError).toBeInTheDocument(); @@ -560,9 +616,7 @@ describe('', () => { expect(queryByText('Host and port are required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); expect(onChange).toBeCalledWith( expect.objectContaining({ isValid: true, @@ -591,9 +645,7 @@ describe('', () => { await waitFor(() => { const hostError = getByText('Host is required'); const monitorIntervalError = getByText('Monitor interval is required'); - const timeoutError = getByText( - 'Timeout must be 0 or greater and less than schedule interval' - ); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); const waitError = getByText('Wait must be 0 or greater'); expect(hostError).toBeInTheDocument(); @@ -616,9 +668,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('Host is required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); expect(queryByText('Wait must be 0 or greater')).not.toBeInTheDocument(); expect(onChange).toBeCalledWith( expect.objectContaining({ @@ -628,13 +678,67 @@ describe('', () => { }); }); + it('handles browser validation', async () => { + const { getByText, getByLabelText, queryByText, getByRole } = render(); + + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; + fireEvent.change(monitorType, { target: { value: DataStream.BROWSER } }); + + const zipUrl = getByRole('textbox', { name: 'Zip URL' }) as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(zipUrl, { target: { value: '' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + await waitFor(() => { + const hostError = getByText('Zip URL is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); + + expect(hostError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + // resolve errors + fireEvent.change(zipUrl, { target: { value: 'http://github.com/tests.zip' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '1' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + + await waitFor(() => { + expect(queryByText('Zip URL is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + + // test inline script validation + fireEvent.click(getByText('Inline script')); + + await waitFor(() => { + expect(getByText('Script is required')).toBeInTheDocument(); + }); + }); + it('handles changing TLS fields', async () => { const { findByLabelText, queryByLabelText } = render(); const enableSSL = queryByLabelText('Enable TLS configuration') as HTMLInputElement; await waitFor(() => { expect(onChange).toBeCalledWith({ - isValid: true, + isValid: false, updatedPolicy: { ...defaultNewPolicy, inputs: [ @@ -671,6 +775,7 @@ describe('', () => { }, defaultNewPolicy.inputs[1], defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); @@ -714,7 +819,7 @@ describe('', () => { await waitFor(() => { expect(onChange).toBeCalledWith({ - isValid: true, + isValid: false, updatedPolicy: { ...defaultNewPolicy, inputs: [ @@ -751,6 +856,7 @@ describe('', () => { }, defaultNewPolicy.inputs[1], defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx index 88bb8e7871459..0bc8f31f3d6cf 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension_wrapper.tsx @@ -13,6 +13,7 @@ import { TCPContextProvider, ICMPSimpleFieldsContextProvider, HTTPContextProvider, + BrowserContextProvider, TLSFieldsContextProvider, } from './contexts'; @@ -28,7 +29,9 @@ export const SyntheticsPolicyCreateExtensionWrapper = memo - + + + diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx index 8a3c42c10bc14..ec135e4e914a7 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx @@ -6,7 +6,6 @@ */ import React, { memo } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; import { PackagePolicyEditExtensionComponentProps } from '../../../../fleet/public'; import { useTrackPageview } from '../../../../observability/public'; import { @@ -17,8 +16,19 @@ import { useHTTPSimpleFieldsContext, useHTTPAdvancedFieldsContext, useTLSFieldsContext, + useBrowserSimpleFieldsContext, + useBrowserAdvancedFieldsContext, } from './contexts'; -import { PolicyConfig, DataStream } from './types'; +import { + ICustomFields, + DataStream, + HTTPFields, + TCPFields, + ICMPFields, + BrowserFields, + ConfigKeys, + PolicyConfig, +} from './types'; import { CustomFields } from './custom_fields'; import { useUpdatePolicy } from './use_update_policy'; import { validate } from './validation'; @@ -26,7 +36,7 @@ import { validate } from './validation'; interface SyntheticsPolicyEditExtensionProps { newPolicy: PackagePolicyEditExtensionComponentProps['newPolicy']; onChange: PackagePolicyEditExtensionComponentProps['onChange']; - defaultConfig: PolicyConfig; + defaultConfig: Partial; isTLSEnabled: boolean; } /** @@ -44,49 +54,42 @@ export const SyntheticsPolicyEditExtension = memo { - setConfig(() => { - switch (monitorType) { - case DataStream.HTTP: - return { - ...httpSimpleFields, - ...httpAdvancedFields, - ...tlsFields, - }; - case DataStream.TCP: - return { - ...tcpSimpleFields, - ...tcpAdvancedFields, - ...tlsFields, - }; - case DataStream.ICMP: - return { - ...icmpSimpleFields, - }; - } - }); - }, - 250, - [ - setConfig, - httpSimpleFields, - httpAdvancedFields, - tcpSimpleFields, - tcpAdvancedFields, - icmpSimpleFields, - tlsFields, - ] - ); - return ; } ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx index fec6c504a445f..d3c9030e85597 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import 'jest-canvas-mock'; + import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../lib/helper/rtl_helpers'; @@ -18,6 +20,10 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => `id-${Math.random()}`, })); +jest.mock('./code_editor', () => ({ + CodeEditor: () =>
code editor mock
, +})); + const defaultNewPolicy: NewPackagePolicy = { name: 'samplePolicyName', description: '', @@ -247,6 +253,54 @@ const defaultNewPolicy: NewPackagePolicy = { }, ], }, + { + type: 'synthetics/browser', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'browser', + }, + vars: { + type: { + value: 'browser', + type: 'text', + }, + name: { + value: 'Sample name', + type: 'text', + }, + schedule: { + value: '"@every 5s"', + type: 'text', + }, + 'source.zip_url.url': { + type: 'text', + }, + 'source.zip_url.username': { + type: 'text', + }, + 'source.zip_url.password': { + type: 'password', + }, + 'source.zip_url.folder': { + type: 'text', + }, + 'source.inline.script': { + type: 'yaml', + }, + timeout: { + type: 'text', + }, + tags: { + type: 'yaml', + }, + }, + }, + ], + }, ], package: { name: 'synthetics', @@ -268,6 +322,7 @@ const defaultCurrentPolicy: any = { const defaultHTTPConfig = defaultConfig[DataStream.HTTP]; const defaultICMPConfig = defaultConfig[DataStream.ICMP]; const defaultTCPConfig = defaultConfig[DataStream.TCP]; +const defaultBrowserConfig = defaultConfig[DataStream.BROWSER]; describe('', () => { const onChange = jest.fn(); @@ -281,6 +336,10 @@ describe('', () => { ); }; + beforeEach(() => { + onChange.mockClear(); + }); + it('renders SyntheticsPolicyEditExtension', async () => { const { getByText, getByLabelText, queryByLabelText } = render(); const url = getByLabelText('URL') as HTMLInputElement; @@ -400,6 +459,7 @@ describe('', () => { }, defaultNewPolicy.inputs[1], defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); @@ -435,6 +495,7 @@ describe('', () => { }, defaultNewPolicy.inputs[1], defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }, }); @@ -458,7 +519,7 @@ describe('', () => { const urlError = getByText('URL is required'); const monitorIntervalError = getByText('Monitor interval is required'); const maxRedirectsError = getByText('Max redirects must be 0 or greater'); - const timeoutError = getByText('Timeout must be 0 or greater and less than schedule interval'); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); expect(urlError).toBeInTheDocument(); expect(monitorIntervalError).toBeInTheDocument(); @@ -485,9 +546,7 @@ describe('', () => { expect(queryByText('URL is required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); expect(queryByText('Max redirects must be 0 or greater')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); expect(onChange).toBeCalledWith( expect.objectContaining({ isValid: true, @@ -496,6 +555,82 @@ describe('', () => { }); }); + it('handles browser validation', async () => { + const currentPolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[2], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[3], + enabled: true, + }, + ], + }; + const { getByText, getByLabelText, queryByText, getByRole } = render( + + ); + + const zipUrl = getByRole('textbox', { name: 'Zip URL' }) as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + + // create errors + fireEvent.change(zipUrl, { target: { value: '' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '-1' } }); + fireEvent.change(timeout, { target: { value: '-1' } }); + + await waitFor(() => { + const hostError = getByText('Zip URL is required'); + const monitorIntervalError = getByText('Monitor interval is required'); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); + + expect(hostError).toBeInTheDocument(); + expect(monitorIntervalError).toBeInTheDocument(); + expect(timeoutError).toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: false, + }) + ); + }); + + await waitFor(() => { + fireEvent.change(zipUrl, { target: { value: 'http://github.com/tests.zip' } }); + fireEvent.change(monitorIntervalNumber, { target: { value: '2' } }); + fireEvent.change(timeout, { target: { value: '1' } }); + expect(zipUrl.value).toEqual('http://github.com/tests.zip'); + expect(monitorIntervalNumber.value).toEqual('2'); + expect(timeout.value).toEqual('1'); + expect(queryByText('Zip URL is required')).not.toBeInTheDocument(); + expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + + await waitFor(() => { + expect(onChange).toBeCalledWith( + expect.objectContaining({ + isValid: true, + }) + ); + }); + }, 10000); + it('handles tcp validation', async () => { const currentPolicy = { ...defaultCurrentPolicy, @@ -509,6 +644,7 @@ describe('', () => { enabled: true, }, defaultNewPolicy.inputs[2], + defaultNewPolicy.inputs[3], ], }; const { getByText, getByLabelText, queryByText } = render( @@ -527,9 +663,7 @@ describe('', () => { await waitFor(() => { const hostError = getByText('Host and port are required'); const monitorIntervalError = getByText('Monitor interval is required'); - const timeoutError = getByText( - 'Timeout must be 0 or greater and less than schedule interval' - ); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); expect(hostError).toBeInTheDocument(); expect(monitorIntervalError).toBeInTheDocument(); @@ -549,9 +683,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('Host is required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); expect(onChange).toBeCalledWith( expect.objectContaining({ isValid: true, @@ -576,6 +708,7 @@ describe('', () => { ...defaultNewPolicy.inputs[2], enabled: true, }, + defaultNewPolicy.inputs[3], ], }; const { getByText, getByLabelText, queryByText } = render( @@ -596,9 +729,7 @@ describe('', () => { await waitFor(() => { const hostError = getByText('Host is required'); const monitorIntervalError = getByText('Monitor interval is required'); - const timeoutError = getByText( - 'Timeout must be 0 or greater and less than schedule interval' - ); + const timeoutError = getByText('Timeout must be greater than or equal to 0'); const waitError = getByText('Wait must be 0 or greater'); expect(hostError).toBeInTheDocument(); @@ -621,9 +752,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('Host is required')).not.toBeInTheDocument(); expect(queryByText('Monitor interval is required')).not.toBeInTheDocument(); - expect( - queryByText('Timeout must be 0 or greater and less than schedule interval') - ).not.toBeInTheDocument(); + expect(queryByText('Timeout must be greater than or equal to 0')).not.toBeInTheDocument(); expect(queryByText('Wait must be 0 or greater')).not.toBeInTheDocument(); expect(onChange).toBeCalledWith( expect.objectContaining({ @@ -640,6 +769,7 @@ describe('', () => { inputs: [ { ...defaultNewPolicy.inputs[0], + enabled: true, streams: [ { ...defaultNewPolicy.inputs[0].streams[0], @@ -663,6 +793,7 @@ describe('', () => { }, defaultCurrentPolicy.inputs[1], defaultCurrentPolicy.inputs[2], + defaultCurrentPolicy.inputs[3], ], }; const { getByText, getByLabelText, queryByLabelText, queryByText } = render( @@ -782,7 +913,7 @@ describe('', () => { }); it('handles null values for icmp', async () => { - const tcpVars = defaultNewPolicy.inputs[1].streams[0].vars; + const icmpVars = defaultNewPolicy.inputs[2].streams[0].vars; const currentPolicy: NewPackagePolicy = { ...defaultCurrentPolicy, inputs: [ @@ -801,12 +932,12 @@ describe('', () => { { ...defaultNewPolicy.inputs[2].streams[0], vars: { - ...Object.keys(tcpVars || []).reduce< + ...Object.keys(icmpVars || []).reduce< Record >((acc, key) => { acc[key] = { value: undefined, - type: `${tcpVars?.[key].type}`, + type: `${icmpVars?.[key].type}`, }; return acc; }, {}), @@ -846,4 +977,72 @@ describe('', () => { expect(queryByLabelText('Url')).not.toBeInTheDocument(); expect(queryByLabelText('Proxy URL')).not.toBeInTheDocument(); }); + + it('handles null values for browser', async () => { + const browserVars = defaultNewPolicy.inputs[3].streams[0].vars; + const currentPolicy: NewPackagePolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[2], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[3], + enabled: true, + streams: [ + { + ...defaultNewPolicy.inputs[3].streams[0], + vars: { + ...Object.keys(browserVars || []).reduce< + Record + >((acc, key) => { + acc[key] = { + value: undefined, + type: `${browserVars?.[key].type}`, + }; + return acc; + }, {}), + [ConfigKeys.MONITOR_TYPE]: { + value: DataStream.BROWSER, + type: 'text', + }, + }, + }, + ], + }, + ], + }; + const { getByLabelText, queryByLabelText, getByRole } = render( + + ); + const zipUrl = getByRole('textbox', { name: 'Zip URL' }) as HTMLInputElement; + const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; + const monitorIntervalUnit = getByLabelText('Unit') as HTMLInputElement; + const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; + const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; + expect(zipUrl).toBeInTheDocument(); + expect(zipUrl.value).toEqual(defaultBrowserConfig[ConfigKeys.SOURCE_ZIP_URL]); + expect(monitorIntervalNumber).toBeInTheDocument(); + expect(monitorIntervalNumber.value).toEqual(defaultBrowserConfig[ConfigKeys.SCHEDULE].number); + expect(monitorIntervalUnit).toBeInTheDocument(); + expect(monitorIntervalUnit.value).toEqual(defaultBrowserConfig[ConfigKeys.SCHEDULE].unit); + expect(apmServiceName).toBeInTheDocument(); + expect(apmServiceName.value).toEqual(defaultBrowserConfig[ConfigKeys.APM_SERVICE_NAME]); + expect(timeout).toBeInTheDocument(); + expect(timeout.value).toEqual(`${defaultBrowserConfig[ConfigKeys.TIMEOUT]}`); + + // ensure other monitor type options are not in the DOM + expect(queryByLabelText('Url')).not.toBeInTheDocument(); + expect(queryByLabelText('Proxy URL')).not.toBeInTheDocument(); + expect(queryByLabelText('Host')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx index 0bafef61166d2..d83130b21a0f1 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.tsx @@ -7,27 +7,17 @@ import React, { memo, useMemo } from 'react'; import { PackagePolicyEditExtensionComponentProps } from '../../../../fleet/public'; -import { - PolicyConfig, - ConfigKeys, - ContentType, - DataStream, - ICustomFields, - contentTypesToMode, -} from './types'; +import { PolicyConfig, ConfigKeys, DataStream, ITLSFields, ICustomFields } from './types'; import { SyntheticsPolicyEditExtension } from './synthetics_policy_edit_extension'; import { MonitorTypeContextProvider, HTTPContextProvider, TCPContextProvider, - defaultTCPSimpleFields, - defaultHTTPSimpleFields, - defaultICMPSimpleFields, - defaultHTTPAdvancedFields, - defaultTCPAdvancedFields, - defaultTLSFields, ICMPSimpleFieldsContextProvider, + BrowserContextProvider, + TLSFieldsContextProvider, } from './contexts'; +import { normalizers } from './helpers/normalizers'; /** * Exports Synthetics-specific package policy instructions @@ -35,123 +25,64 @@ import { */ export const SyntheticsPolicyEditExtensionWrapper = memo( ({ policy: currentPolicy, newPolicy, onChange }) => { - const { enableTLS: isTLSEnabled, config: defaultConfig, monitorType } = useMemo(() => { - const fallbackConfig: PolicyConfig = { - [DataStream.HTTP]: { - ...defaultHTTPSimpleFields, - ...defaultHTTPAdvancedFields, - ...defaultTLSFields, - }, - [DataStream.TCP]: { - ...defaultTCPSimpleFields, - ...defaultTCPAdvancedFields, - ...defaultTLSFields, - }, - [DataStream.ICMP]: defaultICMPSimpleFields, - }; + const { + enableTLS: isTLSEnabled, + fullConfig: fullDefaultConfig, + monitorTypeConfig: defaultConfig, + monitorType, + tlsConfig: defaultTLSConfig, + } = useMemo(() => { let enableTLS = false; const getDefaultConfig = () => { + // find the enabled input to identify the current monitor type const currentInput = currentPolicy.inputs.find((input) => input.enabled === true); - const vars = currentInput?.streams[0]?.vars; + /* Inputs can have multiple data streams. This is true of the `synthetics/browser` input, which includes the browser.network and browser.screenshot + * data streams. The `browser.network` and `browser.screenshot` data streams are used to store metadata and mappings. + * However, the `browser` data stream is where the variables for the policy are stored. For this reason, we only want + * to grab the data stream that exists within our explicitly defined list, which is the browser data stream */ + const vars = currentInput?.streams.find((stream) => + Object.values(DataStream).includes(stream.data_stream.dataset as DataStream) + )?.vars; + const type: DataStream = vars?.[ConfigKeys.MONITOR_TYPE].value as DataStream; - const fallbackConfigForMonitorType = fallbackConfig[type] as Partial; - const configKeys: ConfigKeys[] = Object.values(ConfigKeys); - const formatttedDefaultConfigForMonitorType = configKeys.reduce( - (acc: Record, key: ConfigKeys) => { - const value = vars?.[key]?.value; - switch (key) { - case ConfigKeys.NAME: - acc[key] = currentPolicy.name; - break; - case ConfigKeys.SCHEDULE: - // split unit and number - if (value) { - const fullString = JSON.parse(value); - const fullSchedule = fullString.replace('@every ', ''); - const unit = fullSchedule.slice(-1); - const number = fullSchedule.slice(0, fullSchedule.length - 1); - acc[key] = { - unit, - number, - }; - } else { - acc[key] = fallbackConfigForMonitorType[key]; - } - break; - case ConfigKeys.TIMEOUT: - case ConfigKeys.WAIT: - acc[key] = value - ? value.slice(0, value.length - 1) - : fallbackConfigForMonitorType[key]; // remove unit - break; - case ConfigKeys.TAGS: - case ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE: - case ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE: - case ConfigKeys.RESPONSE_STATUS_CHECK: - case ConfigKeys.RESPONSE_HEADERS_CHECK: - case ConfigKeys.REQUEST_HEADERS_CHECK: - acc[key] = value ? JSON.parse(value) : fallbackConfigForMonitorType[key]; - break; - case ConfigKeys.REQUEST_BODY_CHECK: - const headers = value - ? JSON.parse(vars?.[ConfigKeys.REQUEST_HEADERS_CHECK].value) - : fallbackConfigForMonitorType[ConfigKeys.REQUEST_HEADERS_CHECK]; - const requestBodyValue = - value !== null && value !== undefined - ? JSON.parse(value) - : fallbackConfigForMonitorType[key]?.value; - let requestBodyType = fallbackConfigForMonitorType[key]?.type; - Object.keys(headers || []).some((headerKey) => { - if ( - headerKey === 'Content-Type' && - contentTypesToMode[headers[headerKey] as ContentType] - ) { - requestBodyType = contentTypesToMode[headers[headerKey] as ContentType]; - return true; - } - }); - acc[key] = { - value: requestBodyValue, - type: requestBodyType, - }; - break; - case ConfigKeys.TLS_KEY_PASSPHRASE: - case ConfigKeys.TLS_VERIFICATION_MODE: - acc[key] = { - value: value ?? fallbackConfigForMonitorType[key]?.value, - isEnabled: !!value, - }; - if (!!value) { - enableTLS = true; - } - break; - case ConfigKeys.TLS_CERTIFICATE: - case ConfigKeys.TLS_CERTIFICATE_AUTHORITIES: - case ConfigKeys.TLS_KEY: - case ConfigKeys.TLS_VERSION: - acc[key] = { - value: value ? JSON.parse(value) : fallbackConfigForMonitorType[key]?.value, - isEnabled: !!value, - }; - if (!!value) { - enableTLS = true; - } - break; - default: - acc[key] = value ?? fallbackConfigForMonitorType[key]; - } - return acc; + const configKeys: ConfigKeys[] = Object.values(ConfigKeys) || ([] as ConfigKeys[]); + const formattedDefaultConfigForMonitorType: ICustomFields = configKeys.reduce( + (acc: ICustomFields, key: ConfigKeys) => { + return { + ...acc, + [key]: normalizers[key]?.(vars), + }; }, - {} + {} as ICustomFields ); - const formattedDefaultConfig: PolicyConfig = { - ...fallbackConfig, - [type]: formatttedDefaultConfigForMonitorType, + const tlsConfig: ITLSFields = { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: + formattedDefaultConfigForMonitorType[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES], + [ConfigKeys.TLS_CERTIFICATE]: + formattedDefaultConfigForMonitorType[ConfigKeys.TLS_CERTIFICATE], + [ConfigKeys.TLS_KEY]: formattedDefaultConfigForMonitorType[ConfigKeys.TLS_KEY], + [ConfigKeys.TLS_KEY_PASSPHRASE]: + formattedDefaultConfigForMonitorType[ConfigKeys.TLS_KEY_PASSPHRASE], + [ConfigKeys.TLS_VERIFICATION_MODE]: + formattedDefaultConfigForMonitorType[ConfigKeys.TLS_VERIFICATION_MODE], + [ConfigKeys.TLS_VERSION]: formattedDefaultConfigForMonitorType[ConfigKeys.TLS_VERSION], }; - return { config: formattedDefaultConfig, enableTLS, monitorType: type }; + enableTLS = Object.values(tlsConfig).some((value) => value?.isEnabled); + + const formattedDefaultConfig: Partial = { + [type]: formattedDefaultConfigForMonitorType, + }; + + return { + fullConfig: formattedDefaultConfig, + monitorTypeConfig: formattedDefaultConfigForMonitorType, + tlsConfig, + enableTLS, + monitorType: type, + }; }; return getDefaultConfig(); @@ -159,18 +90,22 @@ export const SyntheticsPolicyEditExtensionWrapper = memo - - - - - - - + + + + + + + + + + + ); } diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp/formatters.ts b/x-pack/plugins/uptime/public/components/fleet_package/tcp/formatters.ts new file mode 100644 index 0000000000000..2f4a43ee6becf --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp/formatters.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TCPFields, ConfigKeys } from '../types'; +import { Formatter, commonFormatters } from '../common/formatters'; +import { tlsFormatters } from '../tls/formatters'; + +export type TCPFormatMap = Record; + +export const tcpFormatters: TCPFormatMap = { + [ConfigKeys.HOSTS]: null, + [ConfigKeys.PROXY_URL]: null, + [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: null, + [ConfigKeys.RESPONSE_RECEIVE_CHECK]: null, + [ConfigKeys.REQUEST_SEND_CHECK]: null, + ...tlsFormatters, + ...commonFormatters, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/tcp/normalizers.ts new file mode 100644 index 0000000000000..d19aea55addf2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp/normalizers.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TCPFields, ConfigKeys } from '../types'; +import { Normalizer, commonNormalizers, getNormalizer } from '../common/normalizers'; +import { tlsNormalizers } from '../tls/normalizers'; +import { defaultTCPSimpleFields, defaultTCPAdvancedFields } from '../contexts'; + +const defaultTCPFields = { + ...defaultTCPSimpleFields, + ...defaultTCPAdvancedFields, +}; + +export type TCPNormalizerMap = Record; + +export const getTCPNormalizer = (key: ConfigKeys) => { + return getNormalizer(key, defaultTCPFields); +}; + +export const tcpNormalizers: TCPNormalizerMap = { + [ConfigKeys.HOSTS]: getTCPNormalizer(ConfigKeys.HOSTS), + [ConfigKeys.PROXY_URL]: getTCPNormalizer(ConfigKeys.PROXY_URL), + [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: getTCPNormalizer(ConfigKeys.PROXY_USE_LOCAL_RESOLVER), + [ConfigKeys.RESPONSE_RECEIVE_CHECK]: getTCPNormalizer(ConfigKeys.RESPONSE_RECEIVE_CHECK), + [ConfigKeys.REQUEST_SEND_CHECK]: getTCPNormalizer(ConfigKeys.REQUEST_SEND_CHECK), + ...tlsNormalizers, + ...commonNormalizers, +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx index 82c77a63611f2..8bc017a51cfa9 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx @@ -33,7 +33,7 @@ export const TCPSimpleFields = memo(({ validate }) => { defaultMessage="Host:Port" /> } - isInvalid={!!validate[ConfigKeys.HOSTS]?.(fields[ConfigKeys.HOSTS])} + isInvalid={!!validate[ConfigKeys.HOSTS]?.(fields)} error={ (({ validate }) => { defaultMessage="Monitor interval" /> } - isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields[ConfigKeys.SCHEDULE])} + isInvalid={!!validate[ConfigKeys.SCHEDULE]?.(fields)} error={ (({ validate }) => { defaultMessage="Timeout in seconds" /> } - isInvalid={ - !!validate[ConfigKeys.TIMEOUT]?.( - fields[ConfigKeys.TIMEOUT], - fields[ConfigKeys.SCHEDULE].number, - fields[ConfigKeys.SCHEDULE].unit - ) - } + isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} error={ - + parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( + + ) : ( + + ) } helpText={ ; + +export const tlsFormatters: TLSFormatMap = { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: (fields) => + tlsValueToYamlFormatter(fields[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]), + [ConfigKeys.TLS_CERTIFICATE]: (fields) => + tlsValueToYamlFormatter(fields[ConfigKeys.TLS_CERTIFICATE]), + [ConfigKeys.TLS_KEY]: (fields) => tlsValueToYamlFormatter(fields[ConfigKeys.TLS_KEY]), + [ConfigKeys.TLS_KEY_PASSPHRASE]: (fields) => + tlsValueToStringFormatter(fields[ConfigKeys.TLS_KEY_PASSPHRASE]), + [ConfigKeys.TLS_VERIFICATION_MODE]: (fields) => + tlsValueToStringFormatter(fields[ConfigKeys.TLS_VERIFICATION_MODE]), + [ConfigKeys.TLS_VERSION]: (fields) => tlsArrayToYamlFormatter(fields[ConfigKeys.TLS_VERSION]), +}; + +// only add tls settings if they are enabled by the user and isEnabled is true +export const tlsValueToYamlFormatter = (tlsValue: { value?: string; isEnabled?: boolean } = {}) => + tlsValue.isEnabled && tlsValue.value ? JSON.stringify(tlsValue.value) : null; + +export const tlsValueToStringFormatter = (tlsValue: { value?: string; isEnabled?: boolean } = {}) => + tlsValue.isEnabled && tlsValue.value ? tlsValue.value : null; + +export const tlsArrayToYamlFormatter = (tlsValue: { value?: string[]; isEnabled?: boolean } = {}) => + tlsValue.isEnabled && tlsValue.value?.length ? JSON.stringify(tlsValue.value) : null; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tls/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/tls/normalizers.ts new file mode 100644 index 0000000000000..2344e599d6c01 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/tls/normalizers.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ITLSFields, ConfigKeys } from '../types'; +import { Normalizer } from '../common/normalizers'; +import { defaultTLSFields } from '../contexts'; + +type TLSNormalizerMap = Record; + +export const tlsNormalizers: TLSNormalizerMap = { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: (fields) => + tlsYamlToObjectNormalizer( + fields?.[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]?.value, + ConfigKeys.TLS_CERTIFICATE_AUTHORITIES + ), + [ConfigKeys.TLS_CERTIFICATE]: (fields) => + tlsYamlToObjectNormalizer( + fields?.[ConfigKeys.TLS_CERTIFICATE]?.value, + ConfigKeys.TLS_CERTIFICATE + ), + [ConfigKeys.TLS_KEY]: (fields) => + tlsYamlToObjectNormalizer(fields?.[ConfigKeys.TLS_KEY]?.value, ConfigKeys.TLS_KEY), + [ConfigKeys.TLS_KEY_PASSPHRASE]: (fields) => + tlsStringToObjectNormalizer( + fields?.[ConfigKeys.TLS_KEY_PASSPHRASE]?.value, + ConfigKeys.TLS_KEY_PASSPHRASE + ), + [ConfigKeys.TLS_VERIFICATION_MODE]: (fields) => + tlsStringToObjectNormalizer( + fields?.[ConfigKeys.TLS_VERIFICATION_MODE]?.value, + ConfigKeys.TLS_VERIFICATION_MODE + ), + [ConfigKeys.TLS_VERSION]: (fields) => + tlsYamlToObjectNormalizer(fields?.[ConfigKeys.TLS_VERSION]?.value, ConfigKeys.TLS_VERSION), +}; + +// only add tls settings if they are enabled by the user and isEnabled is true +export const tlsStringToObjectNormalizer = (value: string = '', key: keyof ITLSFields) => ({ + value: value ?? defaultTLSFields[key]?.value, + isEnabled: Boolean(value), +}); +export const tlsYamlToObjectNormalizer = (value: string = '', key: keyof ITLSFields) => ({ + value: value ? JSON.parse(value) : defaultTLSFields[key]?.value, + isEnabled: Boolean(value), +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx index 7a16d1352c40a..89581bf993339 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx @@ -9,6 +9,7 @@ export enum DataStream { HTTP = 'http', TCP = 'tcp', ICMP = 'icmp', + BROWSER = 'browser', } export enum HTTPMethod { @@ -65,6 +66,12 @@ export enum TLSVersion { ONE_THREE = 'TLSv1.3', } +export enum ScreenshotOption { + ON = 'on', + OFF = 'off', + ONLY_ON_FAILURE = 'only-on-failure', +} + // values must match keys in the integration package export enum ConfigKeys { APM_SERVICE_NAME = 'service.name', @@ -87,6 +94,14 @@ export enum ConfigKeys { REQUEST_METHOD_CHECK = 'check.request.method', REQUEST_SEND_CHECK = 'check.send', SCHEDULE = 'schedule', + SCREENSHOTS = 'screenshots', + SOURCE_INLINE = 'source.inline.script', + SOURCE_ZIP_URL = 'source.zip_url.url', + SOURCE_ZIP_USERNAME = 'source.zip_url.username', + SOURCE_ZIP_PASSWORD = 'source.zip_url.password', + SOURCE_ZIP_FOLDER = 'source.zip_url.folder', + SYNTHETICS_ARGS = 'synthetics_args', + PARAMS = 'params', TLS_CERTIFICATE_AUTHORITIES = 'ssl.certificate_authorities', TLS_CERTIFICATE = 'ssl.certificate', TLS_KEY = 'ssl.key', @@ -100,18 +115,6 @@ export enum ConfigKeys { WAIT = 'wait', } -export interface ISimpleFields { - [ConfigKeys.HOSTS]: string; - [ConfigKeys.MAX_REDIRECTS]: string; - [ConfigKeys.MONITOR_TYPE]: DataStream; - [ConfigKeys.SCHEDULE]: { number: string; unit: ScheduleUnit }; - [ConfigKeys.APM_SERVICE_NAME]: string; - [ConfigKeys.TIMEOUT]: string; - [ConfigKeys.URLS]: string; - [ConfigKeys.TAGS]: string[]; - [ConfigKeys.WAIT]: string; -} - export interface ICommonFields { [ConfigKeys.MONITOR_TYPE]: DataStream; [ConfigKeys.SCHEDULE]: { number: string; unit: ScheduleUnit }; @@ -183,13 +186,29 @@ export interface ITCPAdvancedFields { [ConfigKeys.REQUEST_SEND_CHECK]: string; } +export type IBrowserSimpleFields = { + [ConfigKeys.SOURCE_INLINE]: string; + [ConfigKeys.SOURCE_ZIP_URL]: string; + [ConfigKeys.SOURCE_ZIP_FOLDER]: string; + [ConfigKeys.SOURCE_ZIP_USERNAME]: string; + [ConfigKeys.SOURCE_ZIP_PASSWORD]: string; + [ConfigKeys.PARAMS]: string; +} & ICommonFields; + +export interface IBrowserAdvancedFields { + [ConfigKeys.SYNTHETICS_ARGS]: string[]; + [ConfigKeys.SCREENSHOTS]: string; +} + export type HTTPFields = IHTTPSimpleFields & IHTTPAdvancedFields & ITLSFields; export type TCPFields = ITCPSimpleFields & ITCPAdvancedFields & ITLSFields; export type ICMPFields = IICMPSimpleFields; +export type BrowserFields = IBrowserSimpleFields & IBrowserAdvancedFields; export type ICustomFields = HTTPFields & TCPFields & - ICMPFields & { + ICMPFields & + BrowserFields & { [ConfigKeys.NAME]: string; }; @@ -197,9 +216,12 @@ export interface PolicyConfig { [DataStream.HTTP]: HTTPFields; [DataStream.TCP]: TCPFields; [DataStream.ICMP]: ICMPFields; + [DataStream.BROWSER]: BrowserFields; } -export type Validation = Partial boolean>>; +export type Validator = (config: Partial) => boolean; + +export type Validation = Partial>; export const contentTypesToMode = { [ContentType.FORM]: Mode.FORM, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx index 5a62aec90032d..d57a69860311c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx @@ -6,10 +6,21 @@ */ import { useUpdatePolicy } from './use_update_policy'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import { NewPackagePolicy } from '../../../../fleet/public'; import { validate } from './validation'; -import { ConfigKeys, DataStream, TLSVersion } from './types'; +import { + ConfigKeys, + DataStream, + TLSVersion, + ICommonFields, + ScheduleUnit, + ICMPFields, + TCPFields, + ITLSFields, + HTTPFields, + BrowserFields, +} from './types'; import { defaultConfig } from './synthetics_policy_create_extension'; describe('useBarChartsHooks', () => { @@ -245,6 +256,63 @@ describe('useBarChartsHooks', () => { }, ], }, + { + type: 'synthetics/browser', + enabled: false, + streams: [ + { + enabled: false, + data_stream: { + type: 'synthetics', + dataset: 'browser', + }, + vars: { + type: { + value: 'browser', + type: 'text', + }, + name: { + value: 'Sample name', + type: 'text', + }, + schedule: { + value: '10s', + type: 'text', + }, + 'source.zip_url.url': { + type: 'text', + }, + 'source.zip_url.username': { + type: 'text', + }, + 'source.zip_url.password': { + type: 'password', + }, + 'source.zip_url.folder': { + type: 'text', + }, + 'source.inline.script': { + type: 'yaml', + }, + 'service.name': { + type: 'text', + }, + screenshots: { + type: 'text', + }, + synthetics_args: { + type: 'yaml', + }, + timeout: { + type: 'text', + }, + tags: { + type: 'yaml', + }, + }, + }, + ], + }, ], package: { name: 'synthetics', @@ -253,84 +321,117 @@ describe('useBarChartsHooks', () => { }, }; - it('handles http data stream', () => { + const defaultCommonFields: Partial = { + [ConfigKeys.APM_SERVICE_NAME]: 'APM Service name', + [ConfigKeys.TAGS]: ['some', 'tags'], + [ConfigKeys.SCHEDULE]: { + number: '5', + unit: ScheduleUnit.MINUTES, + }, + [ConfigKeys.TIMEOUT]: '17', + }; + + const defaultTLSFields: Partial = { + [ConfigKeys.TLS_CERTIFICATE_AUTHORITIES]: { + isEnabled: true, + value: 'ca', + }, + [ConfigKeys.TLS_CERTIFICATE]: { + isEnabled: true, + value: 'cert', + }, + [ConfigKeys.TLS_KEY]: { + isEnabled: true, + value: 'key', + }, + [ConfigKeys.TLS_KEY_PASSPHRASE]: { + isEnabled: true, + value: 'password', + }, + }; + + it('handles http data stream', async () => { const onChange = jest.fn(); - const { result } = renderHook((props) => useUpdatePolicy(props), { - initialProps: { defaultConfig, newPolicy, onChange, validate, monitorType: DataStream.HTTP }, + const initialProps = { + defaultConfig: defaultConfig[DataStream.HTTP], + config: defaultConfig[DataStream.HTTP], + newPolicy, + onChange, + validate, + monitorType: DataStream.HTTP, + }; + const { result, rerender, waitFor } = renderHook((props) => useUpdatePolicy(props), { + initialProps, }); expect(result.current.config).toMatchObject({ ...defaultConfig[DataStream.HTTP] }); + const config: HTTPFields = { + ...defaultConfig[DataStream.HTTP], + ...defaultCommonFields, + ...defaultTLSFields, + [ConfigKeys.URLS]: 'url', + [ConfigKeys.PROXY_URL]: 'proxyUrl', + }; + // expect only http to be enabled expect(result.current.updatedPolicy.inputs[0].enabled).toBe(true); expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[3].enabled).toBe(false); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.MONITOR_TYPE].value - ).toEqual(defaultConfig[DataStream.HTTP][ConfigKeys.MONITOR_TYPE]); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.URLS].value - ).toEqual(defaultConfig[DataStream.HTTP][ConfigKeys.URLS]); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.SCHEDULE].value - ).toEqual( - JSON.stringify( - `@every ${defaultConfig[DataStream.HTTP][ConfigKeys.SCHEDULE].number}${ - defaultConfig[DataStream.HTTP][ConfigKeys.SCHEDULE].unit - }` - ) - ); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.PROXY_URL].value - ).toEqual(defaultConfig[DataStream.HTTP][ConfigKeys.PROXY_URL]); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.APM_SERVICE_NAME].value - ).toEqual(defaultConfig[DataStream.HTTP][ConfigKeys.APM_SERVICE_NAME]); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TIMEOUT].value - ).toEqual(`${defaultConfig[DataStream.HTTP][ConfigKeys.TIMEOUT]}s`); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ - ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE - ].value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ - ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE - ].value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_STATUS_CHECK] - .value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.REQUEST_HEADERS_CHECK] - .value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_HEADERS_CHECK] - .value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_BODY_INDEX] - .value - ).toEqual(defaultConfig[DataStream.HTTP][ConfigKeys.RESPONSE_BODY_INDEX]); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_HEADERS_INDEX] - .value - ).toEqual(defaultConfig[DataStream.HTTP][ConfigKeys.RESPONSE_HEADERS_INDEX]); + rerender({ + ...initialProps, + config, + }); + + await waitFor(() => { + const vars = result.current.updatedPolicy.inputs[0]?.streams[0]?.vars; + + expect(vars?.[ConfigKeys.MONITOR_TYPE].value).toEqual(config[ConfigKeys.MONITOR_TYPE]); + expect(vars?.[ConfigKeys.URLS].value).toEqual(config[ConfigKeys.URLS]); + expect(vars?.[ConfigKeys.SCHEDULE].value).toEqual( + JSON.stringify( + `@every ${config[ConfigKeys.SCHEDULE].number}${config[ConfigKeys.SCHEDULE].unit}` + ) + ); + expect(vars?.[ConfigKeys.PROXY_URL].value).toEqual(config[ConfigKeys.PROXY_URL]); + expect(vars?.[ConfigKeys.APM_SERVICE_NAME].value).toEqual( + config[ConfigKeys.APM_SERVICE_NAME] + ); + expect(vars?.[ConfigKeys.TIMEOUT].value).toEqual(`${config[ConfigKeys.TIMEOUT]}s`); + expect(vars?.[ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE].value).toEqual(null); + expect(vars?.[ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE].value).toEqual(null); + expect(vars?.[ConfigKeys.RESPONSE_STATUS_CHECK].value).toEqual(null); + expect(vars?.[ConfigKeys.REQUEST_HEADERS_CHECK].value).toEqual(null); + expect(vars?.[ConfigKeys.RESPONSE_HEADERS_CHECK].value).toEqual(null); + expect(vars?.[ConfigKeys.RESPONSE_BODY_INDEX].value).toEqual( + config[ConfigKeys.RESPONSE_BODY_INDEX] + ); + expect(vars?.[ConfigKeys.RESPONSE_HEADERS_INDEX].value).toEqual( + config[ConfigKeys.RESPONSE_HEADERS_INDEX] + ); + }); }); - it('stringifies array values and returns null for empty array values', () => { + it('stringifies array values and returns null for empty array values', async () => { const onChange = jest.fn(); - const { result } = renderHook((props) => useUpdatePolicy(props), { - initialProps: { defaultConfig, newPolicy, onChange, validate, monitorType: DataStream.HTTP }, + const initialProps = { + defaultConfig: defaultConfig[DataStream.HTTP], + config: defaultConfig[DataStream.HTTP], + newPolicy, + onChange, + validate, + monitorType: DataStream.HTTP, + }; + const { rerender, result, waitFor } = renderHook((props) => useUpdatePolicy(props), { + initialProps, }); - act(() => { - result.current.setConfig({ - ...defaultConfig, + rerender({ + ...initialProps, + config: { + ...defaultConfig[DataStream.HTTP], [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: ['test'], [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: ['test'], [ConfigKeys.RESPONSE_STATUS_CHECK]: ['test'], @@ -339,38 +440,29 @@ describe('useBarChartsHooks', () => { value: [TLSVersion.ONE_ONE], isEnabled: true, }, - }); + }, }); - // expect only http to be enabled - expect(result.current.updatedPolicy.inputs[0].enabled).toBe(true); - expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); - expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + await waitFor(() => { + // expect only http to be enabled + expect(result.current.updatedPolicy.inputs[0].enabled).toBe(true); + expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[3].enabled).toBe(false); + + const vars = result.current.updatedPolicy.inputs[0]?.streams[0]?.vars; + + expect(vars?.[ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE].value).toEqual('["test"]'); + expect(vars?.[ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE].value).toEqual('["test"]'); + expect(vars?.[ConfigKeys.RESPONSE_STATUS_CHECK].value).toEqual('["test"]'); + expect(vars?.[ConfigKeys.TAGS].value).toEqual('["test"]'); + expect(vars?.[ConfigKeys.TLS_VERSION].value).toEqual('["TLSv1.1"]'); + }); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ - ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE - ].value - ).toEqual('["test"]'); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ - ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE - ].value - ).toEqual('["test"]'); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_STATUS_CHECK] - .value - ).toEqual('["test"]'); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TAGS].value - ).toEqual('["test"]'); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TLS_VERSION].value - ).toEqual('["TLSv1.1"]'); - - act(() => { - result.current.setConfig({ - ...defaultConfig, + rerender({ + ...initialProps, + config: { + ...defaultConfig[DataStream.HTTP], [ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE]: [], [ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE]: [], [ConfigKeys.RESPONSE_STATUS_CHECK]: [], @@ -379,125 +471,207 @@ describe('useBarChartsHooks', () => { value: [], isEnabled: true, }, - }); + }, }); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ - ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE - ].value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ - ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE - ].value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_STATUS_CHECK] - .value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TAGS].value - ).toEqual(null); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.TLS_VERSION].value - ).toEqual(null); + await waitFor(() => { + const vars = result.current.updatedPolicy.inputs[0]?.streams[0]?.vars; + + expect(vars?.[ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE].value).toEqual(null); + expect(vars?.[ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE].value).toEqual(null); + expect(vars?.[ConfigKeys.RESPONSE_STATUS_CHECK].value).toEqual(null); + expect(vars?.[ConfigKeys.TAGS].value).toEqual(null); + expect(vars?.[ConfigKeys.TLS_VERSION].value).toEqual(null); + }); }); - it('handles tcp data stream', () => { + it('handles tcp data stream', async () => { const onChange = jest.fn(); - const { result } = renderHook((props) => useUpdatePolicy(props), { - initialProps: { defaultConfig, newPolicy, onChange, validate, monitorType: DataStream.TCP }, + const initialProps = { + defaultConfig: defaultConfig[DataStream.TCP], + config: defaultConfig[DataStream.TCP], + newPolicy, + onChange, + validate, + monitorType: DataStream.TCP, + }; + const { result, rerender, waitFor } = renderHook((props) => useUpdatePolicy(props), { + initialProps, }); // expect only tcp to be enabled expect(result.current.updatedPolicy.inputs[0].enabled).toBe(false); expect(result.current.updatedPolicy.inputs[1].enabled).toBe(true); expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[3].enabled).toBe(false); + + const config: TCPFields = { + ...defaultConfig[DataStream.TCP], + ...defaultCommonFields, + ...defaultTLSFields, + [ConfigKeys.HOSTS]: 'sampleHost', + [ConfigKeys.PROXY_URL]: 'proxyUrl', + [ConfigKeys.PROXY_USE_LOCAL_RESOLVER]: true, + [ConfigKeys.RESPONSE_RECEIVE_CHECK]: 'response', + [ConfigKeys.REQUEST_SEND_CHECK]: 'request', + }; - expect(onChange).toBeCalledWith({ - isValid: false, - updatedPolicy: result.current.updatedPolicy, + rerender({ + ...initialProps, + config, }); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.MONITOR_TYPE].value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.MONITOR_TYPE]); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.HOSTS].value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.HOSTS]); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.SCHEDULE].value - ).toEqual( - JSON.stringify( - `@every ${defaultConfig[DataStream.TCP][ConfigKeys.SCHEDULE].number}${ - defaultConfig[DataStream.TCP][ConfigKeys.SCHEDULE].unit - }` - ) - ); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.PROXY_URL].value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.PROXY_URL]); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.APM_SERVICE_NAME].value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.APM_SERVICE_NAME]); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.TIMEOUT].value - ).toEqual(`${defaultConfig[DataStream.TCP][ConfigKeys.TIMEOUT]}s`); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ - ConfigKeys.PROXY_USE_LOCAL_RESOLVER - ].value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.PROXY_USE_LOCAL_RESOLVER]); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.RESPONSE_RECEIVE_CHECK] - .value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.RESPONSE_RECEIVE_CHECK]); - expect( - result.current.updatedPolicy.inputs[1]?.streams[0]?.vars?.[ConfigKeys.REQUEST_SEND_CHECK] - .value - ).toEqual(defaultConfig[DataStream.TCP][ConfigKeys.REQUEST_SEND_CHECK]); + await waitFor(() => { + const vars = result.current.updatedPolicy.inputs[1]?.streams[0]?.vars; + + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: result.current.updatedPolicy, + }); + + expect(vars?.[ConfigKeys.MONITOR_TYPE].value).toEqual(config[ConfigKeys.MONITOR_TYPE]); + expect(vars?.[ConfigKeys.HOSTS].value).toEqual(config[ConfigKeys.HOSTS]); + expect(vars?.[ConfigKeys.SCHEDULE].value).toEqual( + JSON.stringify( + `@every ${config[ConfigKeys.SCHEDULE].number}${config[ConfigKeys.SCHEDULE].unit}` + ) + ); + expect(vars?.[ConfigKeys.PROXY_URL].value).toEqual(config[ConfigKeys.PROXY_URL]); + expect(vars?.[ConfigKeys.APM_SERVICE_NAME].value).toEqual( + config[ConfigKeys.APM_SERVICE_NAME] + ); + expect(vars?.[ConfigKeys.TIMEOUT].value).toEqual(`${config[ConfigKeys.TIMEOUT]}s`); + expect(vars?.[ConfigKeys.PROXY_USE_LOCAL_RESOLVER].value).toEqual( + config[ConfigKeys.PROXY_USE_LOCAL_RESOLVER] + ); + expect(vars?.[ConfigKeys.RESPONSE_RECEIVE_CHECK].value).toEqual( + config[ConfigKeys.RESPONSE_RECEIVE_CHECK] + ); + expect(vars?.[ConfigKeys.REQUEST_SEND_CHECK].value).toEqual( + config[ConfigKeys.REQUEST_SEND_CHECK] + ); + }); }); - it('handles icmp data stream', () => { + it('handles icmp data stream', async () => { const onChange = jest.fn(); - const { result } = renderHook((props) => useUpdatePolicy(props), { - initialProps: { defaultConfig, newPolicy, onChange, validate, monitorType: DataStream.ICMP }, + const initialProps = { + defaultConfig: defaultConfig[DataStream.ICMP], + config: defaultConfig[DataStream.ICMP], + newPolicy, + onChange, + validate, + monitorType: DataStream.ICMP, + }; + const { rerender, result, waitFor } = renderHook((props) => useUpdatePolicy(props), { + initialProps, }); + const config: ICMPFields = { + ...defaultConfig[DataStream.ICMP], + ...defaultCommonFields, + [ConfigKeys.WAIT]: '2', + [ConfigKeys.HOSTS]: 'sampleHost', + }; // expect only icmp to be enabled expect(result.current.updatedPolicy.inputs[0].enabled).toBe(false); expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); expect(result.current.updatedPolicy.inputs[2].enabled).toBe(true); + expect(result.current.updatedPolicy.inputs[3].enabled).toBe(false); + + // only call onChange when the policy is changed + rerender({ + ...initialProps, + config, + }); + + await waitFor(() => { + const vars = result.current.updatedPolicy.inputs[2]?.streams[0]?.vars; + + expect(vars?.[ConfigKeys.MONITOR_TYPE].value).toEqual(config[ConfigKeys.MONITOR_TYPE]); + expect(vars?.[ConfigKeys.HOSTS].value).toEqual(config[ConfigKeys.HOSTS]); + expect(vars?.[ConfigKeys.SCHEDULE].value).toEqual( + JSON.stringify( + `@every ${config[ConfigKeys.SCHEDULE].number}${config[ConfigKeys.SCHEDULE].unit}` + ) + ); + expect(vars?.[ConfigKeys.APM_SERVICE_NAME].value).toEqual( + config[ConfigKeys.APM_SERVICE_NAME] + ); + expect(vars?.[ConfigKeys.TIMEOUT].value).toEqual(`${config[ConfigKeys.TIMEOUT]}s`); + expect(vars?.[ConfigKeys.WAIT].value).toEqual(`${config[ConfigKeys.WAIT]}s`); + + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: result.current.updatedPolicy, + }); + }); + }); - expect(onChange).toBeCalledWith({ - isValid: false, - updatedPolicy: result.current.updatedPolicy, + it('handles browser data stream', async () => { + const onChange = jest.fn(); + const initialProps = { + defaultConfig: defaultConfig[DataStream.BROWSER], + config: defaultConfig[DataStream.BROWSER], + newPolicy, + onChange, + validate, + monitorType: DataStream.BROWSER, + }; + const { result, rerender, waitFor } = renderHook((props) => useUpdatePolicy(props), { + initialProps, + }); + + // expect only browser to be enabled + expect(result.current.updatedPolicy.inputs[0].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[1].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[2].enabled).toBe(false); + expect(result.current.updatedPolicy.inputs[3].enabled).toBe(true); + + const config: BrowserFields = { + ...defaultConfig[DataStream.BROWSER], + ...defaultCommonFields, + [ConfigKeys.SOURCE_INLINE]: 'inlineScript', + [ConfigKeys.SOURCE_ZIP_URL]: 'zipFolder', + [ConfigKeys.SOURCE_ZIP_FOLDER]: 'zipFolder', + [ConfigKeys.SOURCE_ZIP_USERNAME]: 'username', + [ConfigKeys.SOURCE_ZIP_PASSWORD]: 'password', + [ConfigKeys.SCREENSHOTS]: 'off', + [ConfigKeys.SYNTHETICS_ARGS]: ['args'], + }; + + rerender({ + ...initialProps, + config, }); - expect( - result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.MONITOR_TYPE].value - ).toEqual(defaultConfig[DataStream.ICMP][ConfigKeys.MONITOR_TYPE]); - expect( - result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.HOSTS].value - ).toEqual(defaultConfig[DataStream.ICMP][ConfigKeys.HOSTS]); - expect( - result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.SCHEDULE].value - ).toEqual( - JSON.stringify( - `@every ${defaultConfig[DataStream.ICMP][ConfigKeys.SCHEDULE].number}${ - defaultConfig[DataStream.ICMP][ConfigKeys.SCHEDULE].unit - }` - ) - ); - expect( - result.current.updatedPolicy.inputs[0]?.streams[0]?.vars?.[ConfigKeys.APM_SERVICE_NAME].value - ).toEqual(defaultConfig[DataStream.ICMP][ConfigKeys.APM_SERVICE_NAME]); - expect( - result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.TIMEOUT].value - ).toEqual(`${defaultConfig[DataStream.ICMP][ConfigKeys.TIMEOUT]}s`); - expect( - result.current.updatedPolicy.inputs[2]?.streams[0]?.vars?.[ConfigKeys.WAIT].value - ).toEqual(`${defaultConfig[DataStream.ICMP][ConfigKeys.WAIT]}s`); + await waitFor(() => { + const vars = result.current.updatedPolicy.inputs[3]?.streams[0]?.vars; + + expect(vars?.[ConfigKeys.SOURCE_ZIP_FOLDER].value).toEqual( + config[ConfigKeys.SOURCE_ZIP_FOLDER] + ); + expect(vars?.[ConfigKeys.SOURCE_ZIP_PASSWORD].value).toEqual( + config[ConfigKeys.SOURCE_ZIP_PASSWORD] + ); + expect(vars?.[ConfigKeys.SOURCE_ZIP_URL].value).toEqual(config[ConfigKeys.SOURCE_ZIP_URL]); + expect(vars?.[ConfigKeys.SOURCE_INLINE].value).toEqual(config[ConfigKeys.SOURCE_INLINE]); + expect(vars?.[ConfigKeys.SOURCE_ZIP_PASSWORD].value).toEqual( + config[ConfigKeys.SOURCE_ZIP_PASSWORD] + ); + expect(vars?.[ConfigKeys.SCREENSHOTS].value).toEqual(config[ConfigKeys.SCREENSHOTS]); + expect(vars?.[ConfigKeys.SYNTHETICS_ARGS].value).toEqual( + JSON.stringify(config[ConfigKeys.SYNTHETICS_ARGS]) + ); + expect(vars?.[ConfigKeys.APM_SERVICE_NAME].value).toEqual( + config[ConfigKeys.APM_SERVICE_NAME] + ); + expect(vars?.[ConfigKeys.TIMEOUT].value).toEqual(`${config[ConfigKeys.TIMEOUT]}s`); + + expect(onChange).toBeCalledWith({ + isValid: false, + updatedPolicy: result.current.updatedPolicy, + }); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts index 2b2fb22866463..145a86c6bd50d 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts @@ -6,11 +6,13 @@ */ import { useEffect, useRef, useState } from 'react'; import { NewPackagePolicy } from '../../../../fleet/public'; -import { ConfigKeys, PolicyConfig, DataStream, Validation, ICustomFields } from './types'; +import { ConfigKeys, DataStream, Validation, ICustomFields } from './types'; +import { formatters } from './helpers/formatters'; interface Props { monitorType: DataStream; - defaultConfig: PolicyConfig; + defaultConfig: Partial; + config: Partial; newPolicy: NewPackagePolicy; onChange: (opts: { /** is current form state is valid */ @@ -24,85 +26,45 @@ interface Props { export const useUpdatePolicy = ({ monitorType, defaultConfig, + config, newPolicy, onChange, validate, }: Props) => { const [updatedPolicy, setUpdatedPolicy] = useState(newPolicy); // Update the integration policy with our custom fields - const [config, setConfig] = useState>(defaultConfig[monitorType]); - const currentConfig = useRef>(defaultConfig[monitorType]); + const currentConfig = useRef>(defaultConfig); useEffect(() => { const configKeys = Object.keys(config) as ConfigKeys[]; const validationKeys = Object.keys(validate[monitorType]) as ConfigKeys[]; const configDidUpdate = configKeys.some((key) => config[key] !== currentConfig.current[key]); const isValid = - !!newPolicy.name && !validationKeys.find((key) => validate[monitorType][key]?.(config[key])); + !!newPolicy.name && !validationKeys.find((key) => validate[monitorType]?.[key]?.(config)); const formattedPolicy = { ...newPolicy }; const currentInput = formattedPolicy.inputs.find( (input) => input.type === `synthetics/${monitorType}` ); - const dataStream = currentInput?.streams[0]; - - // prevent an infinite loop of updating the policy - if (currentInput && dataStream && configDidUpdate) { + const dataStream = currentInput?.streams.find( + (stream) => stream.data_stream.dataset === monitorType + ); + formattedPolicy.inputs.forEach((input) => (input.enabled = false)); + if (currentInput && dataStream) { // reset all data streams to enabled false formattedPolicy.inputs.forEach((input) => (input.enabled = false)); // enable only the input type and data stream that matches the monitor type. currentInput.enabled = true; dataStream.enabled = true; + } + + // prevent an infinite loop of updating the policy + if (currentInput && dataStream && configDidUpdate) { configKeys.forEach((key) => { const configItem = dataStream.vars?.[key]; - if (configItem) { - switch (key) { - case ConfigKeys.SCHEDULE: - configItem.value = JSON.stringify( - `@every ${config[key]?.number}${config[key]?.unit}` - ); // convert to cron - break; - case ConfigKeys.RESPONSE_BODY_CHECK_NEGATIVE: - case ConfigKeys.RESPONSE_BODY_CHECK_POSITIVE: - case ConfigKeys.RESPONSE_STATUS_CHECK: - case ConfigKeys.TAGS: - configItem.value = config[key]?.length ? JSON.stringify(config[key]) : null; - break; - case ConfigKeys.RESPONSE_HEADERS_CHECK: - case ConfigKeys.REQUEST_HEADERS_CHECK: - configItem.value = Object.keys(config?.[key] || []).length - ? JSON.stringify(config[key]) - : null; - break; - case ConfigKeys.TIMEOUT: - case ConfigKeys.WAIT: - configItem.value = config[key] ? `${config[key]}s` : null; // convert to cron - break; - case ConfigKeys.REQUEST_BODY_CHECK: - configItem.value = config[key]?.value ? JSON.stringify(config[key]?.value) : null; // only need value of REQUEST_BODY_CHECK for outputted policy - break; - case ConfigKeys.TLS_CERTIFICATE: - case ConfigKeys.TLS_CERTIFICATE_AUTHORITIES: - case ConfigKeys.TLS_KEY: - configItem.value = - config[key]?.isEnabled && config[key]?.value - ? JSON.stringify(config[key]?.value) - : null; // only add tls settings if they are enabled by the user - break; - case ConfigKeys.TLS_VERSION: - configItem.value = - config[key]?.isEnabled && config[key]?.value.length - ? JSON.stringify(config[key]?.value) - : null; // only add tls settings if they are enabled by the user - break; - case ConfigKeys.TLS_KEY_PASSPHRASE: - case ConfigKeys.TLS_VERIFICATION_MODE: - configItem.value = - config[key]?.isEnabled && config[key]?.value ? config[key]?.value : null; // only add tls settings if they are enabled by the user - break; - default: - configItem.value = - config[key] === undefined || config[key] === null ? null : config[key]; - } + if (configItem && formatters[key]) { + configItem.value = formatters[key]?.(config); + } else if (configItem) { + configItem.value = config[key] === undefined || config[key] === null ? null : config[key]; } }); currentConfig.current = config; @@ -114,14 +76,8 @@ export const useUpdatePolicy = ({ } }, [config, currentConfig, newPolicy, onChange, validate, monitorType]); - // update our local config state ever time name, which is managed by fleet, changes - useEffect(() => { - setConfig((prevConfig) => ({ ...prevConfig, name: newPolicy.name })); - }, [newPolicy.name, setConfig]); - return { config, - setConfig, updatedPolicy, }; }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx b/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx index f3057baf10381..0ce5dc6f9f02d 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/validation.tsx @@ -4,11 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ConfigKeys, DataStream, ICustomFields, Validation, ScheduleUnit } from './types'; +import { + ConfigKeys, + DataStream, + ICustomFields, + Validator, + Validation, + ScheduleUnit, +} from './types'; export const digitsOnly = /^[0-9]*$/g; export const includesValidPort = /[^\:]+:[0-9]{1,5}$/g; +type ValidationLibrary = Record; + // returns true if invalid function validateHeaders(headers: T): boolean { return Object.keys(headers).some((key) => { @@ -22,7 +31,7 @@ function validateHeaders(headers: T): boolean { } // returns true if invalid -function validateTimeout({ +const validateTimeout = ({ scheduleNumber, scheduleUnit, timeout, @@ -30,7 +39,7 @@ function validateTimeout({ scheduleNumber: string; scheduleUnit: ScheduleUnit; timeout: string; -}): boolean { +}): boolean => { let schedule: number; switch (scheduleUnit) { case ScheduleUnit.SECONDS: @@ -44,69 +53,83 @@ function validateTimeout({ } return parseFloat(timeout) > schedule; -} +}; // validation functions return true when invalid -const validateCommon = { - [ConfigKeys.SCHEDULE]: (value: unknown) => { +const validateCommon: ValidationLibrary = { + [ConfigKeys.SCHEDULE]: ({ [ConfigKeys.SCHEDULE]: value }) => { const { number, unit } = value as ICustomFields[ConfigKeys.SCHEDULE]; const parsedFloat = parseFloat(number); return !parsedFloat || !unit || parsedFloat < 1; }, - [ConfigKeys.TIMEOUT]: ( - timeoutValue: unknown, - scheduleNumber: string, - scheduleUnit: ScheduleUnit - ) => - !timeoutValue || - parseFloat(timeoutValue as ICustomFields[ConfigKeys.TIMEOUT]) < 0 || - validateTimeout({ - timeout: timeoutValue as ICustomFields[ConfigKeys.TIMEOUT], - scheduleNumber, - scheduleUnit, - }), + [ConfigKeys.TIMEOUT]: ({ [ConfigKeys.TIMEOUT]: timeout, [ConfigKeys.SCHEDULE]: schedule }) => { + const { number, unit } = schedule as ICustomFields[ConfigKeys.SCHEDULE]; + + return ( + !timeout || + parseFloat(timeout) < 0 || + validateTimeout({ + timeout, + scheduleNumber: number, + scheduleUnit: unit, + }) + ); + }, }; -const validateHTTP = { - [ConfigKeys.RESPONSE_STATUS_CHECK]: (value: unknown) => { +const validateHTTP: ValidationLibrary = { + [ConfigKeys.RESPONSE_STATUS_CHECK]: ({ [ConfigKeys.RESPONSE_STATUS_CHECK]: value }) => { const statusCodes = value as ICustomFields[ConfigKeys.RESPONSE_STATUS_CHECK]; return statusCodes.length ? statusCodes.some((code) => !`${code}`.match(digitsOnly)) : false; }, - [ConfigKeys.RESPONSE_HEADERS_CHECK]: (value: unknown) => { + [ConfigKeys.RESPONSE_HEADERS_CHECK]: ({ [ConfigKeys.RESPONSE_HEADERS_CHECK]: value }) => { const headers = value as ICustomFields[ConfigKeys.RESPONSE_HEADERS_CHECK]; return validateHeaders(headers); }, - [ConfigKeys.REQUEST_HEADERS_CHECK]: (value: unknown) => { + [ConfigKeys.REQUEST_HEADERS_CHECK]: ({ [ConfigKeys.REQUEST_HEADERS_CHECK]: value }) => { const headers = value as ICustomFields[ConfigKeys.REQUEST_HEADERS_CHECK]; return validateHeaders(headers); }, - [ConfigKeys.MAX_REDIRECTS]: (value: unknown) => + [ConfigKeys.MAX_REDIRECTS]: ({ [ConfigKeys.MAX_REDIRECTS]: value }) => (!!value && !`${value}`.match(digitsOnly)) || parseFloat(value as ICustomFields[ConfigKeys.MAX_REDIRECTS]) < 0, - [ConfigKeys.URLS]: (value: unknown) => !value, + [ConfigKeys.URLS]: ({ [ConfigKeys.URLS]: value }) => !value, ...validateCommon, }; -const validateTCP = { - [ConfigKeys.HOSTS]: (value: unknown) => { +const validateTCP: Record = { + [ConfigKeys.HOSTS]: ({ [ConfigKeys.HOSTS]: value }) => { return !value || !`${value}`.match(includesValidPort); }, ...validateCommon, }; -const validateICMP = { - [ConfigKeys.HOSTS]: (value: unknown) => !value, - [ConfigKeys.WAIT]: (value: unknown) => +const validateICMP: ValidationLibrary = { + [ConfigKeys.HOSTS]: ({ [ConfigKeys.HOSTS]: value }) => !value, + [ConfigKeys.WAIT]: ({ [ConfigKeys.WAIT]: value }) => !!value && !digitsOnly.test(`${value}`) && parseFloat(value as ICustomFields[ConfigKeys.WAIT]) < 0, ...validateCommon, }; +const validateBrowser: ValidationLibrary = { + ...validateCommon, + [ConfigKeys.SOURCE_ZIP_URL]: ({ + [ConfigKeys.SOURCE_ZIP_URL]: zipUrl, + [ConfigKeys.SOURCE_INLINE]: inlineScript, + }) => !zipUrl && !inlineScript, + [ConfigKeys.SOURCE_INLINE]: ({ + [ConfigKeys.SOURCE_ZIP_URL]: zipUrl, + [ConfigKeys.SOURCE_INLINE]: inlineScript, + }) => !zipUrl && !inlineScript, +}; + export type ValidateDictionary = Record; export const validate: ValidateDictionary = { [DataStream.HTTP]: validateHTTP, [DataStream.TCP]: validateTCP, [DataStream.ICMP]: validateICMP, + [DataStream.BROWSER]: validateBrowser, }; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts index 73f4501ace591..4cf7a566454c4 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ALERT_REASON } from '@kbn/rule-data-utils'; +import { ALERT_REASON, ALERT_SEVERITY_WARNING, ALERT_SEVERITY_LEVEL } from '@kbn/rule-data-utils'; import { generateFilterDSL, hasFilters, @@ -75,6 +75,7 @@ const mockStatusAlertDocument = ( [ALERT_REASON]: `Monitor first with url ${monitorInfo?.url?.full} is down from ${ monitorInfo.observer?.geo?.name }. The latest error message is ${monitorInfo.error?.message || ''}`, + [ALERT_SEVERITY_LEVEL]: ALERT_SEVERITY_WARNING, }, id: getInstanceId( monitorInfo, @@ -95,6 +96,7 @@ const mockAvailabilityAlertDocument = (monitor: GetMonitorAvailabilityResult) => )}% availability expected is 99.34% from ${ monitorInfo.observer?.geo?.name }. The latest error message is ${monitorInfo.error?.message || ''}`, + [ALERT_SEVERITY_LEVEL]: ALERT_SEVERITY_WARNING, }, id: getInstanceId(monitorInfo, `${monitorInfo?.monitor.id}-${monitorInfo.observer?.geo?.name}`), }; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index bf8c0176122f0..4b00b7316b687 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -7,6 +7,7 @@ import { min } from 'lodash'; import datemath from '@elastic/datemath'; import { schema } from '@kbn/config-schema'; +import { ALERT_SEVERITY_WARNING, ALERT_SEVERITY_LEVEL } from '@kbn/rule-data-utils'; import { i18n } from '@kbn/i18n'; import { JsonObject } from '@kbn/utility-types'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; @@ -158,6 +159,7 @@ export const getMonitorAlertDocument = (monitorSummary: Record { 'tls.server.x509.not_after': cert.not_after, 'tls.server.x509.not_before': cert.not_before, 'tls.server.hash.sha256': cert.sha256, + [ALERT_SEVERITY_LEVEL]: ALERT_SEVERITY_WARNING, }), id: `${cert.common_name}-${cert.issuer?.replace(/\s/g, '_')}-${cert.sha256}`, }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls.ts b/x-pack/plugins/uptime/server/lib/alerts/tls.ts index 5bb1e5ee3d903..88fa88b24d22e 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls.ts @@ -6,7 +6,7 @@ */ import moment from 'moment'; import { schema } from '@kbn/config-schema'; -import { ALERT_REASON } from '@kbn/rule-data-utils'; +import { ALERT_REASON, ALERT_SEVERITY_WARNING, ALERT_SEVERITY_LEVEL } from '@kbn/rule-data-utils'; import { UptimeAlertTypeFactory } from './types'; import { updateState, generateAlertMessage } from './common'; import { TLS } from '../../../common/constants/alerts'; @@ -172,6 +172,7 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, 'tls.server.x509.not_after': cert.not_after, 'tls.server.x509.not_before': cert.not_before, 'tls.server.hash.sha256': cert.sha256, + [ALERT_SEVERITY_LEVEL]: ALERT_SEVERITY_WARNING, [ALERT_REASON]: generateAlertMessage(TlsTranslations.defaultActionMessage, summary), }, }); diff --git a/x-pack/plugins/watcher/kibana.json b/x-pack/plugins/watcher/kibana.json index 84fe2b509b263..6c9e46e0647af 100644 --- a/x-pack/plugins/watcher/kibana.json +++ b/x-pack/plugins/watcher/kibana.json @@ -2,6 +2,10 @@ "id": "watcher", "configPath": ["xpack", "watcher"], "version": "kibana", + "owner": { + "name": "Stack Management", + "githubTeam": "kibana-stack-management" + }, "requiredPlugins": [ "home", "licensing", @@ -13,9 +17,5 @@ ], "server": true, "ui": true, - "requiredBundles": [ - "esUiShared", - "kibanaReact", - "fieldFormats" - ] + "requiredBundles": ["esUiShared", "kibanaReact", "fieldFormats"] } diff --git a/x-pack/plugins/xpack_legacy/kibana.json b/x-pack/plugins/xpack_legacy/kibana.json index fc45b612d72cf..9dd0ac8340183 100644 --- a/x-pack/plugins/xpack_legacy/kibana.json +++ b/x-pack/plugins/xpack_legacy/kibana.json @@ -1,10 +1,12 @@ { "id": "xpackLegacy", + "owner": { + "name": "Kibana Core", + "githubTeam": "kibana-core" + }, "version": "8.0.0", "kibanaVersion": "kibana", "server": true, "ui": false, - "requiredPlugins": [ - "usageCollection" - ] + "requiredPlugins": ["usageCollection"] } diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index 96ec1c22687d1..c9031ef8d73d2 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -70,12 +70,13 @@ async function copySourceAndBabelify() { buffer: true, nodir: true, ignore: [ - '**/README.md', + '**/*.{md,asciidoc}', + '**/jest.config.js', '**/*.{test,test.mocks,mock,mocks}.*', '**/*.d.ts', '**/node_modules/**', - '**/public/**/*.{js,ts,tsx,json}', - '**/{__tests__,__mocks__,__snapshots__}/**', + '**/public/**/*.{js,ts,tsx,json,scss}', + '**/{__tests__,__mocks__,__snapshots__,__fixtures__,__jest__,cypress}/**', 'plugins/*/target/**', 'plugins/canvas/shareable_runtime/test/**', 'plugins/telemetry_collection_xpack/schema/**', // Skip telemetry schemas diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index 0eaeaf9a4b7e7..81b544ac97152 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { + const es = getService('es'); const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); @@ -175,5 +176,26 @@ export default function createGetTests({ getService }: FtrProviderContext) { }, ]); }); + + it('7.15.0 migrates security_solution alerts with exceptionLists to be saved object references', async () => { + // NOTE: We hae to use elastic search directly against the ".kibana" index because alerts do not expose the references which we want to test exists + const response = await es.get<{ references: [{}] }>({ + index: '.kibana', + id: 'alert:38482620-ef1b-11eb-ad71-7de7959be71c', + }); + expect(response.statusCode).to.eql(200); + expect(response.body._source?.references).to.eql([ + { + name: 'param:exceptionsList_0', + id: 'endpoint_list', + type: 'exception-list-agnostic', + }, + { + name: 'param:exceptionsList_1', + id: '50e3bd70-ef1b-11eb-ad71-7de7959be71c', + type: 'exception-list', + }, + ]); + }); }); } diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index 2df2727ed869b..2ef401e34af7d 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -204,7 +204,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({ statusCode: 404, error: 'Not Found', - message: 'Response Error', + message: '{}', attributes: {}, }); }); @@ -494,7 +494,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({ error: 'Not Found', - message: 'Response Error', + message: '{"_index":"test_index","_id":"2","found":false}', statusCode: 404, attributes: {}, }); diff --git a/x-pack/test/api_integration/apis/ml/system/capabilities.ts b/x-pack/test/api_integration/apis/ml/system/capabilities.ts index aa1ab2016fcb5..4eb040d031c2e 100644 --- a/x-pack/test/api_integration/apis/ml/system/capabilities.ts +++ b/x-pack/test/api_integration/apis/ml/system/capabilities.ts @@ -45,7 +45,7 @@ export default ({ getService }: FtrProviderContext) => { it('should have the right number of capabilities', async () => { const { capabilities } = await runRequest(USER.ML_POWERUSER); - expect(Object.keys(capabilities).length).to.eql(30); + expect(Object.keys(capabilities).length).to.eql(31); }); it('should get viewer capabilities', async () => { @@ -56,6 +56,7 @@ export default ({ getService }: FtrProviderContext) => { canDeleteJob: false, canOpenJob: false, canCloseJob: false, + canResetJob: false, canUpdateJob: false, canForecastJob: false, canCreateDatafeed: false, @@ -93,6 +94,7 @@ export default ({ getService }: FtrProviderContext) => { canDeleteJob: true, canOpenJob: true, canCloseJob: true, + canResetJob: true, canUpdateJob: true, canForecastJob: true, canCreateDatafeed: true, diff --git a/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts b/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts index b9ca7794b7cd9..6d6a00e882689 100644 --- a/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts +++ b/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts @@ -71,11 +71,11 @@ export default ({ getService }: FtrProviderContext) => { it('should have the right number of capabilities - space with ML', async () => { const { capabilities } = await runRequest(USER.ML_POWERUSER, idSpaceWithMl); - expect(Object.keys(capabilities).length).to.eql(30); + expect(Object.keys(capabilities).length).to.eql(31); }); it('should have the right number of capabilities - space without ML', async () => { const { capabilities } = await runRequest(USER.ML_POWERUSER, idSpaceNoMl); - expect(Object.keys(capabilities).length).to.eql(30); + expect(Object.keys(capabilities).length).to.eql(31); }); it('should get viewer capabilities - space with ML', async () => { @@ -85,6 +85,7 @@ export default ({ getService }: FtrProviderContext) => { canDeleteJob: false, canOpenJob: false, canCloseJob: false, + canResetJob: false, canUpdateJob: false, canForecastJob: false, canCreateDatafeed: false, @@ -121,6 +122,7 @@ export default ({ getService }: FtrProviderContext) => { canDeleteJob: false, canOpenJob: false, canCloseJob: false, + canResetJob: false, canUpdateJob: false, canForecastJob: false, canCreateDatafeed: false, @@ -157,6 +159,7 @@ export default ({ getService }: FtrProviderContext) => { canDeleteJob: true, canOpenJob: true, canCloseJob: true, + canResetJob: true, canUpdateJob: true, canForecastJob: true, canCreateDatafeed: true, @@ -193,6 +196,7 @@ export default ({ getService }: FtrProviderContext) => { canDeleteJob: false, canOpenJob: false, canCloseJob: false, + canResetJob: false, canUpdateJob: false, canForecastJob: false, canCreateDatafeed: false, diff --git a/x-pack/test/apm_api_integration/tests/feature_controls.ts b/x-pack/test/apm_api_integration/tests/feature_controls.ts index 589fba8561ae6..58193726e20f1 100644 --- a/x-pack/test/apm_api_integration/tests/feature_controls.ts +++ b/x-pack/test/apm_api_integration/tests/feature_controls.ts @@ -86,7 +86,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) }, { req: { - url: `/api/apm/services/foo/agent_name?start=${start}&end=${end}`, + url: `/api/apm/services/foo/agent?start=${start}&end=${end}`, }, expectForbidden: expect403, expectResponse: expect200, @@ -124,7 +124,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) }, { req: { - url: `/api/apm/services/foo/transactions/charts/distribution?start=${start}&end=${end}&transactionType=bar&transactionName=baz&environment=ENVIRONMENT_ALL&kuery=`, + url: `/api/apm/services/foo/transactions/traces/samples?start=${start}&end=${end}&transactionType=bar&transactionName=baz&environment=ENVIRONMENT_ALL&kuery=`, }, expectForbidden: expect403, expectResponse: expect200, diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index 12b21ad17bf2f..0e76a4ed86688 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -82,8 +82,8 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte }); // Services - describe('services/agent_name', function () { - loadTestFile(require.resolve('./services/agent_name')); + describe('services/agent', function () { + loadTestFile(require.resolve('./services/agent')); }); describe('services/annotations', function () { @@ -158,8 +158,8 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./transactions/breakdown')); }); - describe('transactions/distribution', function () { - loadTestFile(require.resolve('./transactions/distribution')); + describe('transactions/trace_samples', function () { + loadTestFile(require.resolve('./transactions/trace_samples')); }); describe('transactions/error_rate', function () { diff --git a/x-pack/test/apm_api_integration/tests/services/agent_name.ts b/x-pack/test/apm_api_integration/tests/services/agent.ts similarity index 85% rename from x-pack/test/apm_api_integration/tests/services/agent_name.ts rename to x-pack/test/apm_api_integration/tests/services/agent.ts index 258146dc30be1..5fd222c72a3b2 100644 --- a/x-pack/test/apm_api_integration/tests/services/agent_name.ts +++ b/x-pack/test/apm_api_integration/tests/services/agent.ts @@ -21,7 +21,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { registry.when('Agent name when data is not loaded', { config: 'basic', archives: [] }, () => { it('handles the empty state', async () => { const response = await supertest.get( - `/api/apm/services/opbeans-node/agent_name?start=${start}&end=${end}` + `/api/apm/services/opbeans-node/agent?start=${start}&end=${end}` ); expect(response.status).to.be(200); @@ -35,12 +35,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { () => { it('returns the agent name', async () => { const response = await supertest.get( - `/api/apm/services/opbeans-node/agent_name?start=${start}&end=${end}` + `/api/apm/services/opbeans-node/agent?start=${start}&end=${end}` ); expect(response.status).to.be(200); - expect(response.body).to.eql({ agentName: 'nodejs' }); + expect(response.body).to.eql({ agentName: 'nodejs', runtimeName: 'node' }); }); } ); diff --git a/x-pack/test/apm_api_integration/tests/transactions/distribution.ts b/x-pack/test/apm_api_integration/tests/transactions/distribution.ts deleted file mode 100644 index 3c322a727d1f0..0000000000000 --- a/x-pack/test/apm_api_integration/tests/transactions/distribution.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import qs from 'querystring'; -import { isEmpty } from 'lodash'; -import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { registry } from '../../common/registry'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - const archiveName = 'apm_8.0.0'; - const metadata = archives_metadata[archiveName]; - - const url = `/api/apm/services/opbeans-java/transactions/charts/distribution?${qs.stringify({ - start: metadata.start, - end: metadata.end, - transactionName: 'APIRestController#stats', - transactionType: 'request', - environment: 'ENVIRONMENT_ALL', - kuery: '', - })}`; - - registry.when( - 'Transaction groups distribution when data is not loaded', - { config: 'basic', archives: [] }, - () => { - it('handles empty state', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - - expect(response.body.noHits).to.be(true); - expect(response.body.buckets.length).to.be(0); - }); - } - ); - - registry.when( - 'Transaction groups distribution when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - let response: any; - before(async () => { - response = await supertest.get(url); - }); - - it('returns the correct metadata', () => { - expect(response.status).to.be(200); - expect(response.body.noHits).to.be(false); - expect(response.body.buckets.length).to.be.greaterThan(0); - }); - - it('returns groups with some hits', () => { - expect(response.body.buckets.some((bucket: any) => bucket.count > 0)).to.be(true); - }); - - it('returns groups with some samples', () => { - expect(response.body.buckets.some((bucket: any) => !isEmpty(bucket.samples))).to.be(true); - }); - - it('returns the correct number of buckets', () => { - expectSnapshot(response.body.buckets.length).toMatchInline(`26`); - }); - - it('returns the correct bucket size', () => { - expectSnapshot(response.body.bucketSize).toMatchInline(`1000`); - }); - - it('returns the correct buckets', () => { - const bucketWithSamples = response.body.buckets.find( - (bucket: any) => !isEmpty(bucket.samples) - ); - - expectSnapshot(bucketWithSamples.count).toMatchInline(`1`); - - expectSnapshot(bucketWithSamples.samples.sort((sample: any) => sample.traceId)) - .toMatchInline(` - Array [ - Object { - "traceId": "6d85d8f1bc4bbbfdb19cdba59d2fc164", - "transactionId": "d0a16f0f52f25d6b", - }, - ] - `); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/transactions/trace_samples.ts b/x-pack/test/apm_api_integration/tests/transactions/trace_samples.ts new file mode 100644 index 0000000000000..73b1bbfd781d0 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/trace_samples.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import qs from 'querystring'; +import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { registry } from '../../common/registry'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const metadata = archives_metadata[archiveName]; + + const url = `/api/apm/services/opbeans-java/transactions/traces/samples?${qs.stringify({ + environment: 'ENVIRONMENT_ALL', + kuery: '', + start: metadata.start, + end: metadata.end, + transactionName: 'APIRestController#stats', + transactionType: 'request', + })}`; + + registry.when( + 'Transaction trace samples response structure when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + + expect(response.body.noHits).to.be(true); + expect(response.body.traceSamples.length).to.be(0); + }); + } + ); + + registry.when( + 'Transaction trace samples response structure when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + let response: any; + before(async () => { + response = await supertest.get(url); + }); + + it('returns the correct metadata', () => { + expect(response.status).to.be(200); + expect(response.body.noHits).to.be(false); + expect(response.body.traceSamples.length).to.be.greaterThan(0); + }); + + it('returns the correct number of samples', () => { + expectSnapshot(response.body.traceSamples.length).toMatchInline(`15`); + }); + + it('returns the correct samples', () => { + const { traceSamples } = response.body; + + expectSnapshot(traceSamples.sort((sample: any) => sample.traceId)).toMatchInline(` + Array [ + Object { + "traceId": "5267685738bf75b68b16bf3426ba858c", + "transactionId": "5223f43bc3154c5a", + }, + Object { + "traceId": "9a84d15e5a0e32098d569948474e8e2f", + "transactionId": "b85db78a9824107b", + }, + Object { + "traceId": "e123f0466fa092f345d047399db65aa2", + "transactionId": "c0af16286229d811", + }, + Object { + "traceId": "4943691f87b7eb97d442d1ef33ca65c7", + "transactionId": "f6f4677d731e57c5", + }, + Object { + "traceId": "66bd97c457f5675665397ac9201cc050", + "transactionId": "592b60cc9ddabb15", + }, + Object { + "traceId": "10d882b7118870015815a27c37892375", + "transactionId": "0cf9db0b1e321239", + }, + Object { + "traceId": "6d85d8f1bc4bbbfdb19cdba59d2fc164", + "transactionId": "d0a16f0f52f25d6b", + }, + Object { + "traceId": "0996b09e42ad4dbfaaa6a069326c6e66", + "transactionId": "5721364b179716d0", + }, + Object { + "traceId": "d9415d102c0634e1e8fa53ceef07be70", + "transactionId": "fab91c68c9b1c42b", + }, + Object { + "traceId": "ca7a2072e7974ae84b5096706c6b6255", + "transactionId": "92ab7f2ef11685dd", + }, + Object { + "traceId": "d250e2a1bad40f78653d8858db65326b", + "transactionId": "6fcd12599c1b57fa", + }, + Object { + "traceId": "2ca82e99453c58584c4b8de9a8ba4ec3", + "transactionId": "8fa2ca73976ce1e7", + }, + Object { + "traceId": "45b3d1a86003938687a55e49bf3610b8", + "transactionId": "a707456bda99ee98", + }, + Object { + "traceId": "7483bd52150d1c93a858c60bfdd0c138", + "transactionId": "e20e701ff93bdb55", + }, + Object { + "traceId": "a21ea39b41349a4614a86321d965c957", + "transactionId": "338bd7908cbf7f2d", + }, + ] + `); + }); + } + ); +} diff --git a/x-pack/test/fleet_functional/apps/home/index.ts b/x-pack/test/fleet_functional/apps/home/index.ts new file mode 100644 index 0000000000000..cd14bfdff557d --- /dev/null +++ b/x-pack/test/fleet_functional/apps/home/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function (providerContext: FtrProviderContext) { + const { loadTestFile } = providerContext; + + describe('home onboarding', function () { + this.tags('ciGroup7'); + loadTestFile(require.resolve('./welcome')); + }); +} diff --git a/x-pack/test/fleet_functional/apps/home/welcome.ts b/x-pack/test/fleet_functional/apps/home/welcome.ts new file mode 100644 index 0000000000000..678ee10e9b83b --- /dev/null +++ b/x-pack/test/fleet_functional/apps/home/welcome.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'home']); + const kibanaServer = getService('kibanaServer'); + + // flaky https://github.com/elastic/kibana/issues/109017 + describe.skip('Welcome interstitial', () => { + before(async () => { + // Need to navigate to page first to clear storage before test can be run + await PageObjects.common.navigateToUrl('home', undefined); + await browser.clearLocalStorage(); + await esArchiver.emptyKibanaIndex(); + }); + + /** + * When we run this against a Cloud cluster, we also test the case where Fleet server is running + * and ingesting elastic_agent data. + */ + it('is displayed on a fresh install with Fleet setup executed', async () => { + // Setup Fleet and verify the metrics index pattern was created + await kibanaServer.request({ path: '/api/fleet/setup', method: 'POST' }); + const metricsIndexPattern = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'metrics-*', + }); + expect(metricsIndexPattern?.attributes.title).to.eql('metrics-*'); + + // Reload the home screen and verify the interstitial is displayed + await PageObjects.common.navigateToUrl('home', undefined, { disableWelcomePrompt: false }); + expect(await PageObjects.home.isWelcomeInterstitialDisplayed()).to.be(true); + }); + + // Pending tests we should add once the FTR supports Elastic Agent / Fleet Server + it('is still displayed after a Fleet server is enrolled with agent metrics'); + it('is not displayed after an agent is enrolled with system metrics'); + it('is not displayed after a standalone agent is enrolled with system metrics'); + }); +} diff --git a/x-pack/test/fleet_functional/config.ts b/x-pack/test/fleet_functional/config.ts index 15d0c72ffc603..b68fd08b7890f 100644 --- a/x-pack/test/fleet_functional/config.ts +++ b/x-pack/test/fleet_functional/config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...xpackFunctionalConfig.getAll(), pageObjects, - testFiles: [resolve(__dirname, './apps/fleet')], + testFiles: [resolve(__dirname, './apps/fleet'), resolve(__dirname, './apps/home')], junit: { reportName: 'X-Pack Fleet Functional Tests', }, diff --git a/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts b/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts index 616402098acec..c2b24e87266af 100644 --- a/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts +++ b/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts @@ -17,7 +17,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); - const testData = { serviceName: 'opbeans-go' }; + const testData = { + latencyCorrelationsTab: 'Latency correlations', + logLogChartTitle: 'Latency distribution', + serviceName: 'opbeans-go', + transactionsTab: 'Transactions', + transaction: 'GET /api/stats', + }; describe('latency correlations', () => { describe('space with no features disabled', () => { @@ -90,23 +96,48 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const apmMainTemplateHeaderServiceName = await testSubjects.getVisibleTextAll( 'apmMainTemplateHeaderServiceName' ); - expect(apmMainTemplateHeaderServiceName).to.contain('opbeans-go'); + expect(apmMainTemplateHeaderServiceName).to.contain(testData.serviceName); }); }); - it('shows the correlations flyout', async function () { - await testSubjects.click('apmViewCorrelationsButton'); + it('navigates to the transactions tab', async function () { + await find.clickByDisplayedLinkText(testData.transactionsTab); await retry.try(async () => { - await testSubjects.existOrFail('apmCorrelationsFlyout', { - timeout: 10000, - }); + const apmMainContainerText = await testSubjects.getVisibleTextAll('apmMainContainer'); + const apmMainContainerTextItems = apmMainContainerText[0].split('\n'); + + expect(apmMainContainerTextItems).to.contain(testData.transaction); + }); + }); + + it(`navigates to the 'GET /api/stats' transactions`, async function () { + await find.clickByDisplayedLinkText(testData.transaction); + + await retry.try(async () => { + const apmMainContainerText = await testSubjects.getVisibleTextAll('apmMainContainer'); + const apmMainContainerTextItems = apmMainContainerText[0].split('\n'); + + expect(apmMainContainerTextItems).to.contain(testData.transaction); + expect(apmMainContainerTextItems).to.contain(testData.latencyCorrelationsTab); - const apmCorrelationsFlyoutHeader = await testSubjects.getVisibleText( - 'apmCorrelationsFlyoutHeader' + // The default tab 'Trace samples' should show the log log chart without the correlations analysis part. + // First assert that the log log chart and its header are present + const apmCorrelationsLatencyCorrelationsChartTitle = await testSubjects.getVisibleText( + 'apmCorrelationsLatencyCorrelationsChartTitle' ); + expect(apmCorrelationsLatencyCorrelationsChartTitle).to.be(testData.logLogChartTitle); + await testSubjects.existOrFail('apmCorrelationsChart'); + // Then assert that the correlation analysis part is not present + await testSubjects.missingOrFail('apmCorrelationsLatencyCorrelationsTablePanelTitle'); + }); + }); - expect(apmCorrelationsFlyoutHeader).to.contain('Correlations BETA'); + it('shows the correlations tab', async function () { + await testSubjects.click('apmLatencyCorrelationsTabButton'); + + await retry.try(async () => { + await testSubjects.existOrFail('apmCorrelationsTabContent'); }); }); @@ -122,12 +153,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const apmCorrelationsLatencyCorrelationsChartTitle = await testSubjects.getVisibleText( 'apmCorrelationsLatencyCorrelationsChartTitle' ); - expect(apmCorrelationsLatencyCorrelationsChartTitle).to.be( - `Latency distribution for ${testData.serviceName} (Log-Log Plot)` - ); - await testSubjects.existOrFail('apmCorrelationsChart', { - timeout: 10000, - }); + expect(apmCorrelationsLatencyCorrelationsChartTitle).to.be(testData.logLogChartTitle); + await testSubjects.existOrFail('apmCorrelationsChart'); + await testSubjects.existOrFail('apmCorrelationsLatencyCorrelationsTablePanelTitle'); // Assert that results for the given service didn't find any correlations const apmCorrelationsTable = await testSubjects.getVisibleText('apmCorrelationsTable'); diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index abaa387336c88..eaf626618726a 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -88,6 +88,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./settings')); loadTestFile(require.resolve('./embeddables')); + loadTestFile(require.resolve('./stack_management_jobs')); }); }); } diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/index.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/index.ts new file mode 100644 index 0000000000000..141b5840aab44 --- /dev/null +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('stack management jobs', function () { + this.tags(['mlqa', 'skipFirefox']); + + loadTestFile(require.resolve('./synchronize')); + }); +} diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts new file mode 100644 index 0000000000000..8dd519b0ad121 --- /dev/null +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + const adJobId1 = 'fq_single_1'; + const adJobId2 = 'fq_single_2'; + const adJobId3 = 'fq_single_3'; + const adJobIdES = 'fq_single_es'; + + const dfaJobId1 = 'ihp_od_1'; + const dfaJobIdES = 'ihp_od_es'; + + describe('synchronize', function () { + this.tags(['mlqa']); + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ihp_outlier'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + await ml.testResources.cleanMLSavedObjects(); + }); + + after(async () => { + for (const adJobId of [adJobId1, adJobId2, adJobId3, adJobIdES]) { + await ml.api.deleteAnomalyDetectionJobES(adJobId); + } + for (const dfaJobId of [dfaJobId1, dfaJobIdES]) { + await ml.api.deleteDataFrameAnalyticsJobES(dfaJobId); + } + await ml.testResources.cleanMLSavedObjects(); + }); + + it('should have nothing to sync initially', async () => { + // no sync required warning displayed + await ml.navigation.navigateToMl(); + await ml.overviewPage.assertJobSyncRequiredWarningNotExists(); + + // object counts in sync flyout are all 0, sync button is disabled + await ml.navigation.navigateToStackManagement(); + await ml.navigation.navigateToStackManagementJobsListPage(); + await ml.stackManagementJobs.openSyncFlyout(); + await ml.stackManagementJobs.assertSyncFlyoutObjectCounts( + new Map([ + ['MissingObjects', 0], + ['UnmatchedObjects', 0], + ['ObjectsMissingDatafeed', 0], + ['ObjectsUnmatchedDatafeed', 0], + ]) + ); + await ml.stackManagementJobs.assertSyncFlyoutSyncButtonEnabled(false); + }); + + it('should prepare test data', async () => { + // create jobs + + // create via Kibana API so saved objects are auto-generated + for (const jobId of [adJobId1, adJobId2, adJobId3]) { + await ml.api.createAnomalyDetectionJob(ml.commonConfig.getADFqSingleMetricJobConfig(jobId)); + } + await ml.api.createDataFrameAnalyticsJob( + ml.commonConfig.getDFAIhpOutlierDetectionJobConfig(dfaJobId1) + ); + + // create via ES API so saved objects are missing + await ml.api.createAnomalyDetectionJobES( + ml.commonConfig.getADFqSingleMetricJobConfig(adJobIdES) + ); + await ml.api.createDataFrameAnalyticsJobES( + ml.commonConfig.getDFAIhpOutlierDetectionJobConfig(dfaJobIdES) + ); + + // modify jobs + + // datafeed SO should be added with the sync later + const datafeedConfig2 = ml.commonConfig.getADFqDatafeedConfig(adJobId2); + await ml.api.createDatafeedES(datafeedConfig2); + + // left-over datafeed SO should be removed with the sync later + const datafeedConfig3 = ml.commonConfig.getADFqDatafeedConfig(adJobId3); + await ml.api.createDatafeed(datafeedConfig3); + await ml.api.deleteDatafeedES(datafeedConfig3.datafeed_id); + + // corresponding SO should be created with the sync later + await ml.api.assertJobSpaces(adJobIdES, 'anomaly-detector', []); + + // left-over SO should be removed with the sync later + await ml.api.deleteAnomalyDetectionJobES(adJobId1); + await ml.api.deleteDataFrameAnalyticsJobES(dfaJobId1); + }); + + it('should have objects to sync', async () => { + // sync required warning is displayed + await ml.navigation.navigateToMl(); + await ml.overviewPage.assertJobSyncRequiredWarningExists(); + + // object counts in sync flyout are all 1, sync button is enabled + await ml.navigation.navigateToStackManagement(); + await ml.navigation.navigateToStackManagementJobsListPage(); + await ml.stackManagementJobs.openSyncFlyout(); + await ml.stackManagementJobs.assertSyncFlyoutObjectCounts( + new Map([ + ['MissingObjects', 2], + ['UnmatchedObjects', 2], + ['ObjectsMissingDatafeed', 1], + ['ObjectsUnmatchedDatafeed', 1], + ]) + ); + await ml.stackManagementJobs.assertSyncFlyoutSyncButtonEnabled(true); + }); + + it('should synchronize datafeeds and saved objects', async () => { + await ml.stackManagementJobs.executeSync(); + await ml.stackManagementJobs.closeSyncFlyout(); + }); + + it('should have nothing to sync anymore', async () => { + // object counts in sync flyout are all 0, sync button is disabled + await ml.stackManagementJobs.openSyncFlyout(); + await ml.stackManagementJobs.assertSyncFlyoutObjectCounts( + new Map([ + ['MissingObjects', 0], + ['UnmatchedObjects', 0], + ['ObjectsMissingDatafeed', 0], + ['ObjectsUnmatchedDatafeed', 0], + ]) + ); + await ml.stackManagementJobs.assertSyncFlyoutSyncButtonEnabled(false); + await ml.stackManagementJobs.closeSyncFlyout(); + + // no sync required warning displayed + await ml.navigation.navigateToMl(); + await ml.overviewPage.assertJobSyncRequiredWarningNotExists(); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/alerts/data.json b/x-pack/test/functional/es_archives/alerts/data.json index 26f201c095dca..2ce6be7b4816c 100644 --- a/x-pack/test/functional/es_archives/alerts/data.json +++ b/x-pack/test/functional/es_archives/alerts/data.json @@ -333,3 +333,57 @@ } } } + +{ + "type": "doc", + "value": { + "id": "alert:38482620-ef1b-11eb-ad71-7de7959be71c", + "index": ".kibana_1", + "source": { + "alert" : { + "name" : "test upgrade of exceptionsList", + "alertTypeId" : "siem.signals", + "consumer" : "alertsFixture", + "params" : { + "ruleId" : "4ec223b9-77fa-4895-8539-6b3e586a2858", + "exceptionsList" : [ + { + "id" : "endpoint_list", + "list_id" : "endpoint_list", + "namespace_type" : "agnostic", + "type" : "endpoint" + }, + { + "list_id" : "cd152d0d-3590-4a45-a478-eed04da7936b", + "namespace_type" : "single", + "id" : "50e3bd70-ef1b-11eb-ad71-7de7959be71c", + "type" : "detection" + } + ] + }, + "schedule" : { + "interval" : "1m" + }, + "enabled" : true, + "actions" : [ ], + "throttle" : null, + "apiKeyOwner" : null, + "apiKey" : null, + "createdBy" : "elastic", + "updatedBy" : "elastic", + "createdAt" : "2021-07-27T20:42:55.896Z", + "muteAll" : false, + "mutedInstanceIds" : [ ], + "scheduledTaskId" : null, + "tags": [] + }, + "type" : "alert", + "migrationVersion" : { + "alert" : "7.8.0" + }, + "updated_at" : "2021-08-13T23:00:11.985Z", + "references": [ + ] + } + } +} diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index a3b77655f28e1..7add4a024b469 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -455,10 +455,26 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { }); }, + validateJobId(jobId: string) { + if (jobId.match(/[\*,]/) !== null) { + throw new Error(`No wildcards or list of ids supported in this context (got ${jobId})`); + } + }, + async getAnomalyDetectionJob(jobId: string) { return await esSupertest.get(`/_ml/anomaly_detectors/${jobId}`).expect(200); }, + async adJobExist(jobId: string) { + this.validateJobId(jobId); + try { + await this.getAnomalyDetectionJob(jobId); + return true; + } catch (err) { + return false; + } + }, + async waitForAnomalyDetectionJobToExist(jobId: string, timeout: number = 5 * 1000) { await retry.waitForWithTimeout(`'${jobId}' to exist`, timeout, async () => { if (await this.getAnomalyDetectionJob(jobId)) { @@ -510,6 +526,11 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { async deleteAnomalyDetectionJobES(jobId: string) { log.debug(`Deleting anomaly detection job with id '${jobId}' ...`); + if ((await this.adJobExist(jobId)) === false) { + log.debug('> no such AD job found, nothing to delete.'); + return; + } + const datafeedId = `datafeed-${jobId}`; if ((await this.datafeedExist(datafeedId)) === true) { await this.deleteDatafeedES(datafeedId); @@ -672,6 +693,16 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { return response; }, + async dfaJobExist(analyticsId: string) { + this.validateJobId(analyticsId); + try { + await this.getDataFrameAnalyticsJob(analyticsId); + return true; + } catch (err) { + return false; + } + }, + async waitForDataFrameAnalyticsJobToExist(analyticsId: string) { await retry.waitForWithTimeout(`'${analyticsId}' to exist`, 5 * 1000, async () => { if (await this.getDataFrameAnalyticsJob(analyticsId)) { @@ -725,6 +756,11 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { async deleteDataFrameAnalyticsJobES(analyticsId: string) { log.debug(`Deleting data frame analytics job with id '${analyticsId}' ...`); + if ((await this.dfaJobExist(analyticsId)) === false) { + log.debug('> no such DFA job found, nothing to delete.'); + return; + } + await esSupertest .delete(`/_ml/data_frame/analytics/${analyticsId}`) .query({ force: true }) diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index e269d408cf7d0..4be3dd192f341 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -42,6 +42,7 @@ import { MachineLearningSettingsProvider } from './settings'; import { MachineLearningSettingsCalendarProvider } from './settings_calendar'; import { MachineLearningSettingsFilterListProvider } from './settings_filter_list'; import { MachineLearningSingleMetricViewerProvider } from './single_metric_viewer'; +import { MachineLearningStackManagementJobsProvider } from './stack_management_jobs'; import { MachineLearningTestExecutionProvider } from './test_execution'; import { MachineLearningTestResourcesProvider } from './test_resources'; import { MachineLearningDataVisualizerTableProvider } from './data_visualizer_table'; @@ -112,6 +113,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const settingsCalendar = MachineLearningSettingsCalendarProvider(context, commonUI); const settingsFilterList = MachineLearningSettingsFilterListProvider(context, commonUI); const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context, commonUI); + const stackManagementJobs = MachineLearningStackManagementJobsProvider(context); const testExecution = MachineLearningTestExecutionProvider(context); const testResources = MachineLearningTestResourcesProvider(context); const alerting = MachineLearningAlertingProvider(context, commonUI); @@ -159,6 +161,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { settingsCalendar, settingsFilterList, singleMetricViewer, + stackManagementJobs, swimLane, testExecution, testResources, diff --git a/x-pack/test/functional/services/ml/overview_page.ts b/x-pack/test/functional/services/ml/overview_page.ts index 376d9fff05a8b..6a3e3e1893f61 100644 --- a/x-pack/test/functional/services/ml/overview_page.ts +++ b/x-pack/test/functional/services/ml/overview_page.ts @@ -40,5 +40,13 @@ export function MachineLearningOverviewPageProvider({ getService }: FtrProviderC }')` ); }, + + async assertJobSyncRequiredWarningExists() { + await testSubjects.existOrFail('mlJobSyncRequiredWarning', { timeout: 5000 }); + }, + + async assertJobSyncRequiredWarningNotExists() { + await testSubjects.missingOrFail('mlJobSyncRequiredWarning', { timeout: 5000 }); + }, }; } diff --git a/x-pack/test/functional/services/ml/stack_management_jobs.ts b/x-pack/test/functional/services/ml/stack_management_jobs.ts new file mode 100644 index 0000000000000..05f5083a0ab4e --- /dev/null +++ b/x-pack/test/functional/services/ml/stack_management_jobs.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +type SyncFlyoutObjectType = + | 'MissingObjects' + | 'UnmatchedObjects' + | 'ObjectsMissingDatafeed' + | 'ObjectsUnmatchedDatafeed'; + +export function MachineLearningStackManagementJobsProvider({ getService }: FtrProviderContext) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const toasts = getService('toasts'); + + return { + async openSyncFlyout() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('mlStackMgmtSyncButton', 1000); + await testSubjects.existOrFail('mlJobMgmtSyncFlyout'); + }); + }, + + async closeSyncFlyout() { + await retry.tryForTime(5000, async () => { + await testSubjects.click('mlJobMgmtSyncFlyoutCloseButton', 1000); + await testSubjects.missingOrFail('mlJobMgmtSyncFlyout'); + }); + }, + + async assertSyncFlyoutSyncButtonEnabled(expectedValue: boolean) { + const isEnabled = await testSubjects.isEnabled('mlJobMgmtSyncFlyoutSyncButton'); + expect(isEnabled).to.eql( + expectedValue, + `Expected Stack Management job sync flyout "Synchronize" button to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }, + + async getSyncFlyoutObjectCountFromTitle(objectType: SyncFlyoutObjectType): Promise { + const titleText = await testSubjects.getVisibleText(`mlJobMgmtSyncFlyout${objectType}Title`); + + const pattern = /^.* \((\d+)\)$/; + const matches = titleText.match(pattern); + expect(matches).to.not.eql( + null, + `Object title text should match pattern '${pattern}', got ${titleText}` + ); + const count: number = +matches![1]; + + return count; + }, + + async assertSyncFlyoutObjectCounts(expectedCounts: Map) { + for (const [objectType, expectedCount] of expectedCounts) { + const actualObjectCount = await this.getSyncFlyoutObjectCountFromTitle(objectType); + expect(actualObjectCount).to.eql( + expectedCount, + `Expected ${objectType} count to be ${expectedCount}, got ${actualObjectCount}` + ); + } + }, + + async executeSync() { + await testSubjects.click('mlJobMgmtSyncFlyoutSyncButton', 2000); + + // check and close success toast + const resultToast = await toasts.getToastElement(1); + const titleElement = await testSubjects.findDescendant('euiToastHeader', resultToast); + const title: string = await titleElement.getVisibleText(); + expect(title).to.match(/^\d+ job[s]? synchronized$/); + + const dismissButton = await testSubjects.findDescendant('toastCloseButton', resultToast); + await dismissButton.click(); + }, + }; +} diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 4869237eb7db4..275002155d7e0 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -385,9 +385,9 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi async assertRuntimeMappingsEditorContent(expectedContent: string[]) { await this.assertRuntimeMappingsEditorExists(); - const runtimeMappingsEditorString = await aceEditor.getValue( - 'transformAdvancedRuntimeMappingsEditor' - ); + const wrapper = await testSubjects.find('transformAdvancedRuntimeMappingsEditor'); + const editor = await wrapper.findByCssSelector('.monaco-editor .view-lines'); + const runtimeMappingsEditorString = await editor.getVisibleText(); // Not all lines may be visible in the editor and thus aceEditor may not return all lines. // This means we might not get back valid JSON so we only test against the first few lines // and see if the string matches. @@ -624,7 +624,9 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi }, async assertAdvancedPivotEditorContent(expectedValue: string[]) { - const advancedEditorString = await aceEditor.getValue('transformAdvancedPivotEditor'); + const wrapper = await testSubjects.find('transformAdvancedPivotEditor'); + const editor = await wrapper.findByCssSelector('.monaco-editor .view-lines'); + const advancedEditorString = await editor.getVisibleText(); // Not all lines may be visible in the editor and thus aceEditor may not return all lines. // This means we might not get back valid JSON so we only test against the first few lines // and see if the string matches. diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/bwc_generation_urls.ts b/x-pack/test/reporting_api_integration/reporting_and_security/bwc_generation_urls.ts new file mode 100644 index 0000000000000..fd194a1df1f65 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/bwc_generation_urls.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { REPO_ROOT } from '@kbn/utils'; +import pathNode from 'path'; +import { FtrProviderContext } from '../ftr_provider_context'; +import * as GenerationUrls from '../services/generation_urls'; + +const OSS_KIBANA_ARCHIVE_PATH = pathNode.resolve( + REPO_ROOT, + 'test/functional/fixtures/es_archiver/dashboard/current/kibana' +); +const OSS_DATA_ARCHIVE_PATH = pathNode.resolve( + REPO_ROOT, + 'test/functional/fixtures/es_archiver/dashboard/current/data' +); + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const reportingAPI = getService('reportingAPI'); + + describe('BWC report generation urls', () => { + before(async () => { + await esArchiver.load(OSS_KIBANA_ARCHIVE_PATH); + await esArchiver.load(OSS_DATA_ARCHIVE_PATH); + + await kibanaServer.uiSettings.update({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await reportingAPI.deleteAllReports(); + }); + + describe('Pre 6_2', () => { + // The URL being tested was captured from release 6.4 and then the layout section was removed to test structure before + // preserve_layout was introduced. See https://github.com/elastic/kibana/issues/23414 + it('job posted successfully', async () => { + const path = await reportingAPI.postJob(GenerationUrls.PDF_PRINT_DASHBOARD_PRE_6_2); + await reportingAPI.waitForJobToFinish(path); + }).timeout(500000); + }); + + describe('6_2', () => { + // Might not be great test practice to lump all these jobs together but reporting takes awhile and it'll be + // more efficient to post them all up front, then sequentially. + it('multiple jobs posted', async () => { + const reportPaths = []; + reportPaths.push(await reportingAPI.postJob(GenerationUrls.PDF_PRINT_DASHBOARD_6_2)); + reportPaths.push(await reportingAPI.postJob(GenerationUrls.PDF_PRESERVE_VISUALIZATION_6_2)); + reportPaths.push(await reportingAPI.postJob(GenerationUrls.CSV_DISCOVER_FILTER_QUERY_6_2)); + + await reportingAPI.expectAllJobsToFinishSuccessfully(reportPaths); + }).timeout(1540000); + }); + + // 6.3 urls currently being tested as part of the "bwc_existing_indexes" test suite. Reports are time consuming, + // don't replicate tests if we don't need to, so no specific 6_3 url tests here. + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index 2996f49857ddd..e6fd534274df4 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -20,6 +20,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await reportingAPI.createTestReportingUser(); }); + loadTestFile(require.resolve('./bwc_generation_urls')); loadTestFile(require.resolve('./bwc_existing_indexes')); loadTestFile(require.resolve('./security_roles_privileges')); loadTestFile(require.resolve('./download_csv_dashboard')); diff --git a/x-pack/test/reporting_api_integration/services/generation_urls.ts b/x-pack/test/reporting_api_integration/services/generation_urls.ts index 7ac8b8396a26e..f6379bc376e76 100644 --- a/x-pack/test/reporting_api_integration/services/generation_urls.ts +++ b/x-pack/test/reporting_api_integration/services/generation_urls.ts @@ -5,6 +5,13 @@ * 2.0. */ +// These all have the domain name portion stripped out. The api infrastructure assumes it when we post to it anyhow. + +// The URL below was captured from release 6.4 and then the layout section was removed to test structure before +// preserve_layout was introduced. See https://github.com/elastic/kibana/issues/23414 +export const PDF_PRINT_DASHBOARD_PRE_6_2 = + '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F2ae34a60-3dd4-11e8-b2b9-5d5dc1715159%3F_g%3D(refreshInterval:(pause:!!t,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!%271!%27,w:24,x:0,y:0),id:!%27145ced90-3dcb-11e8-8660-4d65aa086b3c!%27,panelIndex:!%271!%27,type:visualization,version:!%276.3.0!%27),(embeddableConfig:(),gridData:(h:15,i:!%272!%27,w:24,x:24,y:0),id:e2023110-3dcb-11e8-8660-4d65aa086b3c,panelIndex:!%272!%27,type:visualization,version:!%276.3.0!%27)),query:(language:lucene,query:!%27!%27),timeRestore:!!f,title:!%27couple%2Bpanels!%27,viewMode:view)%27),title:%27couple%20panels%27)'; + export const PDF_PRINT_DASHBOARD_6_3 = '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F2ae34a60-3dd4-11e8-b2b9-5d5dc1715159%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!%271!%27,w:24,x:0,y:0),id:!%27145ced90-3dcb-11e8-8660-4d65aa086b3c!%27,panelIndex:!%271!%27,type:visualization,version:!%276.3.0!%27),(embeddableConfig:(),gridData:(h:15,i:!%272!%27,w:24,x:24,y:0),id:e2023110-3dcb-11e8-8660-4d65aa086b3c,panelIndex:!%272!%27,type:visualization,version:!%276.3.0!%27)),query:(language:lucene,query:!%27!%27),timeRestore:!!f,title:!%27couple%2Bpanels!%27,viewMode:view)%27),title:%27couple%20panels%27)'; export const PDF_PRESERVE_DASHBOARD_FILTER_6_3 = @@ -15,3 +22,10 @@ export const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:visualization,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fvisualize%2Fedit%2Fbefdb6b0-3e59-11e8-9fc3-39e49624228e%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(filters:!!((!%27$state!%27:(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!%27!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!%271!%27,otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!%27Filter%2BTest:%2Banimals:%2Blinked%2Bto%2Bsearch%2Bwith%2Bfilter!%27,type:pie))%27),title:%27Filter%20Test:%20animals:%20linked%20to%20search%20with%20filter%27)'; export const CSV_DISCOVER_KUERY_AND_FILTER_6_3 = '/api/reporting/generate/csv?jobParams=(conflictedTypesFields:!(),fields:!(%27@timestamp%27,agent,bytes,clientip),indexPatternId:%270bf35f60-3dc9-11e8-8660-4d65aa086b3c%27,metaFields:!(_source,_id,_type,_index,_score),searchRequest:(body:(_source:(excludes:!(),includes:!(%27@timestamp%27,agent,bytes,clientip)),docvalue_fields:!(%27@timestamp%27),query:(bool:(filter:!((bool:(minimum_should_match:1,should:!((match:(clientip:%2773.14.212.83%27)))))),must:!((range:(bytes:(gte:100,lt:1000))),(range:(%27@timestamp%27:(format:epoch_millis,gte:1369165215770,lte:1526931615770)))),must_not:!(),should:!())),script_fields:(),sort:!((%27@timestamp%27:(order:desc,unmapped_type:boolean))),stored_fields:!(%27@timestamp%27,agent,bytes,clientip),version:!t),index:%27logstash-*%27),title:%27Bytes%20and%20kuery%20in%20saved%20search%20with%20filter%27,type:search)'; + +export const PDF_PRINT_DASHBOARD_6_2 = + '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:dashboard,queryString:%27_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!((!%27$state!%27:(store:appState),meta:(alias:!!n,disabled:!!f,field:isDog,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:isDog,negate:!!f,params:(value:!!t),type:phrase,value:true),script:(script:(inline:!%27boolean%2Bcompare(Supplier%2Bs,%2Bdef%2Bv)%2B%257Breturn%2Bs.get()%2B%253D%253D%2Bv%3B%257Dcompare(()%2B-%253E%2B%257B%2Breturn%2Bdoc%255B!!!%27animal.keyword!!!%27%255D.value%2B%253D%253D%2B!!!%27dog!!!%27%2B%257D,%2Bparams.value)%3B!%27,lang:painless,params:(value:!!t))))),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((gridData:(h:3,i:!%274!%27,w:6,x:6,y:0),id:edb65990-53ca-11e8-b481-c9426d020fcd,panelIndex:!%274!%27,type:visualization,version:!%276.2.4!%27),(gridData:(h:3,i:!%275!%27,w:6,x:0,y:0),id:!%270644f890-53cb-11e8-b481-c9426d020fcd!%27,panelIndex:!%275!%27,type:visualization,version:!%276.2.4!%27)),query:(language:lucene,query:!%27weightLbs:%253E15!%27),timeRestore:!!t,title:!%27Animal%2BWeights%2B(created%2Bin%2B6.2)!%27,viewMode:view)%27,savedObjectId:%271b2f47b0-53cb-11e8-b481-c9426d020fcd%27)'; +export const PDF_PRESERVE_VISUALIZATION_6_2 = + '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(dimensions:(height:441,width:1002),id:preserve_layout),objectType:visualization,queryString:%27_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(filters:!!(),linked:!!f,query:(language:lucene,query:!%27weightLbs:%253E10!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(field:weightLbs,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!%271!%27,otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!%27Weight%2Bin%2Blbs%2Bpie%2Bcreated%2Bin%2B6.2!%27,type:pie))%27,savedObjectId:%270644f890-53cb-11e8-b481-c9426d020fcd%27)'; +export const CSV_DISCOVER_FILTER_QUERY_6_2 = + '/api/reporting/generate/csv?jobParams=(conflictedTypesFields:!(),fields:!(%27@timestamp%27,animal,sound,weightLbs),indexPatternId:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,metaFields:!(_source,_id,_type,_index,_score),searchRequest:(body:(_source:(excludes:!(),includes:!(%27@timestamp%27,animal,sound,weightLbs)),docvalue_fields:!(%27@timestamp%27),query:(bool:(filter:!(),must:!((query_string:(analyze_wildcard:!t,default_field:%27*%27,query:%27weightLbs:%3E10%27)),(match_phrase:(sound.keyword:(query:growl))),(range:(%27@timestamp%27:(format:epoch_millis,gte:1523310968000,lte:1523483768000)))),must_not:!(),should:!())),script_fields:(),sort:!((%27@timestamp%27:(order:desc,unmapped_type:boolean))),stored_fields:!(%27@timestamp%27,animal,sound,weightLbs),version:!t),index:%27animals-*%27),title:%27Search%20created%20in%206.2%27,type:search)'; diff --git a/yarn.lock b/yarn.lock index 8f22540dcefa7..747bb87610539 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1433,10 +1433,10 @@ dependencies: "@elastic/ecs-helpers" "^1.1.0" -"@elastic/elasticsearch@npm:@elastic/elasticsearch-canary@^8.0.0-canary.14": - version "8.0.0-canary.14" - resolved "https://registry.yarnpkg.com/@elastic/elasticsearch-canary/-/elasticsearch-canary-8.0.0-canary.14.tgz#36f0dedc5e02c43a2fd1ceb86e273e29c603f1fb" - integrity sha512-ZwyjT16581grvJLgsbkT9tzy49g5E2qYQ05mS41Db98Kqe0sYZsm25eHGuV7U9DqJo5LuV0TTTs3rhsaqL5Mhw== +"@elastic/elasticsearch@npm:@elastic/elasticsearch-canary@^8.0.0-canary.17": + version "8.0.0-canary.17" + resolved "https://registry.yarnpkg.com/@elastic/elasticsearch-canary/-/elasticsearch-canary-8.0.0-canary.17.tgz#0625a04cc585e3f311bc6471e04cd4fb0e927e9a" + integrity sha512-rsbEdzxYlEU+jS+qf5Gr3+2U6/1Z/S/Yt2dM7lp1A64mCjuOqqHoR2FTHN27BWvBCjtJrtyhlCJht4fsTLNuYA== dependencies: debug "^4.3.1" hpagent "^0.1.1" @@ -9463,10 +9463,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^91.0.1: - version "91.0.1" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-91.0.1.tgz#4d70a569901e356c978a41de3019c464f2a8ebd0" - integrity sha512-9LktpHiUxM4UWUsr+jI1K1YKx2GENt6BKKJ2mibPj1Wc6ODzX/3fFIlr8CZ4Ftuyga+dHTTbAyPWKwKvybEbKA== +chromedriver@^92.0.1: + version "92.0.1" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-92.0.1.tgz#3e28b7e0c9fb94d693cf74d51af0c29d57f18dca" + integrity sha512-LptlDVCs1GgyFNVbRoHzzy948JDVzTgGiVPXjNj385qXKQP3hjAVBIgyvb/Hco0xSEW8fjwJfsm1eQRmu6t4pQ== dependencies: "@testim/chrome-version" "^1.0.7" axios "^0.21.1"