From ab7e3e8d39b62709f09366f65dc7abf001cbe9f2 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 11 Oct 2021 01:36:24 -0700 Subject: [PATCH 01/19] [Fleet] De-dupe setting kibana.version constraint in get package request to EPR (#114376) --- .../plugins/fleet/server/services/epm/registry/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 2dff3f43f3ec..aa2c3f1d4da3 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -96,16 +96,12 @@ export async function fetchList(params?: SearchParams): Promise { const registryUrl = getRegistryUrl(); - const kibanaVersion = appContextService.getKibanaVersion().split('-')[0]; // may be x.y.z-SNAPSHOT - const kibanaBranch = appContextService.getKibanaBranch(); const url = new URL( `${registryUrl}/search?package=${packageName}&internal=true&experimental=true` ); - // on master, request all packages regardless of version - if (kibanaVersion && kibanaBranch !== 'master') { - url.searchParams.set('kibana.version', kibanaVersion); - } + setKibanaVersion(url); + const res = await fetchUrl(url.toString()); const searchResults = JSON.parse(res); if (searchResults.length) { From 2dece3d446b9f7233ac13810bc8e2ccb392189f7 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 11 Oct 2021 09:39:49 +0100 Subject: [PATCH 02/19] Update APM queries development doc (#114268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add links to field references and GET requests to the examples * Add troubleshooting info for failed requests * Add data model and running examples section * Add GET requests for query examples * Add `metricset` possible values Co-authored-by: Søren Louv-Jansen * Add transaction based and metric based queries Co-authored-by: Søren Louv-Jansen --- x-pack/plugins/apm/dev_docs/apm_queries.md | 157 +++++++++++++++++---- 1 file changed, 126 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/apm/dev_docs/apm_queries.md b/x-pack/plugins/apm/dev_docs/apm_queries.md index 7ca28f2e273c..8508e5a173c8 100644 --- a/x-pack/plugins/apm/dev_docs/apm_queries.md +++ b/x-pack/plugins/apm/dev_docs/apm_queries.md @@ -1,3 +1,9 @@ +# Data model +Elastic APM agents capture different types of information from within their instrumented applications. These are known as events, and can be spans, transactions, errors, or metrics. You can find more information [here](https://www.elastic.co/guide/en/apm/get-started/current/apm-data-model.html). + +# Running examples +You can run the example queries on the [edge cluster](https://edge-oblt.elastic.dev/) or any another cluster that contains APM data. + # Transactions Transactions are stored in two different formats: @@ -34,6 +40,8 @@ A pre-aggregated document where `_doc_count` is the number of transaction events } ``` +You can find all the APM transaction fields [here](https://www.elastic.co/guide/en/apm/server/current/exported-fields-apm-transaction.html). + The decision to use aggregated transactions or not is determined in [`getSearchAggregatedTransactions`](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts#L53-L79) and then used to specify [the transaction index](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/suggestions/get_suggestions.ts#L30-L32) and [the latency field](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts#L62-L65) ### Latency @@ -45,6 +53,7 @@ Noteworthy fields: `transaction.duration.us`, `transaction.duration.histogram` #### Transaction-based latency ```json +GET apm-*-transaction-*,traces-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -61,6 +70,7 @@ Noteworthy fields: `transaction.duration.us`, `transaction.duration.histogram` #### Metric-based latency ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -85,14 +95,64 @@ Throughput is the number of transactions per minute. This can be calculated usin Noteworthy fields: None (based on `doc_count`) -```js +#### Transaction-based throughput + +```json +GET apm-*-transaction-*,traces-apm*/_search?terminate_after=1000 +{ + "size": 0, + "query": { + "bool": { + "filter": [{ "terms": { "processor.event": ["transaction"] } }] + } + }, + "aggs": { + "timeseries": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "60s" + }, + "aggs": { + "throughput": { + "rate": { + "unit": "minute" + } + } + } + } + } +} +``` + + +#### Metric-based throughput + +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { - // same filters as for latency + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "transaction" } } + ] + } }, "aggs": { - "throughput": { "rate": { "unit": "minute" } } + "timeseries": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "60s" + }, + "aggs": { + "throughput": { + "rate": { + "unit": "minute" + } + } + } + } } } ``` @@ -102,11 +162,41 @@ Noteworthy fields: None (based on `doc_count`) Failed transaction rate is the number of transactions with `event.outcome=failure` per minute. Noteworthy fields: `event.outcome` -```js +#### Transaction-based failed transaction rate + + ```json +GET apm-*-transaction-*,traces-apm*/_search?terminate_after=1000 { "size": 0, "query": { - // same filters as for latency + "bool": { + "filter": [{ "terms": { "processor.event": ["transaction"] } }] + } + }, + "aggs": { + "outcomes": { + "terms": { + "field": "event.outcome", + "include": ["failure", "success"] + } + } + } +} +``` + +#### Metric-based failed transaction rate + +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "transaction" } } + ] + } }, "aggs": { "outcomes": { @@ -121,7 +211,7 @@ Noteworthy fields: `event.outcome` # System metrics -System metrics are captured periodically (every 60 seconds by default). +System metrics are captured periodically (every 60 seconds by default). You can find all the System Metrics fields [here](https://www.elastic.co/guide/en/apm/server/current/exported-fields-system.html). ### CPU @@ -146,6 +236,7 @@ Noteworthy fields: `system.cpu.total.norm.pct`, `system.process.cpu.total.norm.p #### Query ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -185,18 +276,17 @@ Noteworthy fields: `system.memory.actual.free`, `system.memory.total`, #### Query -```js +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { "bool": { "filter": [ { "terms": { "processor.event": ["metric"] }}, - { "terms": { "metricset.name": ["app"] }} - - // ensure the memory fields exists + { "terms": { "metricset.name": ["app"] }}, { "exists": { "field": "system.memory.actual.free" }}, - { "exists": { "field": "system.memory.total" }}, + { "exists": { "field": "system.memory.total" }} ] } }, @@ -213,7 +303,7 @@ Noteworthy fields: `system.memory.actual.free`, `system.memory.total`, } ``` -Above example is overly simplified. In reality [we do a bit more](https://github.com/elastic/kibana/blob/fe9b5332e157fd456f81aecfd4ffa78d9e511a66/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts#L51-L71) to properly calculate memory usage inside containers +The above example is overly simplified. In reality [we do a bit more](https://github.com/elastic/kibana/blob/fe9b5332e157fd456f81aecfd4ffa78d9e511a66/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts#L51-L71) to properly calculate memory usage inside containers. Please note that an [Exists Query](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-exists-query.html) is used in the filter context in the query to ensure that the memory fields exist. @@ -268,6 +358,7 @@ Noteworthy fields: `transaction.name`, `transaction.type`, `span.type`, `span.su #### Query ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -330,6 +421,7 @@ A pre-aggregated document with 73 span requests from opbeans-ruby to elasticsear The latency between a service and an (external) endpoint ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -360,6 +452,7 @@ Captures the number of requests made from a service to an (external) endpoint #### Query ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -372,10 +465,17 @@ Captures the number of requests made from a service to an (external) endpoint } }, "aggs": { - "throughput": { - "rate": { - "field": "span.destination.service.response_time.count", - "unit": "minute" + "timeseries": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "60s" + }, + "aggs": { + "throughput": { + "rate": { + "unit": "minute" + } + } } } } @@ -390,27 +490,17 @@ Most Elasticsearch queries will need to have one or more filters. There are a co - stability: Running an aggregation on unrelated documents could cause the entire query to fail - performance: limiting the number of documents will make the query faster -```js +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "query": { "bool": { "filter": [ - // service name { "term": { "service.name": "opbeans-go" }}, - - // service environment - { "term": { "service.environment": "testing" }} - - // transaction type - { "term": { "transaction.type": "request" }} - - // event type (possible values : transaction, span, metric, error) + { "term": { "service.environment": "testing" }}, + { "term": { "transaction.type": "request" }}, { "terms": { "processor.event": ["metric"] }}, - - // metric set is a subtype of `processor.event: metric` { "terms": { "metricset.name": ["transaction"] }}, - - // time range { "range": { "@timestamp": { @@ -422,5 +512,10 @@ Most Elasticsearch queries will need to have one or more filters. There are a co } ] } - }, + } +} ``` + +Possible values for `processor.event` are: `transaction`, `span`, `metric`, `error`. + +`metricset` is a subtype of `processor.event: metric`. Possible values are: `transaction`, `span_breakdown`, `transaction_breakdown`, `app`, `service_destination`, `agent_config` From 41c813bac438530a579b7896f6848dd56089145a Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Mon, 11 Oct 2021 11:56:36 +0300 Subject: [PATCH 03/19] [Visualization] Get rid of saved object loader and use savedObjectClient resolve (#113121) * First step: create saved_visualize_utils, starting use new get/save methods * Use new util methods in embeddable * move findListItem in utils * some clean up * clean up * Some fixes * Fix saved object tags * Some types fixes * Fix unit tests * Clean up code * Add unit tests for new utils * Fix lint * Fix tagging * Add unit tests * Some fixes * Clean up code * Fix lint * Fix types * put new methods in start contract * Fix imports * Fix lint * Fix comments * Fix lint * Fix CI * use local url instead of full path * Fix unit test * Some clean up * Fix nits * fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/visualizations/kibana.json | 2 +- .../create_vis_embeddable_from_object.ts | 20 +- .../public/embeddable/visualize_embeddable.ts | 15 +- .../visualize_embeddable_factory.tsx | 63 ++- src/plugins/visualizations/public/index.ts | 2 + src/plugins/visualizations/public/mocks.ts | 7 + src/plugins/visualizations/public/plugin.ts | 51 +- .../public/saved_visualizations/_saved_vis.ts | 42 +- .../saved_visualizations.ts | 1 + src/plugins/visualizations/public/types.ts | 35 +- .../controls_references.ts | 0 .../saved_visualization_references/index.ts | 0 .../saved_visualization_references.test.ts | 0 .../saved_visualization_references.ts | 0 .../timeseries_references.ts | 0 .../utils/saved_visualize_utils.test.ts | 507 ++++++++++++++++++ .../public/utils/saved_visualize_utils.ts | 403 ++++++++++++++ src/plugins/visualizations/tsconfig.json | 1 + src/plugins/visualize/kibana.json | 3 +- .../visualize_editor_common.test.tsx | 112 ++++ .../components/visualize_editor_common.tsx | 56 +- .../components/visualize_listing.tsx | 9 +- .../visualize/public/application/types.ts | 3 +- .../application/utils/get_top_nav_config.tsx | 31 +- .../utils/get_visualization_instance.test.ts | 27 +- .../utils/get_visualization_instance.ts | 9 +- .../public/application/utils/mocks.ts | 1 - .../utils/use/use_saved_vis_instance.test.ts | 5 - .../utils/use/use_saved_vis_instance.ts | 3 - src/plugins/visualize/public/plugin.ts | 4 +- src/plugins/visualize/tsconfig.json | 3 +- 31 files changed, 1291 insertions(+), 124 deletions(-) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/controls_references.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/index.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/saved_visualization_references.test.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/saved_visualization_references.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/timeseries_references.ts (100%) create mode 100644 src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts create mode 100644 src/plugins/visualizations/public/utils/saved_visualize_utils.ts create mode 100644 src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 0afbec24c7c3..32430c9d4e4f 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -11,7 +11,7 @@ "inspector", "savedObjects" ], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover"], "extraPublicDirs": ["common/constants", "common/prepare_log_table", "common/expression_functions"], "owner": { diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index cfa871f17b0e..c72b8618dc19 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -20,16 +20,10 @@ import { AttributeService, } from '../../../../plugins/embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; -import { - getSavedVisualizationsLoader, - getUISettings, - getHttp, - getTimeFilter, - getCapabilities, -} from '../services'; +import { getUISettings, getHttp, getTimeFilter, getCapabilities } from '../services'; +import { urlFor } from '../utils/saved_visualize_utils'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; -import { SavedVisualizationsLoader } from '../saved_visualizations'; import { IndexPattern } from '../../../data/public'; import { createVisualizeEmbeddableAsync } from './visualize_embeddable_async'; @@ -38,7 +32,6 @@ export const createVisEmbeddableFromObject = async ( vis: Vis, input: Partial & { id: string }, - savedVisualizationsLoader?: SavedVisualizationsLoader, attributeService?: AttributeService< VisualizeSavedObjectAttributes, VisualizeByValueInput, @@ -46,16 +39,12 @@ export const createVisEmbeddableFromObject = >, parent?: IContainer ): Promise => { - const savedVisualizations = getSavedVisualizationsLoader(); - try { const visId = vis.id as string; - const editPath = visId ? savedVisualizations.urlFor(visId) : '#/edit_by_value'; + const editPath = visId ? urlFor(visId) : '#/edit_by_value'; - const editUrl = visId - ? getHttp().basePath.prepend(`/app/visualize${savedVisualizations.urlFor(visId)}`) - : ''; + const editUrl = visId ? getHttp().basePath.prepend(`/app/visualize${urlFor(visId)}`) : ''; const isLabsEnabled = getUISettings().get(VISUALIZE_ENABLE_LABS_SETTING); if (!isLabsEnabled && vis.type.stage === 'experimental') { @@ -87,7 +76,6 @@ export const createVisEmbeddableFromObject = }, input, attributeService, - savedVisualizationsLoader, parent ); } catch (e) { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index f5a7349b633e..0c7d58453db6 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -39,7 +39,7 @@ import { getExpressions, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { SavedObjectAttributes } from '../../../../core/types'; -import { SavedVisualizationsLoader } from '../saved_visualizations'; +import { getSavedVisualization } from '../utils/saved_visualize_utils'; import { VisSavedObject } from '../types'; import { toExpressionAst } from './to_ast'; @@ -108,7 +108,6 @@ export class VisualizeEmbeddable VisualizeByValueInput, VisualizeByReferenceInput >; - private savedVisualizationsLoader?: SavedVisualizationsLoader; constructor( timefilter: TimefilterContract, @@ -119,7 +118,6 @@ export class VisualizeEmbeddable VisualizeByValueInput, VisualizeByReferenceInput >, - savedVisualizationsLoader?: SavedVisualizationsLoader, parent?: IContainer ) { super( @@ -144,7 +142,6 @@ export class VisualizeEmbeddable this.vis.uiState.on('change', this.uiStateChangeHandler); this.vis.uiState.on('reload', this.reload); this.attributeService = attributeService; - this.savedVisualizationsLoader = savedVisualizationsLoader; if (this.attributeService) { const isByValue = !this.inputIsRefType(initialInput); @@ -455,7 +452,15 @@ export class VisualizeEmbeddable }; getInputAsRefType = async (): Promise => { - const savedVis = await this.savedVisualizationsLoader?.get({}); + const { savedObjectsClient, data, spaces, savedObjectsTaggingOss } = await this.deps.start() + .plugins; + const savedVis = await getSavedVisualization({ + savedObjectsClient, + search: data.search, + dataViews: data.dataViews, + spaces, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + }); if (!savedVis) { throw new Error('Error creating a saved vis object'); } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 9e22b33bdee9..9b1af5bea3fc 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -33,20 +33,20 @@ import type { import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import type { SerializedVis, Vis } from '../vis'; import { createVisAsync } from '../vis_async'; -import { - getCapabilities, - getTypes, - getUISettings, - getSavedVisualizationsLoader, -} from '../services'; +import { getCapabilities, getTypes, getUISettings } from '../services'; import { showNewVisModal } from '../wizard'; -import { convertToSerializedVis } from '../saved_visualizations/_saved_vis'; +import { + convertToSerializedVis, + getSavedVisualization, + saveVisualization, + getFullPath, +} from '../utils/saved_visualize_utils'; import { extractControlsReferences, extractTimeSeriesReferences, injectTimeSeriesReferences, injectControlsReferences, -} from '../saved_visualizations/saved_visualization_references'; +} from '../utils/saved_visualization_references'; import { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; import { checkForDuplicateTitle } from '../../../saved_objects/public'; @@ -59,7 +59,15 @@ interface VisualizationAttributes extends SavedObjectAttributes { export interface VisualizeEmbeddableFactoryDeps { start: StartServicesGetter< - Pick + Pick< + VisualizationsStartDeps, + | 'inspector' + | 'embeddable' + | 'savedObjectsClient' + | 'data' + | 'savedObjectsTaggingOss' + | 'spaces' + > >; } @@ -147,17 +155,36 @@ export class VisualizeEmbeddableFactory input: Partial & { id: string }, parent?: IContainer ): Promise { - const savedVisualizations = getSavedVisualizationsLoader(); + const startDeps = await this.deps.start(); try { - const savedObject = await savedVisualizations.get(savedObjectId); + const savedObject = await getSavedVisualization( + { + savedObjectsClient: startDeps.core.savedObjects.client, + search: startDeps.plugins.data.search, + dataViews: startDeps.plugins.data.dataViews, + spaces: startDeps.plugins.spaces, + savedObjectsTagging: startDeps.plugins.savedObjectsTaggingOss?.getTaggingApi(), + }, + savedObjectId + ); + + if (savedObject.sharingSavedObjectProps?.outcome === 'conflict') { + return new ErrorEmbeddable( + i18n.translate('visualizations.embeddable.legacyURLConflict.errorMessage', { + defaultMessage: `This visualization has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`, + values: { json: savedObject.sharingSavedObjectProps?.errorJSON }, + }), + input, + parent + ); + } const visState = convertToSerializedVis(savedObject); const vis = await createVisAsync(savedObject.visState.type, visState); return createVisEmbeddableFromObject(this.deps)( vis, input, - savedVisualizations, await this.getAttributeService(), parent ); @@ -173,11 +200,9 @@ export class VisualizeEmbeddableFactory if (input.savedVis) { const visState = input.savedVis; const vis = await createVisAsync(visState.type, visState); - const savedVisualizations = getSavedVisualizationsLoader(); return createVisEmbeddableFromObject(this.deps)( vis, input, - savedVisualizations, await this.getAttributeService(), parent ); @@ -201,9 +226,9 @@ export class VisualizeEmbeddableFactory confirmOverwrite: false, returnToOrigin: true, isTitleDuplicateConfirmed: true, + copyOnSave: false, }; savedVis.title = title; - savedVis.copyOnSave = false; savedVis.description = ''; savedVis.searchSourceFields = visObj?.data.searchSource?.getSerializedFields(); const serializedVis = (visObj as unknown as Vis).serialize(); @@ -217,7 +242,12 @@ export class VisualizeEmbeddableFactory if (visObj) { savedVis.uiStateJSON = visObj?.uiState.toString(); } - const id = await savedVis.save(saveOptions); + const { core, plugins } = await this.deps.start(); + const id = await saveVisualization(savedVis, saveOptions, { + savedObjectsClient: core.savedObjects.client, + overlays: core.overlays, + savedObjectsTagging: plugins.savedObjectsTaggingOss?.getTaggingApi(), + }); if (!id || id === '') { throw new Error( i18n.translate('visualizations.savingVisualizationFailed.errorMsg', { @@ -225,6 +255,7 @@ export class VisualizeEmbeddableFactory }) ); } + core.chrome.recentlyAccessed.add(getFullPath(id), savedVis.title, String(id)); return { id }; } catch (error) { throw error; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 0886f230d101..e6ea3cd48955 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -38,6 +38,7 @@ export { VisToExpressionAst, VisToExpressionAstParams, VisEditorOptionsProps, + GetVisOptions, } from './types'; export { VisualizationListItem, VisualizationStage } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; @@ -49,3 +50,4 @@ export { FakeParams, HistogramParams, } from '../common/expression_functions/xy_dimension'; +export { urlFor, getFullPath } from './utils/saved_visualize_utils'; diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 901593626a94..9b2d6bfe25b3 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -10,6 +10,7 @@ import { PluginInitializerContext } from '../../../core/public'; import { Schema, VisualizationsSetup, VisualizationsStart } from './'; import { Schemas } from './vis_types'; import { VisualizationsPlugin } from './plugin'; +import { spacesPluginMock } from '../../../../x-pack/plugins/spaces/public/mocks'; import { coreMock, applicationServiceMock } from '../../../core/public/mocks'; import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks'; @@ -18,6 +19,7 @@ import { usageCollectionPluginMock } from '../../../plugins/usage_collection/pub import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; import { savedObjectsPluginMock } from '../../../plugins/saved_objects/public/mocks'; +import { savedObjectTaggingOssPluginMock } from '../../saved_objects_tagging_oss/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -34,6 +36,9 @@ const createStartContract = (): VisualizationsStart => ({ savedVisualizationsLoader: { get: jest.fn(), } as any, + getSavedVisualization: jest.fn(), + saveVisualization: jest.fn(), + findListItems: jest.fn(), showNewVisModal: jest.fn(), createVis: jest.fn(), convertFromSerializedVis: jest.fn(), @@ -61,9 +66,11 @@ const createInstance = async () => { uiActions: uiActionsPluginMock.createStartContract(), application: applicationServiceMock.createStartContract(), embeddable: embeddablePluginMock.createStartContract(), + spaces: spacesPluginMock.createStartContract(), getAttributeService: jest.fn(), savedObjectsClient: coreMock.createStart().savedObjects.client, savedObjects: savedObjectsPluginMock.createStartContract(), + savedObjectsTaggingOss: savedObjectTaggingOssPluginMock.createStart(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index ee3e914aa4bc..47f544ce2f5d 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import type { SavedObjectsFindOptionsReference } from 'kibana/public'; import { setUISettings, setTypes, @@ -30,6 +32,7 @@ import { VisualizeEmbeddableFactory, createVisEmbeddableFromObject, } from './embeddable'; +import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; import { TypesService } from './vis_types/types_service'; import { range as rangeExpressionFunction } from '../common/expression_functions/range'; import { visDimension as visDimensionExpressionFunction } from '../common/expression_functions/vis_dimension'; @@ -43,7 +46,10 @@ import { showNewVisModal } from './wizard'; import { convertFromSerializedVis, convertToSerializedVis, -} from './saved_visualizations/_saved_vis'; + getSavedVisualization, + saveVisualization, + findListItems, +} from './utils/saved_visualize_utils'; import { createSavedSearchesLoader } from '../../discover/public'; @@ -66,7 +72,9 @@ import type { import type { DataPublicPluginSetup, DataPublicPluginStart } from '../../../plugins/data/public'; import type { ExpressionsSetup, ExpressionsStart } from '../../expressions/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; +import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; import { createVisAsync } from './vis_async'; +import type { VisSavedObject, SaveVisOptions, GetVisOptions } from './types'; /** * Interface for this plugin's returned setup/start contracts. @@ -82,6 +90,13 @@ export interface VisualizationsStart extends TypesStart { convertToSerializedVis: typeof convertToSerializedVis; convertFromSerializedVis: typeof convertFromSerializedVis; showNewVisModal: typeof showNewVisModal; + getSavedVisualization: (opts?: GetVisOptions | string) => Promise; + saveVisualization: (savedVis: VisSavedObject, saveOptions: SaveVisOptions) => Promise; + findListItems: ( + searchTerm: string, + listingLimit: number, + references?: SavedObjectsFindOptionsReference[] + ) => Promise<{ hits: Array>; total: number }>; __LEGACY: { createVisEmbeddableFromObject: ReturnType }; } @@ -103,6 +118,8 @@ export interface VisualizationsStartDeps { getAttributeService: EmbeddableStart['getAttributeService']; savedObjects: SavedObjectsStart; savedObjectsClient: SavedObjectsClientContract; + spaces?: SpacesPluginStart; + savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; } /** @@ -149,7 +166,15 @@ export class VisualizationsPlugin public start( core: CoreStart, - { data, expressions, uiActions, embeddable, savedObjects }: VisualizationsStartDeps + { + data, + expressions, + uiActions, + embeddable, + savedObjects, + spaces, + savedObjectsTaggingOss, + }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); setTypes(types); @@ -181,6 +206,28 @@ export class VisualizationsPlugin return { ...types, showNewVisModal, + getSavedVisualization: async (opts) => { + return getSavedVisualization( + { + search: data.search, + savedObjectsClient: core.savedObjects.client, + dataViews: data.dataViews, + spaces, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + }, + opts + ); + }, + saveVisualization: async (savedVis, saveOptions) => { + return saveVisualization(savedVis, saveOptions, { + savedObjectsClient: core.savedObjects.client, + overlays: core.overlays, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + }); + }, + findListItems: async (searchTerm, listingLimit, references) => { + return findListItems(core.savedObjects.client, types, searchTerm, listingLimit, references); + }, /** * creates new instance of Vis * @param {IndexPattern} indexPattern - index pattern to use diff --git a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts index fb6c99ac8ef0..fbd8e414c273 100644 --- a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts +++ b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts @@ -16,11 +16,11 @@ import type { SavedObjectsStart, SavedObject } from '../../../../plugins/saved_objects/public'; // @ts-ignore import { updateOldState } from '../legacy/vis_update_state'; -import { extractReferences, injectReferences } from './saved_visualization_references'; +import { extractReferences, injectReferences } from '../utils/saved_visualization_references'; import { createSavedSearchesLoader } from '../../../discover/public'; import type { SavedObjectsClientContract } from '../../../../core/public'; import type { IndexPatternsContract } from '../../../../plugins/data/public'; -import type { ISavedVis, SerializedVis } from '../types'; +import type { ISavedVis } from '../types'; export interface SavedVisServices { savedObjectsClient: SavedObjectsClientContract; @@ -28,43 +28,7 @@ export interface SavedVisServices { indexPatterns: IndexPatternsContract; } -export const convertToSerializedVis = (savedVis: ISavedVis): SerializedVis => { - const { id, title, description, visState, uiStateJSON, searchSourceFields } = savedVis; - - const aggs = searchSourceFields && searchSourceFields.index ? visState.aggs || [] : visState.aggs; - - return { - id, - title, - type: visState.type, - description, - params: visState.params, - uiState: JSON.parse(uiStateJSON || '{}'), - data: { - aggs, - searchSource: searchSourceFields!, - savedSearchId: savedVis.savedSearchId, - }, - }; -}; - -export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { - return { - id: vis.id, - title: vis.title, - description: vis.description, - visState: { - title: vis.title, - type: vis.type, - aggs: vis.data.aggs, - params: vis.params, - }, - uiStateJSON: JSON.stringify(vis.uiState), - searchSourceFields: vis.data.searchSource, - savedSearchId: vis.data.savedSearchId, - }; -}; - +/** @deprecated **/ export function createSavedVisClass(services: SavedVisServices) { const savedSearch = createSavedSearchesLoader(services); diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts index d07d28b393dc..cec65b8f988b 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts @@ -22,6 +22,7 @@ export interface FindListItemsOptions { references?: SavedObjectsFindOptionsReference[]; } +/** @deprecated **/ export function createSavedVisLoader(services: SavedVisServicesWithVisualizations) { const { savedObjectsClient, visualizationTypes } = services; diff --git a/src/plugins/visualizations/public/types.ts b/src/plugins/visualizations/public/types.ts index d68599c0724f..5be8f49e9cdc 100644 --- a/src/plugins/visualizations/public/types.ts +++ b/src/plugins/visualizations/public/types.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import { SavedObject } from '../../../plugins/saved_objects/public'; +import type { SavedObjectsMigrationVersion } from 'kibana/public'; import { IAggConfigs, SearchSourceFields, TimefilterContract, AggConfigSerialized, } from '../../../plugins/data/public'; +import type { ISearchSource } from '../../data/common'; import { ExpressionAstExpression } from '../../expressions/public'; import type { SerializedVis, Vis } from './vis'; @@ -36,9 +37,39 @@ export interface ISavedVis { uiStateJSON?: string; savedSearchRefName?: string; savedSearchId?: string; + sharingSavedObjectProps?: { + outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; + aliasTargetId?: string; + errorJSON?: string; + }; } -export interface VisSavedObject extends SavedObject, ISavedVis {} +export interface VisSavedObject extends ISavedVis { + lastSavedTitle: string; + getEsType: () => string; + getDisplayName?: () => string; + displayName: string; + migrationVersion?: SavedObjectsMigrationVersion; + searchSource?: ISearchSource; + version?: string; + tags?: string[]; +} + +export interface SaveVisOptions { + confirmOverwrite?: boolean; + isTitleDuplicateConfirmed?: boolean; + onTitleDuplicate?: () => void; + copyOnSave?: boolean; +} + +export interface GetVisOptions { + id?: string; + searchSource?: boolean; + migrationVersion?: SavedObjectsMigrationVersion; + savedSearchId?: string; + type?: string; + indexPattern?: string; +} export interface VisToExpressionAstParams { timefilter: TimefilterContract; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/index.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/index.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.test.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.test.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts new file mode 100644 index 000000000000..83b16026de39 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts @@ -0,0 +1,507 @@ +/* + * 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 { ISearchSource } from '../../../data/common'; +import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; +import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import { coreMock } from '../../../../core/public/mocks'; +import { dataPluginMock } from '../../../data/public/mocks'; +import { SavedObjectsClientContract } from '../../../../core/public'; +import { + findListItems, + getSavedVisualization, + saveVisualization, + SAVED_VIS_TYPE, +} from './saved_visualize_utils'; +import { VisTypeAlias, TypesStart } from '../vis_types'; +import type { VisSavedObject } from '../types'; + +let visTypes = [] as VisTypeAlias[]; +const mockGetAliases = jest.fn(() => visTypes); +const mockGetTypes = jest.fn((type: string) => type) as unknown as TypesStart['get']; +jest.mock('../services', () => ({ + getSpaces: jest.fn(() => ({ + getActiveSpace: () => ({ + id: 'test', + }), + })), +})); + +const mockParseSearchSourceJSON = jest.fn(); +const mockInjectSearchSourceReferences = jest.fn(); +const mockExtractSearchSourceReferences = jest.fn((...args) => [{}, []]); + +jest.mock('../../../../plugins/data/public', () => ({ + extractSearchSourceReferences: jest.fn((...args) => mockExtractSearchSourceReferences(...args)), + injectSearchSourceReferences: jest.fn((...args) => mockInjectSearchSourceReferences(...args)), + parseSearchSourceJSON: jest.fn((...args) => mockParseSearchSourceJSON(...args)), +})); + +const mockInjectReferences = jest.fn(); +const mockExtractReferences = jest.fn(() => ({ references: [], attributes: {} })); +jest.mock('./saved_visualization_references', () => ({ + injectReferences: jest.fn((...args) => mockInjectReferences(...args)), + extractReferences: jest.fn(() => mockExtractReferences()), +})); + +let isTitleDuplicateConfirmed = true; +const mockCheckForDuplicateTitle = jest.fn(() => { + if (!isTitleDuplicateConfirmed) { + throw new Error(); + } +}); +const mockSaveWithConfirmation = jest.fn(() => ({ id: 'test-after-confirm' })); +jest.mock('../../../../plugins/saved_objects/public', () => ({ + checkForDuplicateTitle: jest.fn(() => mockCheckForDuplicateTitle()), + saveWithConfirmation: jest.fn(() => mockSaveWithConfirmation()), + isErrorNonFatal: jest.fn(() => true), +})); + +describe('saved_visualize_utils', () => { + const { overlays, savedObjects } = coreMock.createStart(); + const savedObjectsClient = savedObjects.client as jest.Mocked; + (savedObjectsClient.resolve as jest.Mock).mockImplementation(() => ({ + saved_object: { + references: [ + { + id: 'test', + type: 'index-pattern', + }, + ], + attributes: { + visState: JSON.stringify({ type: 'area' }), + kibanaSavedObjectMeta: { + searchSourceJSON: '{filter: []}', + }, + }, + _version: '1', + }, + outcome: 'exact', + alias_target_id: null, + })); + (savedObjectsClient.create as jest.Mock).mockImplementation(() => ({ id: 'test' })); + const { dataViews, search } = dataPluginMock.createStartContract(); + + describe('getSavedVisualization', () => { + beforeEach(() => { + mockParseSearchSourceJSON.mockClear(); + mockInjectSearchSourceReferences.mockClear(); + mockInjectReferences.mockClear(); + }); + it('should return object with defaults if was not provided id', async () => { + const savedVis = await getSavedVisualization({ + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + }); + expect(savedVis).toBeDefined(); + expect(savedVis.title).toBe(''); + expect(savedVis.displayName).toBe(SAVED_VIS_TYPE); + }); + + it('should create search source if saved object has searchSourceJSON', async () => { + await getSavedVisualization( + { + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + }, + { id: 'test', searchSource: true } + ); + expect(mockParseSearchSourceJSON).toHaveBeenCalledWith('{filter: []}'); + expect(mockInjectSearchSourceReferences).toHaveBeenCalled(); + expect(search.searchSource.create).toHaveBeenCalled(); + }); + + it('should inject references if saved object has references', async () => { + await getSavedVisualization( + { + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + }, + { id: 'test', searchSource: true } + ); + expect(mockInjectReferences.mock.calls[0][1]).toEqual([ + { + id: 'test', + type: 'index-pattern', + }, + ]); + }); + + it('should call getTagIdsFromReferences if we provide savedObjectsTagging service', async () => { + const mockGetTagIdsFromReferences = jest.fn(() => ['test']); + await getSavedVisualization( + { + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + savedObjectsTagging: { + ui: { + getTagIdsFromReferences: mockGetTagIdsFromReferences, + }, + } as unknown as SavedObjectsTaggingApi, + }, + { id: 'test', searchSource: true } + ); + expect(mockGetTagIdsFromReferences).toHaveBeenCalled(); + }); + }); + + describe('saveVisualization', () => { + let vis: VisSavedObject; + beforeEach(() => { + mockExtractSearchSourceReferences.mockClear(); + mockExtractReferences.mockClear(); + mockSaveWithConfirmation.mockClear(); + savedObjectsClient.create.mockClear(); + vis = { + visState: { + type: 'area', + }, + title: 'test', + uiStateJSON: '{}', + version: '1', + __tags: [], + lastSavedTitle: 'test', + displayName: 'test', + getEsType: () => 'vis', + } as unknown as VisSavedObject; + }); + + it('should return id after save', async () => { + const savedVisId = await saveVisualization(vis, {}, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(mockExtractReferences).toHaveBeenCalled(); + expect(savedVisId).toBe('test'); + }); + + it('should call extractSearchSourceReferences if we new vis has searchSourceFields', async () => { + vis.searchSourceFields = { fields: [] }; + await saveVisualization(vis, {}, { savedObjectsClient, overlays }); + expect(mockExtractSearchSourceReferences).toHaveBeenCalledWith(vis.searchSourceFields); + }); + + it('should serialize searchSource', async () => { + vis.searchSource = { + serialize: jest.fn(() => ({ searchSourceJSON: '{}', references: [] })), + } as unknown as ISearchSource; + await saveVisualization(vis, {}, { savedObjectsClient, overlays }); + expect(vis.searchSource?.serialize).toHaveBeenCalled(); + }); + + it('should call updateTagsReferences if we provide savedObjectsTagging service', async () => { + const mockUpdateTagsReferences = jest.fn(() => []); + await saveVisualization( + vis, + {}, + { + savedObjectsClient, + overlays, + savedObjectsTagging: { + ui: { + updateTagsReferences: mockUpdateTagsReferences, + }, + } as unknown as SavedObjectsTaggingApi, + } + ); + expect(mockUpdateTagsReferences).toHaveBeenCalled(); + }); + + describe('confirmOverwrite', () => { + it('as false we should not call saveWithConfirmation and just do create', async () => { + const savedVisId = await saveVisualization( + vis, + { confirmOverwrite: false }, + { savedObjectsClient, overlays } + ); + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(mockExtractReferences).toHaveBeenCalled(); + expect(mockSaveWithConfirmation).not.toHaveBeenCalled(); + expect(savedVisId).toBe('test'); + }); + + it('as true we should call saveWithConfirmation', async () => { + const savedVisId = await saveVisualization( + vis, + { confirmOverwrite: true }, + { savedObjectsClient, overlays } + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(mockSaveWithConfirmation).toHaveBeenCalled(); + expect(savedVisId).toBe('test-after-confirm'); + }); + }); + + describe('isTitleDuplicateConfirmed', () => { + it('as false we should not save vis with duplicated title', async () => { + isTitleDuplicateConfirmed = false; + const savedVisId = await saveVisualization( + vis, + { isTitleDuplicateConfirmed }, + { savedObjectsClient, overlays } + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(mockSaveWithConfirmation).not.toHaveBeenCalled(); + expect(mockCheckForDuplicateTitle).toHaveBeenCalled(); + expect(savedVisId).toBe(''); + expect(vis.id).toBeUndefined(); + }); + + it('as true we should save vis with duplicated title', async () => { + isTitleDuplicateConfirmed = true; + const savedVisId = await saveVisualization( + vis, + { isTitleDuplicateConfirmed }, + { savedObjectsClient, overlays } + ); + expect(mockCheckForDuplicateTitle).toHaveBeenCalled(); + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(savedVisId).toBe('test'); + expect(vis.id).toBe('test'); + }); + }); + }); + + describe('findListItems', () => { + function testProps() { + (savedObjectsClient.find as jest.Mock).mockImplementation(() => ({ + total: 0, + savedObjects: [], + })); + return { + savedObjectsClient, + search: '', + size: 10, + }; + } + + beforeEach(() => { + savedObjectsClient.find.mockClear(); + }); + + it('searches visualization title and description', async () => { + const props = testProps(); + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['visualization'], + searchFields: ['title^3', 'description'], + }, + ], + ]); + }); + + it('searches searchFields and types specified by app extensions', async () => { + const props = testProps(); + visTypes = [ + { + appExtensions: { + visualizations: { + docTypes: ['bazdoc', 'etc'], + searchFields: ['baz', 'bing'], + }, + }, + } as VisTypeAlias, + ]; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['bazdoc', 'etc', 'visualization'], + searchFields: ['baz', 'bing', 'title^3', 'description'], + }, + ], + ]); + }); + + it('deduplicates types and search fields', async () => { + const props = testProps(); + visTypes = [ + { + appExtensions: { + visualizations: { + docTypes: ['bazdoc', 'bar'], + searchFields: ['baz', 'bing', 'barfield'], + }, + }, + } as VisTypeAlias, + { + appExtensions: { + visualizations: { + docTypes: ['visualization', 'foo', 'bazdoc'], + searchFields: ['baz', 'bing', 'foofield'], + }, + }, + } as VisTypeAlias, + ]; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['bazdoc', 'bar', 'visualization', 'foo'], + searchFields: ['baz', 'bing', 'barfield', 'foofield', 'title^3', 'description'], + }, + ], + ]); + }); + + it('searches the search term prefix', async () => { + const props = { + ...testProps(), + search: 'ahoythere', + }; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + search: 'ahoythere*', + }, + ], + ]); + }); + + it('searches with references', async () => { + const props = { + ...testProps(), + references: [ + { type: 'foo', id: 'hello' }, + { type: 'bar', id: 'dolly' }, + ], + }; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size, + props.references + ); + expect(find.mock.calls).toMatchObject([ + [ + { + hasReference: [ + { type: 'foo', id: 'hello' }, + { type: 'bar', id: 'dolly' }, + ], + }, + ], + ]); + }); + + it('uses type-specific toListItem function, if available', async () => { + const props = testProps(); + + visTypes = [ + { + appExtensions: { + visualizations: { + docTypes: ['wizard'], + toListItem(savedObject) { + return { + id: savedObject.id, + title: `${(savedObject.attributes as { label: string }).label} THE GRAY`, + }; + }, + }, + }, + } as VisTypeAlias, + ]; + (props.savedObjectsClient.find as jest.Mock).mockImplementationOnce(async () => ({ + total: 2, + savedObjects: [ + { + id: 'lotr', + type: 'wizard', + attributes: { label: 'Gandalf' }, + }, + { + id: 'wat', + type: 'visualization', + attributes: { title: 'WATEVER', typeName: 'test' }, + }, + ], + })); + + const items = await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(items).toEqual({ + total: 2, + hits: [ + { + id: 'lotr', + references: undefined, + title: 'Gandalf THE GRAY', + }, + { + id: 'wat', + references: undefined, + icon: undefined, + savedObjectType: 'visualization', + editUrl: '/edit/wat', + type: 'test', + typeName: 'test', + typeTitle: undefined, + title: 'WATEVER', + url: '#/edit/wat', + }, + ], + }); + }); + }); +}); diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts new file mode 100644 index 000000000000..a28ee9486c4d --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -0,0 +1,403 @@ +/* + * 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 _ from 'lodash'; +import type { + SavedObjectsFindOptionsReference, + SavedObjectsFindOptions, + SavedObjectsClientContract, + SavedObjectAttributes, + SavedObjectReference, +} from 'kibana/public'; +import type { OverlayStart } from '../../../../core/public'; +import { SavedObjectNotFound } from '../../../kibana_utils/public'; +import { + extractSearchSourceReferences, + injectSearchSourceReferences, + parseSearchSourceJSON, + DataPublicPluginStart, +} from '../../../../plugins/data/public'; +import { + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, +} from '../../../../plugins/saved_objects/public'; +import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; +import { VisualizationsAppExtension } from '../vis_types/vis_type_alias_registry'; +import type { + VisSavedObject, + SerializedVis, + ISavedVis, + SaveVisOptions, + GetVisOptions, +} from '../types'; +import type { TypesStart, BaseVisType } from '../vis_types'; +// @ts-ignore +import { updateOldState } from '../legacy/vis_update_state'; +import { injectReferences, extractReferences } from './saved_visualization_references'; + +export const SAVED_VIS_TYPE = 'visualization'; + +const getDefaults = (opts: GetVisOptions) => ({ + title: '', + visState: !opts.type ? null : { type: opts.type }, + uiStateJSON: '{}', + description: '', + savedSearchId: opts.savedSearchId, + version: 1, +}); + +export function getFullPath(id: string) { + return `/app/visualize#/edit/${id}`; +} + +export function urlFor(id: string) { + return `#/edit/${encodeURIComponent(id)}`; +} + +export function mapHitSource( + visTypes: Pick, + { + attributes, + id, + references, + }: { + attributes: SavedObjectAttributes; + id: string; + references: SavedObjectReference[]; + } +) { + const newAttributes: { + id: string; + references: SavedObjectReference[]; + url: string; + savedObjectType?: string; + editUrl?: string; + type?: BaseVisType; + icon?: BaseVisType['icon']; + image?: BaseVisType['image']; + typeTitle?: BaseVisType['title']; + error?: string; + } = { + id, + references, + url: urlFor(id), + ...attributes, + }; + + let typeName = attributes.typeName; + if (attributes.visState) { + try { + typeName = JSON.parse(String(attributes.visState)).type; + } catch (e) { + /* missing typename handled below */ + } + } + + if (!typeName || !visTypes.get(typeName as string)) { + newAttributes.error = 'Unknown visualization type'; + return newAttributes; + } + + newAttributes.type = visTypes.get(typeName as string); + newAttributes.savedObjectType = 'visualization'; + newAttributes.icon = newAttributes.type?.icon; + newAttributes.image = newAttributes.type?.image; + newAttributes.typeTitle = newAttributes.type?.title; + newAttributes.editUrl = `/edit/${id}`; + + return newAttributes; +} + +export const convertToSerializedVis = (savedVis: VisSavedObject): SerializedVis => { + const { id, title, description, visState, uiStateJSON, searchSourceFields } = savedVis; + + const aggs = searchSourceFields && searchSourceFields.index ? visState.aggs || [] : visState.aggs; + + return { + id, + title, + type: visState.type, + description, + params: visState.params, + uiState: JSON.parse(uiStateJSON || '{}'), + data: { + aggs, + searchSource: searchSourceFields!, + savedSearchId: savedVis.savedSearchId, + }, + }; +}; + +export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { + return { + id: vis.id, + title: vis.title, + description: vis.description, + visState: { + title: vis.title, + type: vis.type, + aggs: vis.data.aggs, + params: vis.params, + }, + uiStateJSON: JSON.stringify(vis.uiState), + searchSourceFields: vis.data.searchSource, + savedSearchId: vis.data.savedSearchId, + }; +}; + +export async function findListItems( + savedObjectsClient: SavedObjectsClientContract, + visTypes: Pick, + search: string, + size: number, + references?: SavedObjectsFindOptionsReference[] +) { + const visAliases = visTypes.getAliases(); + const extensions = visAliases + .map((v) => v.appExtensions?.visualizations) + .filter(Boolean) as VisualizationsAppExtension[]; + const extensionByType = extensions.reduce((acc, m) => { + return m!.docTypes.reduce((_acc, type) => { + acc[type] = m; + return acc; + }, acc); + }, {} as { [visType: string]: VisualizationsAppExtension }); + const searchOption = (field: string, ...defaults: string[]) => + _(extensions).map(field).concat(defaults).compact().flatten().uniq().value() as string[]; + const searchOptions: SavedObjectsFindOptions = { + type: searchOption('docTypes', 'visualization'), + searchFields: searchOption('searchFields', 'title^3', 'description'), + search: search ? `${search}*` : undefined, + perPage: size, + page: 1, + defaultSearchOperator: 'AND' as 'AND', + hasReference: references, + }; + + const { total, savedObjects } = await savedObjectsClient.find( + searchOptions + ); + + return { + total, + hits: savedObjects.map((savedObject) => { + const config = extensionByType[savedObject.type]; + + if (config) { + return { + ...config.toListItem(savedObject), + references: savedObject.references, + }; + } else { + return mapHitSource(visTypes, savedObject); + } + }), + }; +} + +export async function getSavedVisualization( + services: { + savedObjectsClient: SavedObjectsClientContract; + search: DataPublicPluginStart['search']; + dataViews: DataPublicPluginStart['dataViews']; + spaces?: SpacesPluginStart; + savedObjectsTagging?: SavedObjectsTaggingApi; + }, + opts?: GetVisOptions | string +): Promise { + if (typeof opts !== 'object') { + opts = { id: opts } as GetVisOptions; + } + + const id = (opts.id as string) || ''; + const savedObject = { + id, + migrationVersion: opts.migrationVersion, + displayName: SAVED_VIS_TYPE, + getEsType: () => SAVED_VIS_TYPE, + getDisplayName: () => SAVED_VIS_TYPE, + searchSource: opts.searchSource ? services.search.searchSource.createEmpty() : undefined, + } as VisSavedObject; + const defaultsProps = getDefaults(opts); + + if (!id) { + Object.assign(savedObject, defaultsProps); + return savedObject; + } + + const { + saved_object: resp, + outcome, + alias_target_id: aliasTargetId, + } = await services.savedObjectsClient.resolve(SAVED_VIS_TYPE, id); + + if (!resp._version) { + throw new SavedObjectNotFound(SAVED_VIS_TYPE, id || ''); + } + + const attributes = _.cloneDeep(resp.attributes); + + if (attributes.visState && typeof attributes.visState === 'string') { + attributes.visState = JSON.parse(attributes.visState); + } + + // assign the defaults to the response + _.defaults(attributes, defaultsProps); + + Object.assign(savedObject, attributes); + savedObject.lastSavedTitle = savedObject.title; + + savedObject.sharingSavedObjectProps = { + aliasTargetId, + outcome, + errorJSON: + outcome === 'conflict' && services.spaces + ? JSON.stringify({ + targetType: SAVED_VIS_TYPE, + sourceId: id, + targetSpace: (await services.spaces.getActiveSpace()).id, + }) + : undefined, + }; + + const meta = (attributes.kibanaSavedObjectMeta || {}) as SavedObjectAttributes; + + if (meta.searchSourceJSON) { + try { + let searchSourceValues = parseSearchSourceJSON(meta.searchSourceJSON as string); + + if (opts.searchSource) { + searchSourceValues = injectSearchSourceReferences( + searchSourceValues as any, + resp.references + ); + savedObject.searchSource = await services.search.searchSource.create(searchSourceValues); + } else { + savedObject.searchSourceFields = searchSourceValues; + } + } catch (error: any) { + throw error; + } + } + + if (resp.references && resp.references.length > 0) { + injectReferences(savedObject, resp.references); + } + + if (services.savedObjectsTagging) { + savedObject.tags = services.savedObjectsTagging.ui.getTagIdsFromReferences(resp.references); + } + + savedObject.visState = await updateOldState(savedObject.visState); + if (savedObject.searchSourceFields?.index) { + await services.dataViews.get(savedObject.searchSourceFields.index as any); + } + + return savedObject; +} + +export async function saveVisualization( + savedObject: VisSavedObject, + { + confirmOverwrite = false, + isTitleDuplicateConfirmed = false, + onTitleDuplicate, + copyOnSave = false, + }: SaveVisOptions, + services: { + savedObjectsClient: SavedObjectsClientContract; + overlays: OverlayStart; + savedObjectsTagging?: SavedObjectsTaggingApi; + } +) { + // Save the original id in case the save fails. + const originalId = savedObject.id; + // Read https://github.com/elastic/kibana/issues/9056 and + // https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable + // exists. + // The goal is to move towards a better rename flow, but since our users have been conditioned + // to expect a 'save as' flow during a rename, we are keeping the logic the same until a better + // UI/UX can be worked out. + if (copyOnSave) { + delete savedObject.id; + } + + const attributes: SavedObjectAttributes = { + visState: JSON.stringify(savedObject.visState), + title: savedObject.title, + uiStateJSON: savedObject.uiStateJSON, + description: savedObject.description, + savedSearchId: savedObject.savedSearchId, + version: savedObject.version, + }; + let references: SavedObjectReference[] = []; + + if (savedObject.searchSource) { + const { searchSourceJSON, references: searchSourceReferences } = + savedObject.searchSource.serialize(); + attributes.kibanaSavedObjectMeta = { searchSourceJSON }; + references.push(...searchSourceReferences); + } + + if (savedObject.searchSourceFields) { + const [searchSourceFields, searchSourceReferences] = extractSearchSourceReferences( + savedObject.searchSourceFields + ); + const searchSourceJSON = JSON.stringify(searchSourceFields); + attributes.kibanaSavedObjectMeta = { searchSourceJSON }; + references.push(...searchSourceReferences); + } + + if (services.savedObjectsTagging) { + references = services.savedObjectsTagging.ui.updateTagsReferences( + references, + savedObject.tags || [] + ); + } + + const extractedRefs = extractReferences({ attributes, references }); + + if (!extractedRefs.references) { + throw new Error('References not returned from extractReferences'); + } + + try { + await checkForDuplicateTitle( + { + ...savedObject, + copyOnSave, + } as any, + isTitleDuplicateConfirmed, + onTitleDuplicate, + services as any + ); + const createOpt = { + id: savedObject.id, + migrationVersion: savedObject.migrationVersion, + references: extractedRefs.references, + }; + const resp = confirmOverwrite + ? await saveWithConfirmation(attributes, savedObject, createOpt, services) + : await services.savedObjectsClient.create(SAVED_VIS_TYPE, extractedRefs.attributes, { + ...createOpt, + overwrite: true, + }); + + savedObject.id = resp.id; + savedObject.lastSavedTitle = savedObject.title; + return savedObject.id; + } catch (err: any) { + savedObject.id = originalId; + if (isErrorNonFatal(err)) { + return ''; + } + return Promise.reject(err); + } +} diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index 65ab83d5e0ba..eeaed655c3e7 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -23,5 +23,6 @@ { "path": "../usage_collection/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, { "path": "../discover/tsconfig.json" }, + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, ] } diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index afa9e3ce055b..bfb23bec2111 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -17,7 +17,8 @@ "home", "share", "savedObjectsTaggingOss", - "usageCollection" + "usageCollection", + "spaces" ], "requiredBundles": [ "kibanaUtils", diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx new file mode 100644 index 000000000000..2c8478492005 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { VisualizeEditorCommon } from './visualize_editor_common'; +import { VisualizeEditorVisInstance } from '../types'; + +const mockGetLegacyUrlConflict = jest.fn(); +const mockRedirectLegacyUrl = jest.fn(() => Promise.resolve()); +jest.mock('../../../../kibana_react/public', () => ({ + useKibana: jest.fn(() => ({ + services: { + spaces: { + ui: { + redirectLegacyUrl: mockRedirectLegacyUrl, + components: { + getLegacyUrlConflict: mockGetLegacyUrlConflict, + }, + }, + }, + history: { + location: { + search: '?_g=test', + }, + }, + http: { + basePath: { + prepend: (url: string) => url, + }, + }, + }, + })), + withKibana: jest.fn((comp) => comp), +})); + +describe('VisualizeEditorCommon', () => { + it('should display a conflict callout if saved object conflicts', async () => { + shallow( + {}} + hasUnappliedChanges={false} + isEmbeddableRendered={false} + onAppLeave={() => {}} + visEditorRef={React.createRef()} + visInstance={ + { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance + } + /> + ); + expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({ + currentObjectId: 'test', + objectNoun: 'TSVB visualization', + otherObjectId: 'alias_id', + otherObjectPath: '#/edit/alias_id?_g=test', + }); + }); + + it('should redirect to new id if saved object aliasMatch', async () => { + mount( + {}} + hasUnappliedChanges={false} + isEmbeddableRendered={false} + onAppLeave={() => {}} + visEditorRef={React.createRef()} + visInstance={ + { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'aliasMatch', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance + } + /> + ); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( + '#/edit/alias_id?_g=test', + 'TSVB visualization' + ); + }); +}); diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index a03073e61f59..6268ba5c936e 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -7,15 +7,19 @@ */ import './visualize_editor.scss'; -import React, { RefObject } from 'react'; +import React, { RefObject, useCallback, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { EuiScreenReaderOnly } from '@elastic/eui'; import { AppMountParameters } from 'kibana/public'; import { VisualizeTopNav } from './visualize_top_nav'; import { ExperimentalVisInfo } from './experimental_vis_info'; +import { useKibana } from '../../../../kibana_react/public'; +import { urlFor } from '../../../../visualizations/public'; import { SavedVisInstance, VisualizeAppState, + VisualizeServices, VisualizeAppStateContainer, VisualizeEditorVisInstance, } from '../types'; @@ -53,6 +57,55 @@ export const VisualizeEditorCommon = ({ embeddableId, visEditorRef, }: VisualizeEditorCommonProps) => { + const { services } = useKibana(); + + useEffect(() => { + async function aliasMatchRedirect() { + const sharingSavedObjectProps = visInstance?.savedVis.sharingSavedObjectProps; + if (services.spaces && sharingSavedObjectProps?.outcome === 'aliasMatch') { + // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash + const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch' + const newPath = `${urlFor(newObjectId!)}${services.history.location.search}`; + await services.spaces.ui.redirectLegacyUrl( + newPath, + i18n.translate('visualize.legacyUrlConflict.objectNoun', { + defaultMessage: '{visName} visualization', + values: { + visName: visInstance?.vis?.type.title, + }, + }) + ); + return; + } + } + + aliasMatchRedirect(); + }, [visInstance?.savedVis.sharingSavedObjectProps, visInstance?.vis?.type.title, services]); + + const getLegacyUrlConflictCallout = useCallback(() => { + // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario + const currentObjectId = visInstance?.savedVis.id; + const sharingSavedObjectProps = visInstance?.savedVis.sharingSavedObjectProps; + if (services.spaces && sharingSavedObjectProps?.outcome === 'conflict' && currentObjectId) { + // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a + // callout with a warning for the user, and provide a way for them to navigate to the other object. + const otherObjectId = sharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'conflict' + const otherObjectPath = `${urlFor(otherObjectId)}${services.history.location.search}`; + return services.spaces.ui.components.getLegacyUrlConflict({ + objectNoun: i18n.translate('visualize.legacyUrlConflict.objectNoun', { + defaultMessage: '{visName} visualization', + values: { + visName: visInstance?.vis?.type.title, + }, + }), + currentObjectId, + otherObjectId, + otherObjectPath, + }); + } + return null; + }, [visInstance?.savedVis, services, visInstance?.vis?.type.title]); + return (
{visInstance && appState && currentAppState && ( @@ -74,6 +127,7 @@ export const VisualizeEditorCommon = ({ )} {visInstance?.vis?.type?.stage === 'experimental' && } {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} + {getLegacyUrlConflictCallout()} {visInstance && (

diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 07c3d18b54b0..7319a9b5e52f 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -31,7 +31,6 @@ export const VisualizeListing = () => { chrome, dashboard, history, - savedVisualizations, toastNotifications, visualizations, stateTransferService, @@ -113,16 +112,16 @@ export const VisualizeListing = () => { } const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); - return savedVisualizations - .findListItems(searchTerm, { size: listingLimit, references }) - .then(({ total, hits }: { total: number; hits: object[] }) => ({ + return visualizations + .findListItems(searchTerm, listingLimit, references) + .then(({ total, hits }: { total: number; hits: Array> }) => ({ total, hits: hits.filter( (result: any) => isLabsEnabled || result.type?.stage !== 'experimental' ), })); }, - [listingLimit, savedVisualizations, uiSettings, savedObjectsTagging] + [listingLimit, uiSettings, savedObjectsTagging, visualizations] ); const deleteItems = useCallback( diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index 7e9f69163f5a..4debd9a4a7b7 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -42,6 +42,7 @@ import type { SavedObjectsStart, SavedObject } from 'src/plugins/saved_objects/p import type { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import type { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import type { PresentationUtilPluginStart } from 'src/plugins/presentation_util/public'; +import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; import type { DashboardStart } from '../../../dashboard/public'; import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; import type { UsageCollectionStart } from '../../../usage_collection/public'; @@ -94,7 +95,6 @@ export interface VisualizeServices extends CoreStart { dashboardCapabilities: Record>; visualizations: VisualizationsStart; savedObjectsPublic: SavedObjectsStart; - savedVisualizations: VisualizationsStart['savedVisualizationsLoader']; setActiveUrl: (newUrl: string) => void; createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject']; restorePreviousUrl: () => void; @@ -105,6 +105,7 @@ export interface VisualizeServices extends CoreStart { presentationUtil: PresentationUtilPluginStart; usageCollection?: UsageCollectionStart; getKibanaVersion: () => string; + spaces?: SpacesPluginStart; } export interface SavedVisInstance { diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 0dc37ca00a6a..9d1c93f25645 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -14,7 +14,11 @@ import { parse } from 'query-string'; import { Capabilities } from 'src/core/public'; import { TopNavMenuData } from 'src/plugins/navigation/public'; -import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; +import { + VISUALIZE_EMBEDDABLE_TYPE, + VisualizeInput, + getFullPath, +} from '../../../../visualizations/public'; import { showSaveModal, SavedObjectSaveModalOrigin, @@ -87,6 +91,7 @@ export const getTopNavConfig = ( data, application, chrome, + overlays, history, share, setActiveUrl, @@ -99,6 +104,8 @@ export const getTopNavConfig = ( presentationUtil, usageCollection, getKibanaVersion, + savedObjects, + visualizations, }: VisualizeServices ) => { const { vis, embeddableHandler } = visInstance; @@ -117,8 +124,10 @@ export const getTopNavConfig = ( /** * Called when the user clicks "Save" button. */ - async function doSave(saveOptions: SavedObjectSaveOpts & { dashboardId?: string }) { - const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave; + async function doSave( + saveOptions: SavedObjectSaveOpts & { dashboardId?: string; copyOnSave?: boolean } + ) { + const newlyCreated = !Boolean(savedVis.id) || saveOptions.copyOnSave; // vis.title was not bound and it's needed to reflect title into visState stateContainer.transitions.setVis({ title: savedVis.title, @@ -129,7 +138,7 @@ export const getTopNavConfig = ( setHasUnsavedChanges(false); try { - const id = await savedVis.save(saveOptions); + const id = await visualizations.saveVisualization(savedVis, saveOptions); if (id) { toastNotifications.addSuccess({ @@ -142,6 +151,8 @@ export const getTopNavConfig = ( 'data-test-subj': 'saveVisualizationSuccess', }); + chrome.recentlyAccessed.add(getFullPath(id), savedVis.title, String(id)); + if ((originatingApp && saveOptions.returnToOrigin) || saveOptions.dashboardId) { if (!embeddableId) { const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(id)}`; @@ -164,7 +175,7 @@ export const getTopNavConfig = ( state: { type: VISUALIZE_EMBEDDABLE_TYPE, input: { savedObjectId: id }, - embeddableId: savedVis.copyOnSave ? undefined : embeddableId, + embeddableId: saveOptions.copyOnSave ? undefined : embeddableId, searchSessionId: data.search.session.getSessionId(), }, path, @@ -392,11 +403,10 @@ export const getTopNavConfig = ( const currentTitle = savedVis.title; savedVis.title = newTitle; embeddableHandler.updateInput({ title: newTitle }); - savedVis.copyOnSave = newCopyOnSave; savedVis.description = newDescription; - if (savedObjectsTagging && savedObjectsTagging.ui.hasTagDecoration(savedVis)) { - savedVis.setTags(selectedTags); + if (savedObjectsTagging) { + savedVis.tags = selectedTags; } const saveOptions = { @@ -405,6 +415,7 @@ export const getTopNavConfig = ( onTitleDuplicate, returnToOrigin, dashboardId: !!dashboardId ? dashboardId : undefined, + copyOnSave: newCopyOnSave, }; // If we're adding to a dashboard and not saving to library, @@ -457,9 +468,7 @@ export const getTopNavConfig = ( let tagOptions: React.ReactNode | undefined; if (savedObjectsTagging) { - if (savedVis && savedObjectsTagging.ui.hasTagDecoration(savedVis)) { - selectedTags = savedVis.getTags(); - } + selectedTags = savedVis.tags || []; tagOptions = ( ({ })), })); +let savedVisMock: VisSavedObject; + describe('getVisualizationInstance', () => { const serializedVisMock = { type: 'area', }; - let savedVisMock: VisSavedObject; let visMock: Vis; let mockServices: jest.Mocked; let subj: BehaviorSubject; @@ -47,13 +48,16 @@ describe('getVisualizationInstance', () => { data: {}, } as Vis; savedVisMock = {} as VisSavedObject; + // @ts-expect-error mockServices.data.search.showError.mockImplementation(() => {}); // @ts-expect-error - mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock); - // @ts-expect-error mockServices.visualizations.convertToSerializedVis.mockImplementation(() => serializedVisMock); // @ts-expect-error + mockServices.visualizations.getSavedVisualization.mockImplementation( + (opts: unknown) => savedVisMock + ); + // @ts-expect-error mockServices.visualizations.createVis.mockImplementation(() => visMock); // @ts-expect-error mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({ @@ -71,7 +75,9 @@ describe('getVisualizationInstance', () => { opts ); - expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith(opts); + expect((mockServices.visualizations.getSavedVisualization as jest.Mock).mock.calls[0][0]).toBe( + opts + ); expect(savedVisMock.searchSourceFields).toEqual({ index: opts.indexPattern, }); @@ -98,7 +104,9 @@ describe('getVisualizationInstance', () => { visMock.type.setup = jest.fn(() => newVisObj); const { vis } = await getVisualizationInstance(mockServices, 'saved_vis_id'); - expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith('saved_vis_id'); + expect((mockServices.visualizations.getSavedVisualization as jest.Mock).mock.calls[0][0]).toBe( + 'saved_vis_id' + ); expect(savedVisMock.searchSourceFields).toBeUndefined(); expect(visMock.type.setup).toHaveBeenCalledWith(visMock); expect(vis).toBe(newVisObj); @@ -128,7 +136,6 @@ describe('getVisualizationInstanceInput', () => { const serializedVisMock = { type: 'pie', }; - let savedVisMock: VisSavedObject; let visMock: Vis; let mockServices: jest.Mocked; let subj: BehaviorSubject; @@ -142,10 +149,12 @@ describe('getVisualizationInstanceInput', () => { } as Vis; savedVisMock = {} as VisSavedObject; // @ts-expect-error - mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock); - // @ts-expect-error mockServices.visualizations.createVis.mockImplementation(() => visMock); // @ts-expect-error + mockServices.visualizations.getSavedVisualization.mockImplementation( + (opts: unknown) => savedVisMock + ); + // @ts-expect-error mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({ getOutput$: jest.fn(() => subj.asObservable()), })); @@ -183,7 +192,7 @@ describe('getVisualizationInstanceInput', () => { const { savedVis, savedSearch, vis, embeddableHandler } = await getVisualizationInstanceFromInput(mockServices, input); - expect(mockServices.savedVisualizations.get).toHaveBeenCalled(); + expect(mockServices.visualizations.getSavedVisualization).toHaveBeenCalled(); expect(mockServices.visualizations.createVis).toHaveBeenCalledWith( serializedVisMock.type, input.savedVis diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts index 88797ce264e2..faf25ff28cec 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts @@ -66,14 +66,15 @@ export const getVisualizationInstanceFromInput = async ( visualizeServices: VisualizeServices, input: VisualizeInput ) => { - const { visualizations, savedVisualizations } = visualizeServices; + const { visualizations } = visualizeServices; const visState = input.savedVis as SerializedVis; /** * A saved vis is needed even in by value mode to support 'save to library' which converts the 'by value' * state of the visualization, into a new saved object. */ - const savedVis: VisSavedObject = await savedVisualizations.get(); + const savedVis: VisSavedObject = await visualizations.getSavedVisualization(); + if (visState.uiState && Object.keys(visState.uiState).length !== 0) { savedVis.uiStateJSON = JSON.stringify(visState.uiState); } @@ -107,8 +108,8 @@ export const getVisualizationInstance = async ( */ opts?: Record | string ) => { - const { visualizations, savedVisualizations } = visualizeServices; - const savedVis: VisSavedObject = await savedVisualizations.get(opts); + const { visualizations } = visualizeServices; + const savedVis: VisSavedObject = await visualizations.getSavedVisualization(opts); if (typeof opts !== 'string') { savedVis.searchSourceFields = { index: opts?.indexPattern } as SearchSourceFields; diff --git a/src/plugins/visualize/public/application/utils/mocks.ts b/src/plugins/visualize/public/application/utils/mocks.ts index a7029071851c..f26c81ed99a8 100644 --- a/src/plugins/visualize/public/application/utils/mocks.ts +++ b/src/plugins/visualize/public/application/utils/mocks.ts @@ -26,7 +26,6 @@ export const createVisualizeServicesMock = () => { location: { pathname: '' }, }, visualizations, - savedVisualizations: visualizations.savedVisualizationsLoader, createVisEmbeddableFromObject: visualizations.__LEGACY.createVisEmbeddableFromObject, } as unknown as jest.Mocked; }; diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts index b142f3fcd406..f81744326365 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts @@ -22,7 +22,6 @@ import { createEmbeddableStateTransferMock } from '../../../../../embeddable/pub const mockDefaultEditorControllerDestroy = jest.fn(); const mockEmbeddableHandlerDestroy = jest.fn(); const mockEmbeddableHandlerRender = jest.fn(); -const mockSavedVisDestroy = jest.fn(); const savedVisId = '9ca7aa90-b892-11e8-a6d9-e546fe2bba5f'; const mockSavedVisInstance = { embeddableHandler: { @@ -32,7 +31,6 @@ const mockSavedVisInstance = { savedVis: { id: savedVisId, title: 'Test Vis', - destroy: mockSavedVisDestroy, }, vis: { type: {}, @@ -103,7 +101,6 @@ describe('useSavedVisInstance', () => { mockDefaultEditorControllerDestroy.mockClear(); mockEmbeddableHandlerDestroy.mockClear(); mockEmbeddableHandlerRender.mockClear(); - mockSavedVisDestroy.mockClear(); toastNotifications.addWarning.mockClear(); mockGetVisualizationInstance.mockClear(); }); @@ -153,7 +150,6 @@ describe('useSavedVisInstance', () => { expect(mockDefaultEditorControllerDestroy.mock.calls.length).toBe(1); expect(mockEmbeddableHandlerDestroy).not.toHaveBeenCalled(); - expect(mockSavedVisDestroy.mock.calls.length).toBe(1); }); }); @@ -236,7 +232,6 @@ describe('useSavedVisInstance', () => { unmount(); expect(mockDefaultEditorControllerDestroy).not.toHaveBeenCalled(); expect(mockEmbeddableHandlerDestroy.mock.calls.length).toBe(1); - expect(mockSavedVisDestroy.mock.calls.length).toBe(1); }); }); }); diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts index 965951bfbd88..b5919ec07496 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts @@ -176,9 +176,6 @@ export const useSavedVisInstance = ( } else if (state.savedVisInstance?.embeddableHandler) { state.savedVisInstance.embeddableHandler.destroy(); } - if (state.savedVisInstance?.savedVis) { - state.savedVisInstance.savedVis.destroy(); - } }; }, [state]); diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index b128c0920974..c9df6a6ec57d 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -28,6 +28,7 @@ import { createKbnUrlStateStorage, withNotifyOnErrors, } from '../../kibana_utils/public'; +import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; import { VisualizeConstants } from './application/visualize_constants'; import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; @@ -61,6 +62,7 @@ export interface VisualizePluginStartDependencies { savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; presentationUtil: PresentationUtilPluginStart; usageCollection?: UsageCollectionStart; + spaces: SpacesPluginStart; } export interface VisualizePluginSetupDependencies { @@ -192,7 +194,6 @@ export class VisualizePlugin data: pluginsStart.data, localStorage: new Storage(localStorage), navigation: pluginsStart.navigation, - savedVisualizations: pluginsStart.visualizations.savedVisualizationsLoader, share: pluginsStart.share, toastNotifications: coreStart.notifications.toasts, visualizeCapabilities: coreStart.application.capabilities.visualize, @@ -212,6 +213,7 @@ export class VisualizePlugin presentationUtil: pluginsStart.presentationUtil, usageCollection: pluginsStart.usageCollection, getKibanaVersion: () => this.initializerContext.env.packageInfo.version, + spaces: pluginsStart.spaces, }; params.element.classList.add('visAppWrapper'); diff --git a/src/plugins/visualize/tsconfig.json b/src/plugins/visualize/tsconfig.json index 3f1f7487085b..9c1e3fd72ff8 100644 --- a/src/plugins/visualize/tsconfig.json +++ b/src/plugins/visualize/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../home/tsconfig.json" }, { "path": "../presentation_util/tsconfig.json" }, - { "path": "../discover/tsconfig.json" } + { "path": "../discover/tsconfig.json" }, + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, ] } From 930fe96260d08830d64cd6e7f52b553341ceaa48 Mon Sep 17 00:00:00 2001 From: juliaElastic <90178898+juliaElastic@users.noreply.github.com> Date: Mon, 11 Oct 2021 11:00:53 +0200 Subject: [PATCH 04/19] [Fleet] added support for installing tag saved objects (#114110) * added tag saved objects to assets * fixed review comments * added translation to constants * added missing icon type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../package_to_package_policy.test.ts | 1 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../components/assets_facet_group.stories.tsx | 1 + .../integrations/sections/epm/constants.tsx | 65 ++++++++++++++----- .../services/epm/kibana/assets/install.ts | 2 + ...kage_policies_to_agent_permissions.test.ts | 3 + .../context/fixtures/integration.nginx.ts | 1 + .../context/fixtures/integration.okta.ts | 1 + .../apis/epm/install_remove_assets.ts | 11 ++++ .../apis/epm/update_assets.ts | 5 ++ .../kibana/dashboard/sample_dashboard.json | 3 +- .../0.1.0/kibana/tag/sample_tag.json | 17 +++++ .../kibana/dashboard/sample_dashboard.json | 3 +- .../0.2.0/kibana/tag/sample_tag.json | 17 +++++ .../error_handling/0.2.0/manifest.yml | 1 + 15 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index 275cf237a962..e554eb925c38 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -33,6 +33,7 @@ describe('Fleet - packageToPackagePolicy', () => { lens: [], ml_module: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 1325d74f82b6..a487fd0a37e7 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -69,6 +69,7 @@ export enum KibanaAssetType { lens = 'lens', securityRule = 'security_rule', mlModule = 'ml_module', + tag = 'tag', } /* @@ -83,6 +84,7 @@ export enum KibanaSavedObjectType { lens = 'lens', mlModule = 'ml-module', securityRule = 'security-rule', + tag = 'tag', } export enum ElasticsearchAssetType { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx index d98f2b2408d5..a7fa069e77a6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx @@ -36,6 +36,7 @@ export const AssetsFacetGroup = ({ width }: Args) => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx index 8e900e625215..25604bb6b984 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx @@ -6,6 +6,7 @@ */ import type { IconType } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import type { ServiceName } from '../../types'; import { ElasticsearchAssetType, KibanaAssetType } from '../../types'; @@ -22,21 +23,54 @@ export const DisplayedAssets: ServiceNameToAssetTypes = { export type DisplayedAssetType = ElasticsearchAssetType | KibanaAssetType | 'view'; export const AssetTitleMap: Record = { - dashboard: 'Dashboards', - ilm_policy: 'ILM policies', - ingest_pipeline: 'Ingest pipelines', - transform: 'Transforms', - index_pattern: 'Index patterns', - index_template: 'Index templates', - component_template: 'Component templates', - search: 'Saved searches', - visualization: 'Visualizations', - map: 'Maps', - data_stream_ilm_policy: 'Data stream ILM policies', - lens: 'Lens', - security_rule: 'Security rules', - ml_module: 'ML modules', - view: 'Views', + dashboard: i18n.translate('xpack.fleet.epm.assetTitles.dashboards', { + defaultMessage: 'Dashboards', + }), + ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.ilmPolicies', { + defaultMessage: 'ILM policies', + }), + ingest_pipeline: i18n.translate('xpack.fleet.epm.assetTitles.ingestPipelines', { + defaultMessage: 'Ingest pipelines', + }), + transform: i18n.translate('xpack.fleet.epm.assetTitles.transforms', { + defaultMessage: 'Transforms', + }), + index_pattern: i18n.translate('xpack.fleet.epm.assetTitles.indexPatterns', { + defaultMessage: 'Index patterns', + }), + index_template: i18n.translate('xpack.fleet.epm.assetTitles.indexTemplates', { + defaultMessage: 'Index templates', + }), + component_template: i18n.translate('xpack.fleet.epm.assetTitles.componentTemplates', { + defaultMessage: 'Component templates', + }), + search: i18n.translate('xpack.fleet.epm.assetTitles.savedSearches', { + defaultMessage: 'Saved searches', + }), + visualization: i18n.translate('xpack.fleet.epm.assetTitles.visualizations', { + defaultMessage: 'Visualizations', + }), + map: i18n.translate('xpack.fleet.epm.assetTitles.maps', { + defaultMessage: 'Maps', + }), + data_stream_ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.dataStreamILM', { + defaultMessage: 'Data stream ILM policies', + }), + lens: i18n.translate('xpack.fleet.epm.assetTitles.lens', { + defaultMessage: 'Lens', + }), + security_rule: i18n.translate('xpack.fleet.epm.assetTitles.securityRules', { + defaultMessage: 'Security rules', + }), + ml_module: i18n.translate('xpack.fleet.epm.assetTitles.mlModules', { + defaultMessage: 'ML modules', + }), + view: i18n.translate('xpack.fleet.epm.assetTitles.views', { + defaultMessage: 'Views', + }), + tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', { + defaultMessage: 'Tag', + }), }; export const ServiceTitleMap: Record = { @@ -53,6 +87,7 @@ export const AssetIcons: Record = { lens: 'lensApp', security_rule: 'securityApp', ml_module: 'mlApp', + tag: 'tagApp', }; export const ServiceIcons: Record = { diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 0f2d7b6679bf..50c0239cd8c5 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -39,6 +39,7 @@ const KibanaSavedObjectTypeMapping: Record { diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 9f8ac01afe6c..845e4f1d2670 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -97,6 +97,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], @@ -207,6 +208,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], @@ -323,6 +325,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts index 50262b73a6a4..e0179897a59c 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts @@ -252,6 +252,7 @@ export const response: GetInfoResponse['response'] = { lens: [], map: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts index efef00579f4b..387161171485 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const response: GetInfoResponse['response'] = { lens: [], ml_module: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index e57899531e93..79f3d52821f7 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -414,6 +414,7 @@ const expectAssetsInstalled = ({ id: 'sample_dashboard', }); expect(resDashboard.id).equal('sample_dashboard'); + expect(resDashboard.references.map((ref: any) => ref.id).includes('sample_tag')).equal(true); const resDashboard2 = await kibanaServer.savedObjects.get({ type: 'dashboard', id: 'sample_dashboard2', @@ -444,6 +445,11 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', }); expect(resSecurityRule.id).equal('sample_security_rule'); + const resTag = await kibanaServer.savedObjects.get({ + type: 'tag', + id: 'sample_tag', + }); + expect(resTag.id).equal('sample_tag'); const resIndexPattern = await kibanaServer.savedObjects.get({ type: 'index-pattern', id: 'test-*', @@ -521,6 +527,10 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', type: 'security-rule', }, + { + id: 'sample_tag', + type: 'tag', + }, { id: 'sample_visualization', type: 'visualization', @@ -607,6 +617,7 @@ const expectAssetsInstalled = ({ { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, + { id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', type: 'epm-packages-assets' }, { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index f46dcdb761e6..528231216414 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -339,6 +339,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_ml_module', type: 'ml-module', }, + { + id: 'sample_tag', + type: 'tag', + }, ], installed_es: [ { @@ -418,6 +422,7 @@ export default function (providerContext: FtrProviderContext) { { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, { id: '2e56f08b-1d06-55ed-abee-4708e1ccf0aa', type: 'epm-packages-assets' }, { id: '4035007b-9c33-5227-9803-2de8a17523b5', type: 'epm-packages-assets' }, + { id: 'e6ae7d31-6920-5408-9219-91ef1662044b', type: 'epm-packages-assets' }, { id: 'c7bf1a39-e057-58a0-afde-fb4b48751d8c', type: 'epm-packages-assets' }, { id: '8c665f28-a439-5f43-b5fd-8fda7b576735', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json index 7f416c26cc9a..c75dd7673dc3 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json @@ -15,7 +15,8 @@ { "id": "sample_visualization", "name": "panel_0", "type": "visualization" }, { "id": "sample_search", "name": "panel_1", "type": "search" }, { "id": "sample_search", "name": "panel_2", "type": "search" }, - { "id": "sample_visualization", "name": "panel_3", "type": "visualization" } + { "id": "sample_visualization", "name": "panel_3", "type": "visualization" }, + { "id": "sample_tag", "type": "tag", "name": "tag-ref-sample_tag" } ], "id": "sample_dashboard", "type": "dashboard" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json new file mode 100644 index 000000000000..c6494d42679b --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json @@ -0,0 +1,17 @@ +{ + "id": "sample_tag", + "type": "tag", + "namespaces": [ + "default" + ], + "attributes": { + "name": "my tag", + "description": "", + "color": "#a80853" + }, + "references": [], + "migrationVersion": { + "tag": "8.0.0" + }, + "coreMigrationVersion": "8.0.0" +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json index 4513c07f2778..1215a934c636 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json @@ -15,7 +15,8 @@ { "id": "sample_visualization", "name": "panel_0", "type": "visualization" }, { "id": "sample_search2", "name": "panel_1", "type": "search" }, { "id": "sample_search2", "name": "panel_2", "type": "search" }, - { "id": "sample_visualization", "name": "panel_3", "type": "visualization" } + { "id": "sample_visualization", "name": "panel_3", "type": "visualization" }, + { "id": "sample_tag", "type": "tag", "name": "tag-ref-sample_tag" } ], "id": "sample_dashboard", "type": "dashboard" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json new file mode 100644 index 000000000000..c6494d42679b --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json @@ -0,0 +1,17 @@ +{ + "id": "sample_tag", + "type": "tag", + "namespaces": [ + "default" + ], + "attributes": { + "name": "my tag", + "description": "", + "color": "#a80853" + }, + "references": [], + "migrationVersion": { + "tag": "8.0.0" + }, + "coreMigrationVersion": "8.0.0" +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml index c92f0ab5ae7f..c473ce29b87d 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml @@ -17,3 +17,4 @@ requirement: icons: - src: '/img/logo_overrides_64_color.svg' size: '16x16' + type: 'image/svg+xml' From 25fef38f123dc6c7b5e6c94f7268d42a248b5db7 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Mon, 11 Oct 2021 04:19:00 -0500 Subject: [PATCH 05/19] [fleet][ui] Fix offset image; scrollbar flashing; missing assets in Stories (#114406) --- packages/kbn-storybook/src/lib/run_storybook_cli.ts | 6 +++++- .../plugins/fleet/public/applications/integrations/app.tsx | 6 ++---- .../public/applications/integrations/layouts/default.tsx | 2 ++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/kbn-storybook/src/lib/run_storybook_cli.ts b/packages/kbn-storybook/src/lib/run_storybook_cli.ts index 24a3e4511f7b..93197a1f2b31 100644 --- a/packages/kbn-storybook/src/lib/run_storybook_cli.ts +++ b/packages/kbn-storybook/src/lib/run_storybook_cli.ts @@ -36,7 +36,11 @@ export function runStorybookCli({ configDir, name }: { configDir: string; name: async ({ flags, log }) => { log.debug('Global config:\n', constants); - const staticDir = [UiSharedDepsNpm.distDir, UiSharedDepsSrc.distDir]; + const staticDir = [ + UiSharedDepsNpm.distDir, + UiSharedDepsSrc.distDir, + 'src/plugins/kibana_react/public/assets:plugins/kibanaReact/assets', + ]; const config: Record = { configDir, mode: flags.site ? 'static' : 'dev', diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index c5cc1e1892ed..b10cef9d3ffe 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -39,15 +39,13 @@ import { Error, Loading, SettingFlyout } from './components'; import type { UIExtensionsStorage } from './types'; import { EPMApp } from './sections/epm'; -import { DefaultLayout, WithoutHeaderLayout } from './layouts'; +import { DefaultLayout } from './layouts'; import { PackageInstallProvider } from './hooks'; import { useBreadcrumbs, UIExtensionsContext } from './hooks'; const ErrorLayout = ({ children }: { children: JSX.Element }) => ( - - {children} - + {children} ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx index e4de48a85c35..70e55c9bd56b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx @@ -28,6 +28,8 @@ interface Props { const Illustration = styled(EuiImage)` margin-bottom: -68px; + position: relative; + top: -20px; width: 80%; `; From cba91fdaab4153c4a7257c506f187c30755b3031 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 11 Oct 2021 11:48:10 +0200 Subject: [PATCH 06/19] Status service: improve overall status summary (#114228) * improve getSummaryStatus * fix unit tests --- .../server/status/get_summary_status.test.ts | 153 +++++++++--------- src/core/server/status/get_summary_status.ts | 40 +++-- src/core/server/status/plugins_status.test.ts | 32 ++-- src/core/server/status/status_service.test.ts | 26 +-- 4 files changed, 127 insertions(+), 124 deletions(-) diff --git a/src/core/server/status/get_summary_status.test.ts b/src/core/server/status/get_summary_status.test.ts index 33b2e6f7913a..2c91aa8c7b16 100644 --- a/src/core/server/status/get_summary_status.test.ts +++ b/src/core/server/status/get_summary_status.test.ts @@ -81,93 +81,86 @@ describe('getSummaryStatus', () => { }); describe('summary', () => { - describe('when a single service is at highest level', () => { - it('returns all information about that single service', () => { - expect( - getSummaryStatus( - Object.entries({ - s1: degraded, - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - meta: { - custom: { data: 'here' }, - }, + it('returns correct summary when a single service is affected', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + meta: { + custom: { data: 'here' }, }, - }) - ) - ).toEqual({ - level: ServiceStatusLevels.unavailable, - summary: '[s2]: Lorem ipsum', - detail: 'See the status page for more information', - meta: { - affectedServices: ['s2'], - }, - }); + }, + }) + ) + ).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '1 service is unavailable: s2', + detail: 'See the status page for more information', + meta: { + affectedServices: ['s2'], + }, }); + }); - it('allows the single service to override the detail and documentationUrl fields', () => { - expect( - getSummaryStatus( - Object.entries({ - s1: degraded, - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - custom: { data: 'here' }, - }, + it('returns correct summary when multiple services are affected', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + detail: 'Vivamus pulvinar sem ac luctus ultrices.', + documentationUrl: 'http://helpmenow.com/problem1', + meta: { + custom: { data: 'here' }, + }, + }, + s3: { + level: ServiceStatusLevels.unavailable, + summary: 'Proin mattis', + detail: 'Nunc quis nulla at mi lobortis pretium.', + documentationUrl: 'http://helpmenow.com/problem2', + meta: { + other: { data: 'over there' }, }, - }) - ) - ).toEqual({ - level: ServiceStatusLevels.unavailable, - summary: '[s2]: Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - affectedServices: ['s2'], - }, - }); + }, + }) + ) + ).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '2 services are unavailable: s2, s3', + detail: 'See the status page for more information', + meta: { + affectedServices: ['s2', 's3'], + }, }); }); - describe('when multiple services is at highest level', () => { - it('returns aggregated information about the affected services', () => { - expect( - getSummaryStatus( - Object.entries({ - s1: degraded, - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - custom: { data: 'here' }, - }, - }, - s3: { - level: ServiceStatusLevels.unavailable, - summary: 'Proin mattis', - detail: 'Nunc quis nulla at mi lobortis pretium.', - documentationUrl: 'http://helpmenow.com/problem2', - meta: { - other: { data: 'over there' }, - }, - }, - }) - ) - ).toEqual({ - level: ServiceStatusLevels.unavailable, - summary: '[2] services are unavailable', - detail: 'See the status page for more information', - meta: { - affectedServices: ['s2', 's3'], - }, - }); + it('returns correct summary more than `maxServices` services are affected', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: available, + s3: degraded, + s4: degraded, + s5: degraded, + s6: available, + s7: degraded, + }), + { maxServices: 3 } + ) + ).toEqual({ + level: ServiceStatusLevels.degraded, + summary: '5 services are degraded: s1, s3, s4 and 2 other(s)', + detail: 'See the status page for more information', + meta: { + affectedServices: ['s1', 's3', 's4', 's5', 's7'], + }, }); }); }); diff --git a/src/core/server/status/get_summary_status.ts b/src/core/server/status/get_summary_status.ts index 9124023148dd..1dc939ce3f80 100644 --- a/src/core/server/status/get_summary_status.ts +++ b/src/core/server/status/get_summary_status.ts @@ -10,11 +10,13 @@ import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from './types' /** * Returns a single {@link ServiceStatus} that summarizes the most severe status level from a group of statuses. - * @param statuses */ export const getSummaryStatus = ( statuses: Array<[string, ServiceStatus]>, - { allAvailableSummary = `All services are available` }: { allAvailableSummary?: string } = {} + { + allAvailableSummary = `All services are available`, + maxServices = 3, + }: { allAvailableSummary?: string; maxServices?: number } = {} ): ServiceStatus => { const { highestLevel, highestStatuses } = highestLevelSummary(statuses); @@ -23,30 +25,38 @@ export const getSummaryStatus = ( level: ServiceStatusLevels.available, summary: allAvailableSummary, }; - } else if (highestStatuses.length === 1) { - const [serviceName, status] = highestStatuses[0]! as [string, ServiceStatus]; - return { - ...status, - summary: `[${serviceName}]: ${status.summary!}`, - // TODO: include URL to status page - detail: status.detail ?? `See the status page for more information`, - meta: { - affectedServices: [serviceName], - }, - }; } else { + const affectedServices = highestStatuses.map(([serviceName]) => serviceName); return { level: highestLevel, - summary: `[${highestStatuses.length}] services are ${highestLevel.toString()}`, + summary: getSummaryContent(affectedServices, highestLevel, maxServices), // TODO: include URL to status page detail: `See the status page for more information`, meta: { - affectedServices: highestStatuses.map(([serviceName]) => serviceName), + affectedServices, }, }; } }; +const getSummaryContent = ( + affectedServices: string[], + statusLevel: ServiceStatusLevel, + maxServices: number +): string => { + const serviceCount = affectedServices.length; + if (serviceCount === 1) { + return `1 service is ${statusLevel.toString()}: ${affectedServices[0]}`; + } else if (serviceCount > maxServices) { + const exceedingCount = serviceCount - maxServices; + return `${serviceCount} services are ${statusLevel.toString()}: ${affectedServices + .slice(0, maxServices) + .join(', ')} and ${exceedingCount} other(s)`; + } else { + return `${serviceCount} services are ${statusLevel.toString()}: ${affectedServices.join(', ')}`; + } +}; + type StatusPair = [string, ServiceStatus]; const highestLevelSummary = ( diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index b7c0733de728..0befbf63bd18 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -73,7 +73,7 @@ describe('PluginStatusService', () => { }); expect(await serviceDegraded.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -84,7 +84,7 @@ describe('PluginStatusService', () => { }); expect(await serviceCritical.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -95,7 +95,7 @@ describe('PluginStatusService', () => { service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a is degraded' })); expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: savedObjects, a', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -106,7 +106,7 @@ describe('PluginStatusService', () => { service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' })); expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.unavailable, - summary: '[a]: a is not working', + summary: '1 service is unavailable: a', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -120,7 +120,7 @@ describe('PluginStatusService', () => { service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' })); expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -132,7 +132,7 @@ describe('PluginStatusService', () => { service.set('b', of({ level: ServiceStatusLevels.unavailable, summary: 'b is not working' })); expect(await service.getDerivedStatus$('c').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.unavailable, - summary: '[b]: b is not working', + summary: '1 service is unavailable: b', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -166,19 +166,19 @@ describe('PluginStatusService', () => { expect(await serviceDegraded.getAll$().pipe(first()).toPromise()).toEqual({ a: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, b: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, c: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, @@ -191,19 +191,19 @@ describe('PluginStatusService', () => { expect(await serviceCritical.getAll$().pipe(first()).toPromise()).toEqual({ a: { level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }, b: { level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }, c: { level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }, @@ -218,13 +218,13 @@ describe('PluginStatusService', () => { a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded b: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, c: { level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: savedObjects, b', detail: 'See the status page for more information', meta: expect.any(Object), }, @@ -298,7 +298,7 @@ describe('PluginStatusService', () => { a: { level: ServiceStatusLevels.unavailable, summary: 'Status check timed out after 30s' }, b: { level: ServiceStatusLevels.unavailable, - summary: '[a]: Status check timed out after 30s', + summary: '1 service is unavailable: a', detail: 'See the status page for more information', meta: { affectedServices: ['a'], @@ -341,7 +341,7 @@ describe('PluginStatusService', () => { a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded b: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 255ed821bc2f..dfd0ff9a7e10 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -188,7 +188,7 @@ describe('StatusService', () => { ); expect(await setup.overall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -208,15 +208,15 @@ describe('StatusService', () => { const subResult3 = await setup.overall$.pipe(first()).toPromise(); expect(subResult1).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult2).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult3).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -265,7 +265,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, @@ -315,7 +315,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, @@ -340,7 +340,7 @@ describe('StatusService', () => { ); expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -357,7 +357,7 @@ describe('StatusService', () => { ); expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.critical, - summary: '[savedObjects]: This is critical!', + summary: '1 service is critical: savedObjects', }); }); @@ -379,15 +379,15 @@ describe('StatusService', () => { expect(subResult1).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult2).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult3).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -436,7 +436,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, @@ -486,7 +486,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, From 9d2c536ccb717bc439c957f6cea1b7c16f217529 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 11 Oct 2021 11:49:20 +0200 Subject: [PATCH 07/19] [Discover] Unskip Painless date functional test (#114224) * [Discover] Unskip functional test * Remove comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- test/functional/apps/management/_scripted_fields.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 2e965c275d6d..72f45e1fedb4 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -367,8 +367,7 @@ export default function ({ getService, getPageObjects }) { }); }); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/113745 - describe.skip('creating and using Painless date scripted fields', function describeIndexTests() { + describe('creating and using Painless date scripted fields', function describeIndexTests() { const scriptedPainlessFieldName2 = 'painDate'; it('should create scripted field', async function () { @@ -384,7 +383,7 @@ export default function ({ getService, getPageObjects }) { 'date', { format: 'date', datePattern: 'YYYY-MM-DD HH:00' }, '1', - "doc['utc_time'].value.getMillis() + (1000) * 60 * 60" + "doc['utc_time'].value.toEpochMilli() + (1000) * 60 * 60" ); await retry.try(async function () { expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( From 1bf09e6930daa027b54f33a1477caa4d3af8310f Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Mon, 11 Oct 2021 12:32:19 +0200 Subject: [PATCH 08/19] [Lens] Thresholds: Add text to markers body (#113629) * :bug: Add padding to the tick label to fit threshold markers * :bug: Better icon detection * :bug: Fix edge cases with no title or labels * :camera_flash: Update snapshots * :sparkles: Add icon placement flag * :sparkles: Sync padding computation with marker positioning * :ok_hand: Make disabled when no icon is selected * :sparkles: First text on marker implementation * :bug: Fix some edge cases with auto positioning * Update x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx Co-authored-by: Michael Marcialis * :bug: Fix minor details * :lipstick: Small tweak * :sparkles: Reduce the padding if no icon is shown on the axis * :white_check_mark: Fix broken unit tests * :lipstick: Fix vertical text centering * :rotating_light: Fix linting issue * :bug: Fix issue * :lipstick: Reorder panel inputs * :lipstick: Move styling to sass * :ok_hand: Address feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Marcialis --- .../expressions/xy_chart/axis_config.ts | 5 + .../dimension_panel/dimension_editor.tsx | 32 ++-- .../dimension_panel/dimension_panel.test.tsx | 20 ++- .../public/xy_visualization/expression.tsx | 1 + .../expression_thresholds.scss | 18 +++ .../expression_thresholds.tsx | 134 +++++++++++++---- .../public/xy_visualization/to_expression.ts | 10 +- .../xy_config_panel/threshold_panel.tsx | 137 +++++++++++------- 8 files changed, 248 insertions(+), 109 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts index 47bb1f91b4ab..9ff1b5a4dc3f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts @@ -41,6 +41,7 @@ export interface YConfig { lineStyle?: LineStyle; fill?: FillStyle; iconPosition?: IconPosition; + textVisibility?: boolean; } export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & { @@ -187,6 +188,10 @@ export const yAxisConfig: ExpressionFunctionDefinition< options: ['auto', 'above', 'below', 'left', 'right'], help: 'The placement of the icon for the threshold line', }, + textVisibility: { + types: ['boolean'], + help: 'Visibility of the label on the threshold line', + }, fill: { types: ['string'], options: ['none', 'above', 'below'], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 29bbe6a96b9e..2f1c00bc5cca 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -107,16 +107,19 @@ export function DimensionEditor(props: DimensionEditorProps) { ); const setStateWrapper = ( - setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer), + options: { forceRender?: boolean } = {} ) => { const hypotheticalLayer = typeof setter === 'function' ? setter(state.layers[layerId]) : setter; + const isDimensionComplete = Boolean(hypotheticalLayer.columns[columnId]); setState( (prevState) => { const layer = typeof setter === 'function' ? setter(prevState.layers[layerId]) : setter; return mergeLayer({ state: prevState, layerId, newLayer: layer }); }, { - isDimensionComplete: Boolean(hypotheticalLayer.columns[columnId]), + isDimensionComplete, + ...options, } ); }; @@ -169,20 +172,8 @@ export function DimensionEditor(props: DimensionEditorProps) { ) => { if (temporaryStaticValue) { setTemporaryState('none'); - if (typeof setter === 'function') { - return setState( - (prevState) => { - const layer = setter(addStaticValueColumn(prevState.layers[layerId])); - return mergeLayer({ state: prevState, layerId, newLayer: layer }); - }, - { - isDimensionComplete: true, - forceRender: true, - } - ); - } } - return setStateWrapper(setter); + return setStateWrapper(setter, { forceRender: true }); }; const ParamEditor = getParamEditor( @@ -314,7 +305,7 @@ export function DimensionEditor(props: DimensionEditorProps) { temporaryQuickFunction && isQuickFunction(newLayer.columns[columnId].operationType) ) { - // Only switch the tab once the formula is fully removed + // Only switch the tab once the "non quick function" is fully removed setTemporaryState('none'); } setStateWrapper(newLayer); @@ -344,13 +335,12 @@ export function DimensionEditor(props: DimensionEditorProps) { visualizationGroups: dimensionGroups, targetGroup: props.groupId, }); - // ); } if ( temporaryQuickFunction && isQuickFunction(newLayer.columns[columnId].operationType) ) { - // Only switch the tab once the formula is fully removed + // Only switch the tab once the "non quick function" is fully removed setTemporaryState('none'); } setStateWrapper(newLayer); @@ -508,6 +498,9 @@ export function DimensionEditor(props: DimensionEditorProps) { } incompleteOperation={incompleteOperation} onChoose={(choice) => { + if (temporaryQuickFunction) { + setTemporaryState('none'); + } setStateWrapper( insertOrReplaceColumn({ layer: state.layers[layerId], @@ -518,7 +511,8 @@ export function DimensionEditor(props: DimensionEditorProps) { visualizationGroups: dimensionGroups, targetGroup: props.groupId, incompleteParams, - }) + }), + { forceRender: temporaryQuickFunction } ); }} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 6df4360aeac4..6d9e1ae3fe81 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -513,7 +513,10 @@ describe('IndexPatternDimensionEditorPanel', () => { comboBox.prop('onChange')!([option]); }); - expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[0]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[0][0](defaultProps.state)).toEqual({ ...initialState, layers: { @@ -545,7 +548,10 @@ describe('IndexPatternDimensionEditorPanel', () => { comboBox.prop('onChange')!([option]); }); - expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[0]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[0][0](defaultProps.state)).toEqual({ ...state, layers: { @@ -1037,7 +1043,10 @@ describe('IndexPatternDimensionEditorPanel', () => { }); expect(setState.mock.calls.length).toEqual(2); - expect(setState.mock.calls[1]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[1]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[1][0](state)).toEqual({ ...state, layers: { @@ -1921,7 +1930,10 @@ describe('IndexPatternDimensionEditorPanel', () => { comboBox.prop('onChange')!([option]); }); - expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[0]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[0][0](defaultProps.state)).toEqual({ ...state, layers: { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 484032e5ffbd..5dfad58f5001 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -924,6 +924,7 @@ export function XYChart({ right: Boolean(yAxesMap.right), }} isHorizontal={shouldRotate} + thresholdPaddingMap={thresholdPaddings} /> ) : null} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss new file mode 100644 index 000000000000..41b30ce40676 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss @@ -0,0 +1,18 @@ +.lnsXyDecorationRotatedWrapper { + display: inline-block; + overflow: hidden; + line-height: $euiLineHeight; + + .lnsXyDecorationRotatedWrapper__label { + display: inline-block; + white-space: nowrap; + transform: translate(0, 100%) rotate(-90deg); + transform-origin: 0 0; + + &::after { + content: ''; + float: left; + margin-top: 100%; + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx index 7532d41f091d..67e994b734b8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import './expression_thresholds.scss'; import React from 'react'; import { groupBy } from 'lodash'; import { EuiIcon } from '@elastic/eui'; @@ -14,8 +15,9 @@ import type { FieldFormat } from 'src/plugins/field_formats/common'; import { euiLightVars } from '@kbn/ui-shared-deps-src/theme'; import type { LayerArgs, YConfig } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; +import { hasIcon } from './xy_config_panel/threshold_panel'; -const THRESHOLD_ICON_SIZE = 20; +const THRESHOLD_MARKER_SIZE = 20; export const computeChartMargins = ( thresholdPaddings: Partial>, @@ -51,27 +53,35 @@ export const computeChartMargins = ( return result; }; -function hasIcon(icon: string | undefined): icon is string { - return icon != null && icon !== 'none'; -} - // Note: it does not take into consideration whether the threshold is in view or not export const getThresholdRequiredPaddings = ( thresholdLayers: LayerArgs[], axesMap: Record<'left' | 'right', unknown> ) => { - const positions = Object.keys(Position); - return thresholdLayers.reduce((memo, layer) => { - if (positions.some((pos) => !(pos in memo))) { - layer.yConfig?.forEach(({ axisMode, icon, iconPosition }) => { - if (axisMode && hasIcon(icon)) { - const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); - memo[placement] = THRESHOLD_ICON_SIZE; - } - }); + // collect all paddings for the 4 axis: if any text is detected double it. + const paddings: Partial> = {}; + const icons: Partial> = {}; + thresholdLayers.forEach((layer) => { + layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => { + if (axisMode && (hasIcon(icon) || textVisibility)) { + const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); + paddings[placement] = Math.max( + paddings[placement] || 0, + THRESHOLD_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text + ); + icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); + } + }); + }); + // post-process the padding based on the icon presence: + // if no icon is present for the placement, just reduce the padding + (Object.keys(paddings) as Position[]).forEach((placement) => { + if (!icons[placement]) { + paddings[placement] = THRESHOLD_MARKER_SIZE; } - return memo; - }, {} as Partial>); + }); + + return paddings; }; function mapVerticalToHorizontalPlacement(placement: Position) { @@ -117,17 +127,57 @@ function getBaseIconPlacement( return Position.Top; } -function getIconPlacement( - iconPosition: YConfig['iconPosition'], - axisMode: YConfig['axisMode'], - axesMap: Record, - isHorizontal: boolean -) { - const vPosition = getBaseIconPlacement(iconPosition, axisMode, axesMap); +function getMarkerBody(label: string | undefined, isHorizontal: boolean) { + if (!label) { + return; + } if (isHorizontal) { - return mapVerticalToHorizontalPlacement(vPosition); + return ( +
+ {label} +
+ ); + } + return ( +
+
+ {label} +
+
+ ); +} + +function getMarkerToShow( + yConfig: YConfig, + label: string | undefined, + isHorizontal: boolean, + hasReducedPadding: boolean +) { + // show an icon if present + if (hasIcon(yConfig.icon)) { + return ; + } + // if there's some text, check whether to show it as marker, or just show some padding for the icon + if (yConfig.textVisibility) { + if (hasReducedPadding) { + return getMarkerBody( + label, + (!isHorizontal && yConfig.axisMode === 'bottom') || + (isHorizontal && yConfig.axisMode !== 'bottom') + ); + } + return ; } - return vPosition; } export const ThresholdAnnotations = ({ @@ -138,6 +188,7 @@ export const ThresholdAnnotations = ({ syncColors, axesMap, isHorizontal, + thresholdPaddingMap, }: { thresholdLayers: LayerArgs[]; data: LensMultiTable; @@ -146,6 +197,7 @@ export const ThresholdAnnotations = ({ syncColors: boolean; axesMap: Record<'left' | 'right', boolean>; isHorizontal: boolean; + thresholdPaddingMap: Partial>; }) => { return ( <> @@ -180,15 +232,35 @@ export const ThresholdAnnotations = ({ const defaultColor = euiLightVars.euiColorDarkShade; + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement( + yConfig.iconPosition, + yConfig.axisMode, + axesMap + ); + // the padding map is built for vertical chart + const hasReducedPadding = + thresholdPaddingMap[markerPositionVertical] === THRESHOLD_MARKER_SIZE; + const props = { groupId, - marker: hasIcon(yConfig.icon) ? : undefined, - markerPosition: getIconPlacement( - yConfig.iconPosition, - yConfig.axisMode, - axesMap, - isHorizontal + marker: getMarkerToShow( + yConfig, + columnToLabelMap[yConfig.forAccessor], + isHorizontal, + hasReducedPadding + ), + markerBody: getMarkerBody( + yConfig.textVisibility && !hasReducedPadding + ? columnToLabelMap[yConfig.forAccessor] + : undefined, + (!isHorizontal && yConfig.axisMode === 'bottom') || + (isHorizontal && yConfig.axisMode !== 'bottom') ), + // rotate the position if required + markerPosition: isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical, }; const annotations = []; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index bb65b69a8d12..96ea9b84dd98 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -13,6 +13,7 @@ import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; +import { hasIcon } from './xy_config_panel/threshold_panel'; import { defaultThresholdColor } from './color_assignment'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { @@ -66,6 +67,7 @@ export function toPreviewExpression( ...config, lineWidth: 1, icon: undefined, + textVisibility: false, })), } ), @@ -344,8 +346,12 @@ export const buildExpression = ( lineStyle: [yConfig.lineStyle || 'solid'], lineWidth: [yConfig.lineWidth || 1], fill: [yConfig.fill || 'none'], - icon: yConfig.icon ? [yConfig.icon] : [], - iconPosition: [yConfig.iconPosition || 'auto'], + icon: hasIcon(yConfig.icon) ? [yConfig.icon] : [], + iconPosition: + hasIcon(yConfig.icon) || yConfig.textVisibility + ? [yConfig.iconPosition || 'auto'] + : ['auto'], + textVisibility: [yConfig.textVisibility || false], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx index cdf5bb2cc2ef..7c31d72e6cbd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx @@ -8,7 +8,14 @@ import './xy_config_panel.scss'; import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiIcon, EuiRange } from '@elastic/eui'; +import { + EuiButtonGroup, + EuiComboBox, + EuiFormRow, + EuiIcon, + EuiRange, + EuiSwitch, +} from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State, XYState } from '../types'; @@ -177,6 +184,10 @@ function getIconPositionOptions({ return [...options, ...yOptions]; } +export function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'none'; +} + export const ThresholdPanel = ( props: VisualizationDimensionEditorProps & { formatFactory: FormatFactory; @@ -220,6 +231,78 @@ export const ThresholdPanel = ( return ( <> + + { + setYConfig({ forAccessor: accessor, textVisibility: !currentYConfig?.textVisibility }); + }} + /> + + + { + setYConfig({ forAccessor: accessor, icon: newIcon }); + }} + /> + + + + { + const newMode = id.replace(idPrefix, '') as IconPosition; + setYConfig({ forAccessor: accessor, iconPosition: newMode }); + }} + /> + + - - { - setYConfig({ forAccessor: accessor, icon: newIcon }); - }} - /> - - - - { - const newMode = id.replace(idPrefix, '') as IconPosition; - setYConfig({ forAccessor: accessor, iconPosition: newMode }); - }} - /> - - ); }; From 9a1779d364044dff27672c110f6aa344e61fce15 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 11 Oct 2021 13:37:01 +0300 Subject: [PATCH 09/19] [Visualize] unskip the reporting funtional test (#114094) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/functional/apps/visualize/reporting.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index efffa0b6a692..07ce3d9b2312 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -25,13 +25,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visEditor', ]); - // Failing: See https://github.com/elastic/kibana/issues/113496 - describe.skip('Visualize Reporting Screenshots', () => { + describe('Visualize Reporting Screenshots', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/reporting/ecommerce'); await kibanaServer.importExport.load(ecommerceSOPath); await browser.setWindowSize(1600, 850); + await kibanaServer.uiSettings.replace({ + 'timepicker:timeDefaults': + '{ "from": "2019-04-27T23:56:51.374Z", "to": "2019-08-23T16:18:51.821Z"}', + }); }); after('clean up archives', async () => { await esArchiver.unload('x-pack/test/functional/es_archives/reporting/ecommerce'); @@ -41,6 +44,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { refresh: true, body: { query: { match_all: {} } }, }); + await kibanaServer.uiSettings.unset('timepicker:timeDefaults'); }); describe('Print PDF button', () => { @@ -54,11 +58,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('becomes available when saved', async () => { - await PageObjects.timePicker.timePickerExists(); - const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; - const toTime = 'Aug 23, 2019 @ 16:18:51.821'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.visEditor.clickBucket('X-axis'); await PageObjects.visEditor.selectAggregation('Date Histogram'); await PageObjects.visEditor.clickGo(); From b2108f4c2c939f7f9ea8f0cf272c6f856d260b0e Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 11 Oct 2021 11:53:49 +0100 Subject: [PATCH 10/19] Add all APM configuration settings to the documentation (#114139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add APM configuration settings to the documentation * Rename the deprecated apm_oss.* configurations to xpack.apm.* * Remove new lines * Add ess icon to config settings * Add link to the APM configuration settings docs Co-authored-by: Søren Louv-Jansen Co-authored-by: Søren Louv-Jansen Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/settings/apm-settings.asciidoc | 48 +++++++++++++++++++---------- x-pack/plugins/apm/server/index.ts | 2 ++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index e565bda0dff4..fb96f6835533 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -40,57 +40,71 @@ Changing these settings may disable features of the APM App. [cols="2*<"] |=== -| `xpack.apm.enabled` +| `xpack.apm.enabled` {ess-icon} | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] Set to `false` to disable the APM app. Defaults to `true`. -| `xpack.apm.maxServiceEnvironments` +| `xpack.apm.maxServiceEnvironments` {ess-icon} | Maximum number of unique service environments recognized by the UI. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintBucketSize` +| `xpack.apm.serviceMapFingerprintBucketSize` {ess-icon} | Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintGlobalBucketSize` +| `xpack.apm.serviceMapFingerprintGlobalBucketSize` {ess-icon} | Maximum number of unique transaction combinations sampled for generating the global service map. Defaults to `100`. +| `xpack.apm.serviceMapEnabled` {ess-icon} + | Set to `false` to disable service maps. Defaults to `true`. + +| `xpack.apm.serviceMapTraceIdBucketSize` {ess-icon} + | Maximum number of trace IDs sampled for generating service map focused on a specific service. Defaults to `65`. + +| `xpack.apm.serviceMapTraceIdGlobalBucketSize` {ess-icon} + | Maximum number of trace IDs sampled for generating the global service map. Defaults to `6`. + +| `xpack.apm.serviceMapMaxTracesPerRequest` {ess-icon} + | Maximum number of traces per request for generating the global service map. Defaults to `50`. + | `xpack.apm.ui.enabled` {ess-icon} | Set to `false` to hide the APM app from the main menu. Defaults to `true`. -| `xpack.apm.ui.transactionGroupBucketSize` +| `xpack.apm.ui.transactionGroupBucketSize` {ess-icon} | Number of top transaction groups displayed in the APM app. Defaults to `1000`. | `xpack.apm.ui.maxTraceItems` {ess-icon} | Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -| `xpack.observability.annotations.index` +| `xpack.observability.annotations.index` {ess-icon} | Index name where Observability annotations are stored. Defaults to `observability-annotations`. -| `xpack.apm.searchAggregatedTransactions` +| `xpack.apm.searchAggregatedTransactions` {ess-icon} | experimental[] Enables Transaction histogram metrics. Defaults to `never` and aggregated transactions are not used. When set to `auto`, the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. See {apm-server-ref-v}/transaction-metrics.html[Configure transaction metrics] for more information. -| `apm_oss.indexPattern` {ess-icon} - | The index pattern used for integrations with Machine Learning and Query Bar. - It must match all apm indices. Defaults to `apm-*`. +| `xpack.apm.metricsInterval` {ess-icon} + | Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to `30`. + +| `xpack.apm.agent.migrations.enabled` {ess-icon} + | Set to `false` to disable cloud APM migrations. Defaults to `true`. -| `apm_oss.errorIndices` {ess-icon} +| `xpack.apm.errorIndices` {ess-icon} | Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. -| `apm_oss.onboardingIndices` +| `xpack.apm.onboardingIndices` {ess-icon} | Matcher for all onboarding indices. Defaults to `apm-*`. -| `apm_oss.spanIndices` {ess-icon} +| `xpack.apm.spanIndices` {ess-icon} | Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. -| `apm_oss.transactionIndices` {ess-icon} +| `xpack.apm.transactionIndices` {ess-icon} | Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. -| `apm_oss.metricsIndices` +| `xpack.apm.metricsIndices` {ess-icon} | Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. -| `apm_oss.sourcemapIndices` +| `xpack.apm.sourcemapIndices` {ess-icon} | Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. |=== -// end::general-apm-settings[] +// end::general-apm-settings[] \ No newline at end of file diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index b7002ff7cbe7..22787b0301ce 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -74,6 +74,8 @@ export type APMXPackConfig = TypeOf; export type APMConfig = ReturnType; // plugin config and ui indices settings +// All options should be documented in the APM configuration settings: https://github.com/elastic/kibana/blob/master/docs/settings/apm-settings.asciidoc +// and be included on cloud allow list unless there are specific reasons not to export function mergeConfigs( apmOssConfig: APMOSSConfig, apmConfig: APMXPackConfig From 75983cf45065fc498feb150f4be8e95b6b85886f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 11 Oct 2021 12:58:59 +0200 Subject: [PATCH 11/19] [Reporting] Update chromium exit behaviour (#113544) * move uncaught exception out of exit$ * reintroduce original error, but as a log instead * change log level: error -> warning. also update copy to make it explicit that the error will be ignored Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../browsers/chromium/driver_factory/index.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 2170b50f195b..a0487421a9a0 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -220,6 +220,17 @@ export class HeadlessChromiumDriverFactory { }) ); + const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe( + map((err) => { + logger.warning( + i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', { + defaultMessage: `Reporting encountered an uncaught error on the page that will be ignored: {err}`, + values: { err: err.toString() }, + }) + ); + }) + ); + const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe( map((req) => { const failure = req.failure && req.failure(); @@ -231,7 +242,7 @@ export class HeadlessChromiumDriverFactory { }) ); - return Rx.merge(consoleMessages$, pageRequestFailed$); + return Rx.merge(consoleMessages$, uncaughtExceptionPageError$, pageRequestFailed$); } getProcessLogger(browser: puppeteer.Browser, logger: LevelLogger): Rx.Observable { @@ -266,21 +277,10 @@ export class HeadlessChromiumDriverFactory { }) ); - const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe( - mergeMap((err) => { - return Rx.throwError( - i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', { - defaultMessage: `Reporting encountered an error on the page: {err}`, - values: { err: err.toString() }, - }) - ); - }) - ); - const browserDisconnect$ = Rx.fromEvent(browser, 'disconnected').pipe( mergeMap(() => Rx.throwError(getChromiumDisconnectedError())) ); - return Rx.merge(pageError$, uncaughtExceptionPageError$, browserDisconnect$); + return Rx.merge(pageError$, browserDisconnect$); } } From ce7b1ea6530653ddb910ca57af00d3503f8e3362 Mon Sep 17 00:00:00 2001 From: Dmitry Shevchenko Date: Mon, 11 Oct 2021 13:05:52 +0200 Subject: [PATCH 12/19] Implement writing rule execution events to event_log (#112286) --- .../plugins/event_log/generated/mappings.json | 43 +++ x-pack/plugins/event_log/generated/schemas.ts | 23 ++ x-pack/plugins/event_log/scripts/mappings.js | 48 +++- x-pack/plugins/security_solution/kibana.json | 5 +- .../security_solution/server/config.ts | 14 + .../routes/__mocks__/index.ts | 4 + .../__mocks__/rule_execution_log_client.ts | 2 +- .../event_log_adapter/constants.ts | 15 + .../event_log_adapter/event_log_adapter.ts | 87 ++++++ .../event_log_adapter/event_log_client.ts | 157 ++++++++++ .../register_event_log_provider.ts | 16 ++ .../rule_execution_log_client.ts | 34 ++- .../rule_registry_adapter.ts | 106 ------- .../rule_registry_log_client/constants.ts | 41 --- .../parse_rule_execution_log.ts | 40 --- .../rule_execution_field_map.ts | 32 --- .../rule_registry_log_client.ts | 270 ------------------ .../rule_registry_log_client/utils.ts | 76 ----- .../saved_objects_adapter.ts | 35 ++- .../rule_execution_log/types.ts | 55 ++-- .../rule_types/__mocks__/rule_type.ts | 4 +- .../create_security_rule_type_factory.ts | 49 ++-- .../eql/create_eql_alert_type.test.ts | 6 +- .../rule_types/eql/create_eql_alert_type.ts | 17 +- .../build_alert_group_from_sequence.test.ts | 1 + .../create_indicator_match_alert_type.test.ts | 16 +- .../create_indicator_match_alert_type.ts | 17 +- .../ml/create_ml_alert_type.test.ts | 6 +- .../rule_types/ml/create_ml_alert_type.ts | 8 +- .../query/create_query_alert_type.test.ts | 11 +- .../query/create_query_alert_type.ts | 17 +- .../create_threshold_alert_type.test.ts | 6 +- .../threshold/create_threshold_alert_type.ts | 17 +- .../lib/detection_engine/rule_types/types.ts | 12 +- .../lib/detection_engine/rules/enable_rule.ts | 2 + .../signals/__mocks__/es_results.ts | 1 + .../signals/executors/eql.test.ts | 1 + .../signals/executors/threshold.test.ts | 1 + .../signals/signal_rule_alert_type.test.ts | 23 +- .../signals/signal_rule_alert_type.ts | 63 ++-- .../lib/detection_engine/signals/types.ts | 1 + .../detection_engine/signals/utils.test.ts | 4 + .../lib/detection_engine/signals/utils.ts | 23 +- .../security_solution/server/plugin.ts | 26 +- .../event_log/service_api_integration.ts | 15 + 45 files changed, 653 insertions(+), 797 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index cbb59cc3204c..aba23eef79e3 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -267,6 +267,42 @@ } } }, + "alert": { + "properties": { + "rule": { + "properties": { + "execution": { + "properties": { + "uuid": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "status_order": { + "type": "long" + }, + "metrics": { + "properties": { + "total_indexing_duration_ms": { + "type": "long" + }, + "total_search_duration_ms": { + "type": "long" + }, + "execution_gap_duration_s": { + "type": "long" + } + } + } + } + } + } + } + } + }, "saved_objects": { "type": "nested", "properties": { @@ -292,6 +328,13 @@ } } }, + "space_ids": { + "type": "keyword", + "ignore_above": 1024, + "meta": { + "isArray": "true" + } + }, "version": { "type": "version" } diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 15dc182dbe65..e73bafd9cb81 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -116,6 +116,28 @@ export const EventSchema = schema.maybe( status: ecsString(), }) ), + alert: schema.maybe( + schema.object({ + rule: schema.maybe( + schema.object({ + execution: schema.maybe( + schema.object({ + uuid: ecsString(), + status: ecsString(), + status_order: ecsNumber(), + metrics: schema.maybe( + schema.object({ + total_indexing_duration_ms: ecsNumber(), + total_search_duration_ms: ecsNumber(), + execution_gap_duration_s: ecsNumber(), + }) + ), + }) + ), + }) + ), + }) + ), saved_objects: schema.maybe( schema.arrayOf( schema.object({ @@ -127,6 +149,7 @@ export const EventSchema = schema.maybe( }) ) ), + space_ids: ecsStringMulti(), version: ecsVersion(), }) ), diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index d11460305249..231cc225f7c4 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -49,6 +49,42 @@ exports.EcsCustomPropertyMappings = { }, }, }, + alert: { + properties: { + rule: { + properties: { + execution: { + properties: { + uuid: { + type: 'keyword', + ignore_above: 1024, + }, + status: { + type: 'keyword', + ignore_above: 1024, + }, + status_order: { + type: 'long', + }, + metrics: { + properties: { + total_indexing_duration_ms: { + type: 'long', + }, + total_search_duration_ms: { + type: 'long', + }, + execution_gap_duration_s: { + type: 'long', + }, + }, + }, + }, + }, + }, + }, + }, + }, // array of saved object references, for "linking" via search saved_objects: { type: 'nested', @@ -77,6 +113,10 @@ exports.EcsCustomPropertyMappings = { }, }, }, + space_ids: { + type: 'keyword', + ignore_above: 1024, + }, version: { type: 'version', }, @@ -105,4 +145,10 @@ exports.EcsPropertiesToGenerate = [ /** * These properties can have multiple values (are arrays in the generated event schema). */ -exports.EcsEventLogMultiValuedProperties = ['tags', 'event.category', 'event.type', 'rule.author']; +exports.EcsEventLogMultiValuedProperties = [ + 'tags', + 'event.category', + 'event.type', + 'rule.author', + 'kibana.space_ids', +]; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 8bb1f4d75e6b..a76b942e555b 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -12,15 +12,16 @@ "actions", "alerting", "cases", - "ruleRegistry", "data", "dataEnhanced", "embeddable", + "eventLog", "features", - "taskManager", "inspector", "licensing", "maps", + "ruleRegistry", + "taskManager", "timelines", "triggersActionsUi", "uiActions" diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index 0850e43b21ed..bc5b43c6d25f 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -12,6 +12,7 @@ import { getExperimentalAllowedValues, isValidExperimentalValue, } from '../common/experimental_features'; +import { UnderlyingLogClient } from './lib/detection_engine/rule_execution_log/types'; const allowedExperimentalValues = getExperimentalAllowedValues(); @@ -103,6 +104,19 @@ export const configSchema = schema.object({ }, }), + /** + * Rule Execution Log Configuration + */ + ruleExecutionLog: schema.object({ + underlyingClient: schema.oneOf( + [ + schema.literal(UnderlyingLogClient.eventLog), + schema.literal(UnderlyingLogClient.savedObjects), + ], + { defaultValue: UnderlyingLogClient.savedObjects } + ), + }), + /** * Host Endpoint Configuration */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index 1ac85f9a2796..2f401d27813a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -11,6 +11,7 @@ import { serverMock } from './server'; import { requestMock } from './request'; import { responseMock } from './response_factory'; import { ConfigType } from '../../../../config'; +import { UnderlyingLogClient } from '../../rule_execution_log/types'; export { requestMock, requestContextMock, responseMock, serverMock }; @@ -29,6 +30,9 @@ export const createMockConfig = (): ConfigType => ({ alertIgnoreFields: [], prebuiltRulesFromFileSystem: true, prebuiltRulesFromSavedObjects: false, + ruleExecutionLog: { + underlyingClient: UnderlyingLogClient.savedObjects, + }, }); export const mockGetCurrentUser = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts index bc9723e47a9d..910e1ecaa508 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts @@ -14,7 +14,7 @@ export const ruleExecutionLogClientMock = { update: jest.fn(), delete: jest.fn(), logStatusChange: jest.fn(), - logExecutionMetric: jest.fn(), + logExecutionMetrics: jest.fn(), }), }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts new file mode 100644 index 000000000000..f09eb43bf15f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.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. + */ + +export const RULE_EXECUTION_LOG_PROVIDER = 'rule-execution.security'; + +export const ALERT_SAVED_OBJECT_TYPE = 'alert'; + +export enum RuleExecutionLogAction { + 'status-change' = 'status-change', + 'execution-metrics' = 'execution-metrics', +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts new file mode 100644 index 000000000000..6b1a0cd5b18d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEventLogService } from '../../../../../../event_log/server'; +import { + FindBulkExecutionLogArgs, + FindExecutionLogArgs, + IRuleExecutionLogClient, + LogExecutionMetricsArgs, + LogStatusChangeArgs, + UpdateExecutionLogArgs, +} from '../types'; +import { EventLogClient } from './event_log_client'; + +export class EventLogAdapter implements IRuleExecutionLogClient { + private eventLogClient: EventLogClient; + + constructor(eventLogService: IEventLogService) { + this.eventLogClient = new EventLogClient(eventLogService); + } + + public async find({ ruleId, logsCount = 1, spaceId }: FindExecutionLogArgs) { + return []; // TODO Implement + } + + public async findBulk({ ruleIds, logsCount = 1, spaceId }: FindBulkExecutionLogArgs) { + return {}; // TODO Implement + } + + public async update({ attributes, spaceId, ruleName, ruleType }: UpdateExecutionLogArgs) { + // execution events are immutable, so we just log a status change istead of updating previous + if (attributes.status) { + this.eventLogClient.logStatusChange({ + ruleName, + ruleType, + ruleId: attributes.alertId, + newStatus: attributes.status, + spaceId, + }); + } + } + + public async delete(id: string) { + // execution events are immutable, nothing to do here + } + + public async logExecutionMetrics({ + ruleId, + spaceId, + ruleType, + ruleName, + metrics, + }: LogExecutionMetricsArgs) { + this.eventLogClient.logExecutionMetrics({ + ruleId, + ruleName, + ruleType, + spaceId, + metrics: { + executionGapDuration: metrics.executionGap?.asSeconds(), + totalIndexingDuration: metrics.indexingDurations?.reduce( + (acc, cur) => acc + Number(cur), + 0 + ), + totalSearchDuration: metrics.searchDurations?.reduce((acc, cur) => acc + Number(cur), 0), + }, + }); + } + + public async logStatusChange(args: LogStatusChangeArgs) { + if (args.metrics) { + this.logExecutionMetrics({ + ruleId: args.ruleId, + ruleName: args.ruleName, + ruleType: args.ruleType, + spaceId: args.spaceId, + metrics: args.metrics, + }); + } + + this.eventLogClient.logStatusChange(args); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts new file mode 100644 index 000000000000..d85c67e42203 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsUtils } from '../../../../../../../../src/core/server'; +import { + IEventLogger, + IEventLogService, + SAVED_OBJECT_REL_PRIMARY, +} from '../../../../../../event_log/server'; +import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { LogStatusChangeArgs } from '../types'; +import { + RuleExecutionLogAction, + RULE_EXECUTION_LOG_PROVIDER, + ALERT_SAVED_OBJECT_TYPE, +} from './constants'; + +const spaceIdToNamespace = SavedObjectsUtils.namespaceStringToId; + +const statusSeverityDict: Record = { + [RuleExecutionStatus.succeeded]: 0, + [RuleExecutionStatus['going to run']]: 10, + [RuleExecutionStatus.warning]: 20, + [RuleExecutionStatus['partial failure']]: 20, + [RuleExecutionStatus.failed]: 30, +}; + +interface FindExecutionLogArgs { + ruleIds: string[]; + spaceId: string; + logsCount?: number; + statuses?: RuleExecutionStatus[]; +} + +interface LogExecutionMetricsArgs { + ruleId: string; + ruleName: string; + ruleType: string; + spaceId: string; + metrics: EventLogExecutionMetrics; +} + +interface EventLogExecutionMetrics { + totalSearchDuration?: number; + totalIndexingDuration?: number; + executionGapDuration?: number; +} + +interface IExecLogEventLogClient { + find: (args: FindExecutionLogArgs) => Promise<{}>; + logStatusChange: (args: LogStatusChangeArgs) => void; + logExecutionMetrics: (args: LogExecutionMetricsArgs) => void; +} + +export class EventLogClient implements IExecLogEventLogClient { + private sequence = 0; + private eventLogger: IEventLogger; + + constructor(eventLogService: IEventLogService) { + this.eventLogger = eventLogService.getLogger({ + event: { provider: RULE_EXECUTION_LOG_PROVIDER }, + }); + } + + public async find({ ruleIds, spaceId, statuses, logsCount = 1 }: FindExecutionLogArgs) { + return {}; // TODO implement + } + + public logExecutionMetrics({ + ruleId, + ruleName, + ruleType, + metrics, + spaceId, + }: LogExecutionMetricsArgs) { + this.eventLogger.logEvent({ + rule: { + id: ruleId, + name: ruleName, + category: ruleType, + }, + event: { + kind: 'metric', + action: RuleExecutionLogAction['execution-metrics'], + sequence: this.sequence++, + }, + kibana: { + alert: { + rule: { + execution: { + metrics: { + execution_gap_duration_s: metrics.executionGapDuration, + total_search_duration_ms: metrics.totalSearchDuration, + total_indexing_duration_ms: metrics.totalIndexingDuration, + }, + }, + }, + }, + space_ids: [spaceId], + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: ALERT_SAVED_OBJECT_TYPE, + id: ruleId, + namespace: spaceIdToNamespace(spaceId), + }, + ], + }, + }); + } + + public logStatusChange({ + ruleId, + ruleName, + ruleType, + newStatus, + message, + spaceId, + }: LogStatusChangeArgs) { + this.eventLogger.logEvent({ + rule: { + id: ruleId, + name: ruleName, + category: ruleType, + }, + event: { + kind: 'event', + action: RuleExecutionLogAction['status-change'], + sequence: this.sequence++, + }, + message, + kibana: { + alert: { + rule: { + execution: { + status: newStatus, + status_order: statusSeverityDict[newStatus], + }, + }, + }, + space_ids: [spaceId], + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: ALERT_SAVED_OBJECT_TYPE, + id: ruleId, + namespace: spaceIdToNamespace(spaceId), + }, + ], + }, + }); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts new file mode 100644 index 000000000000..7f28715198da --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.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 { IEventLogService } from '../../../../../../event_log/server'; +import { RuleExecutionLogAction, RULE_EXECUTION_LOG_PROVIDER } from './constants'; + +export const registerEventLogProvider = (eventLogService: IEventLogService) => { + eventLogService.registerProviderActions( + RULE_EXECUTION_LOG_PROVIDER, + Object.keys(RuleExecutionLogAction) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts index 135cefe2243b..87a3b00cf4ed 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts @@ -6,34 +6,40 @@ */ import { SavedObjectsClientContract } from '../../../../../../../src/core/server'; -import { RuleRegistryAdapter } from './rule_registry_adapter/rule_registry_adapter'; +import { IEventLogService } from '../../../../../event_log/server'; +import { EventLogAdapter } from './event_log_adapter/event_log_adapter'; import { SavedObjectsAdapter } from './saved_objects_adapter/saved_objects_adapter'; import { - ExecutionMetric, - ExecutionMetricArgs, + LogExecutionMetricsArgs, FindBulkExecutionLogArgs, FindExecutionLogArgs, - IRuleDataPluginService, IRuleExecutionLogClient, LogStatusChangeArgs, UpdateExecutionLogArgs, + UnderlyingLogClient, } from './types'; export interface RuleExecutionLogClientArgs { - ruleDataService: IRuleDataPluginService; savedObjectsClient: SavedObjectsClientContract; + eventLogService: IEventLogService; + underlyingClient: UnderlyingLogClient; } -const RULE_REGISTRY_LOG_ENABLED = false; - export class RuleExecutionLogClient implements IRuleExecutionLogClient { private client: IRuleExecutionLogClient; - constructor({ ruleDataService, savedObjectsClient }: RuleExecutionLogClientArgs) { - if (RULE_REGISTRY_LOG_ENABLED) { - this.client = new RuleRegistryAdapter(ruleDataService); - } else { - this.client = new SavedObjectsAdapter(savedObjectsClient); + constructor({ + savedObjectsClient, + eventLogService, + underlyingClient, + }: RuleExecutionLogClientArgs) { + switch (underlyingClient) { + case UnderlyingLogClient.savedObjects: + this.client = new SavedObjectsAdapter(savedObjectsClient); + break; + case UnderlyingLogClient.eventLog: + this.client = new EventLogAdapter(eventLogService); + break; } } @@ -53,8 +59,8 @@ export class RuleExecutionLogClient implements IRuleExecutionLogClient { return this.client.delete(id); } - public async logExecutionMetric(args: ExecutionMetricArgs) { - return this.client.logExecutionMetric(args); + public async logExecutionMetrics(args: LogExecutionMetricsArgs) { + return this.client.logExecutionMetrics(args); } public async logStatusChange(args: LogStatusChangeArgs) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts deleted file mode 100644 index ab8664ae995b..000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts +++ /dev/null @@ -1,106 +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 { merge } from 'lodash'; -import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { RuleRegistryLogClient } from './rule_registry_log_client/rule_registry_log_client'; -import { - CreateExecutionLogArgs, - ExecutionMetric, - ExecutionMetricArgs, - FindBulkExecutionLogArgs, - FindExecutionLogArgs, - IRuleDataPluginService, - IRuleExecutionLogClient, - LogStatusChangeArgs, - UpdateExecutionLogArgs, -} from '../types'; - -/** - * @deprecated RuleRegistryAdapter is kept here only as a reference. It will be superseded with EventLog implementation - */ -export class RuleRegistryAdapter implements IRuleExecutionLogClient { - private ruleRegistryClient: RuleRegistryLogClient; - - constructor(ruleDataService: IRuleDataPluginService) { - this.ruleRegistryClient = new RuleRegistryLogClient(ruleDataService); - } - - public async find({ ruleId, logsCount = 1, spaceId }: FindExecutionLogArgs) { - const logs = await this.ruleRegistryClient.find({ - ruleIds: [ruleId], - logsCount, - spaceId, - }); - - return logs[ruleId].map((log) => ({ - id: '', - type: '', - score: 0, - attributes: log, - references: [], - })); - } - - public async findBulk({ ruleIds, logsCount = 1, spaceId }: FindBulkExecutionLogArgs) { - const [statusesById, lastErrorsById] = await Promise.all([ - this.ruleRegistryClient.find({ ruleIds, spaceId }), - this.ruleRegistryClient.find({ - ruleIds, - statuses: [RuleExecutionStatus.failed], - logsCount, - spaceId, - }), - ]); - return merge(statusesById, lastErrorsById); - } - - private async create({ attributes, spaceId }: CreateExecutionLogArgs) { - if (attributes.status) { - await this.ruleRegistryClient.logStatusChange({ - ruleId: attributes.alertId, - newStatus: attributes.status, - spaceId, - }); - } - - if (attributes.bulkCreateTimeDurations) { - await this.ruleRegistryClient.logExecutionMetric({ - ruleId: attributes.alertId, - metric: ExecutionMetric.indexingDurationMax, - value: Math.max(...attributes.bulkCreateTimeDurations.map(Number)), - spaceId, - }); - } - - if (attributes.gap) { - await this.ruleRegistryClient.logExecutionMetric({ - ruleId: attributes.alertId, - metric: ExecutionMetric.executionGap, - value: Number(attributes.gap), - spaceId, - }); - } - } - - public async update({ attributes, spaceId }: UpdateExecutionLogArgs) { - // execution events are immutable, so we just use 'create' here instead of 'update' - await this.create({ attributes, spaceId }); - } - - public async delete(id: string) { - // execution events are immutable, nothing to do here - } - - public async logExecutionMetric(args: ExecutionMetricArgs) { - return this.ruleRegistryClient.logExecutionMetric(args); - } - - public async logStatusChange(args: LogStatusChangeArgs) { - return this.ruleRegistryClient.logStatusChange(args); - } -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts deleted file mode 100644 index 8d74c71bf447..000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts +++ /dev/null @@ -1,41 +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. - */ - -/** - * @deprecated EVENTS_INDEX_PREFIX is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENTS_INDEX_PREFIX = '.kibana_alerts-security.events'; - -/** - * @deprecated MESSAGE is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const MESSAGE = 'message' as const; - -/** - * @deprecated EVENT_SEQUENCE is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENT_SEQUENCE = 'event.sequence' as const; - -/** - * @deprecated EVENT_DURATION is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENT_DURATION = 'event.duration' as const; - -/** - * @deprecated EVENT_END is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENT_END = 'event.end' as const; - -/** - * @deprecated RULE_STATUS is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const RULE_STATUS = 'kibana.rac.detection_engine.rule_status' as const; - -/** - * @deprecated RULE_STATUS_SEVERITY is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const RULE_STATUS_SEVERITY = 'kibana.rac.detection_engine.rule_status_severity' as const; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts deleted file mode 100644 index cbc6e570e936..000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts +++ /dev/null @@ -1,40 +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 { isLeft } from 'fp-ts/lib/Either'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { technicalRuleFieldMap } from '../../../../../../../rule_registry/common/assets/field_maps/technical_rule_field_map'; -import { - mergeFieldMaps, - runtimeTypeFromFieldMap, -} from '../../../../../../../rule_registry/common/field_map'; -import { ruleExecutionFieldMap } from './rule_execution_field_map'; - -const ruleExecutionLogRuntimeType = runtimeTypeFromFieldMap( - mergeFieldMaps(technicalRuleFieldMap, ruleExecutionFieldMap) -); - -/** - * @deprecated parseRuleExecutionLog is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const parseRuleExecutionLog = (input: unknown) => { - const validate = ruleExecutionLogRuntimeType.decode(input); - - if (isLeft(validate)) { - throw new Error(PathReporter.report(validate).join('\n')); - } - - return ruleExecutionLogRuntimeType.encode(validate.right); -}; - -/** - * @deprecated RuleExecutionEvent is kept here only as a reference. It will be superseded with EventLog implementation - * - * It's marked as `Partial` because the field map is not yet appropriate for - * execution log events. - */ -export type RuleExecutionEvent = Partial>; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts deleted file mode 100644 index b3c70cd56d9e..000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EVENT_DURATION, - EVENT_END, - EVENT_SEQUENCE, - MESSAGE, - RULE_STATUS, - RULE_STATUS_SEVERITY, -} from './constants'; - -/** - * @deprecated ruleExecutionFieldMap is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const ruleExecutionFieldMap = { - [MESSAGE]: { type: 'keyword' }, - [EVENT_SEQUENCE]: { type: 'long' }, - [EVENT_END]: { type: 'date' }, - [EVENT_DURATION]: { type: 'long' }, - [RULE_STATUS]: { type: 'keyword' }, - [RULE_STATUS_SEVERITY]: { type: 'integer' }, -} as const; - -/** - * @deprecated RuleExecutionFieldMap is kept here only as a reference. It will be superseded with EventLog implementation - */ -export type RuleExecutionFieldMap = typeof ruleExecutionFieldMap; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts deleted file mode 100644 index 3cd6171b5bbe..000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts +++ /dev/null @@ -1,270 +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 { estypes } from '@elastic/elasticsearch'; -import { - ALERT_RULE_CONSUMER, - ALERT_RULE_TYPE_ID, - EVENT_ACTION, - EVENT_KIND, - SPACE_IDS, - TIMESTAMP, - ALERT_RULE_UUID, -} from '@kbn/rule-data-utils'; -import moment from 'moment'; - -import { mappingFromFieldMap } from '../../../../../../../rule_registry/common/mapping_from_field_map'; -import { Dataset, IRuleDataClient } from '../../../../../../../rule_registry/server'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; -import { RuleExecutionStatus } from '../../../../../../common/detection_engine/schemas/common/schemas'; -import { invariant } from '../../../../../../common/utils/invariant'; -import { IRuleStatusSOAttributes } from '../../../rules/types'; -import { makeFloatString } from '../../../signals/utils'; -import { - ExecutionMetric, - ExecutionMetricArgs, - IRuleDataPluginService, - LogStatusChangeArgs, -} from '../../types'; -import { EVENT_SEQUENCE, MESSAGE, RULE_STATUS, RULE_STATUS_SEVERITY } from './constants'; -import { parseRuleExecutionLog, RuleExecutionEvent } from './parse_rule_execution_log'; -import { ruleExecutionFieldMap } from './rule_execution_field_map'; -import { - getLastEntryAggregation, - getMetricAggregation, - getMetricField, - sortByTimeDesc, -} from './utils'; - -const statusSeverityDict: Record = { - [RuleExecutionStatus.succeeded]: 0, - [RuleExecutionStatus['going to run']]: 10, - [RuleExecutionStatus.warning]: 20, - [RuleExecutionStatus['partial failure']]: 20, - [RuleExecutionStatus.failed]: 30, -}; - -interface FindExecutionLogArgs { - ruleIds: string[]; - spaceId: string; - logsCount?: number; - statuses?: RuleExecutionStatus[]; -} - -interface IRuleRegistryLogClient { - find: (args: FindExecutionLogArgs) => Promise<{ - [ruleId: string]: IRuleStatusSOAttributes[] | undefined; - }>; - create: (event: RuleExecutionEvent) => Promise; - logStatusChange: (args: LogStatusChangeArgs) => Promise; - logExecutionMetric: (args: ExecutionMetricArgs) => Promise; -} - -/** - * @deprecated RuleRegistryLogClient is kept here only as a reference. It will be superseded with EventLog implementation - */ -export class RuleRegistryLogClient implements IRuleRegistryLogClient { - private sequence = 0; - private ruleDataClient: IRuleDataClient; - - constructor(ruleDataService: IRuleDataPluginService) { - this.ruleDataClient = ruleDataService.initializeIndex({ - feature: SERVER_APP_ID, - registrationContext: 'security', - dataset: Dataset.events, - componentTemplateRefs: [], - componentTemplates: [ - { - name: 'mappings', - mappings: mappingFromFieldMap(ruleExecutionFieldMap, 'strict'), - }, - ], - }); - } - - public async find({ ruleIds, spaceId, statuses, logsCount = 1 }: FindExecutionLogArgs) { - if (ruleIds.length === 0) { - return {}; - } - - const filter: estypes.QueryDslQueryContainer[] = [ - { terms: { [ALERT_RULE_UUID]: ruleIds } }, - { terms: { [SPACE_IDS]: [spaceId] } }, - ]; - - if (statuses) { - filter.push({ terms: { [RULE_STATUS]: statuses } }); - } - - const result = await this.ruleDataClient.getReader().search({ - size: 0, - body: { - query: { - bool: { - filter, - }, - }, - aggs: { - rules: { - terms: { - field: ALERT_RULE_UUID, - size: ruleIds.length, - }, - aggs: { - most_recent_logs: { - top_hits: { - sort: sortByTimeDesc, - size: logsCount, - }, - }, - last_failure: getLastEntryAggregation(RuleExecutionStatus.failed), - last_success: getLastEntryAggregation(RuleExecutionStatus.succeeded), - execution_gap: getMetricAggregation(ExecutionMetric.executionGap), - search_duration_max: getMetricAggregation(ExecutionMetric.searchDurationMax), - indexing_duration_max: getMetricAggregation(ExecutionMetric.indexingDurationMax), - indexing_lookback: getMetricAggregation(ExecutionMetric.indexingLookback), - }, - }, - }, - }, - }); - - if (result.hits.total.value === 0) { - return {}; - } - - invariant(result.aggregations, 'Search response should contain aggregations'); - - return Object.fromEntries( - result.aggregations.rules.buckets.map<[ruleId: string, logs: IRuleStatusSOAttributes[]]>( - (bucket) => [ - bucket.key as string, - bucket.most_recent_logs.hits.hits.map((event) => { - const logEntry = parseRuleExecutionLog(event._source); - invariant( - logEntry[ALERT_RULE_UUID] ?? '', - 'Malformed execution log entry: rule.id field not found' - ); - - const lastFailure = bucket.last_failure.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.last_failure.event.hits.hits[0]._source) - : undefined; - - const lastSuccess = bucket.last_success.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.last_success.event.hits.hits[0]._source) - : undefined; - - const lookBack = bucket.indexing_lookback.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.indexing_lookback.event.hits.hits[0]._source) - : undefined; - - const executionGap = bucket.execution_gap.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.execution_gap.event.hits.hits[0]._source)[ - getMetricField(ExecutionMetric.executionGap) - ] - : undefined; - - const searchDuration = bucket.search_duration_max.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.search_duration_max.event.hits.hits[0]._source)[ - getMetricField(ExecutionMetric.searchDurationMax) - ] - : undefined; - - const indexingDuration = bucket.indexing_duration_max.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.indexing_duration_max.event.hits.hits[0]._source)[ - getMetricField(ExecutionMetric.indexingDurationMax) - ] - : undefined; - - const alertId = logEntry[ALERT_RULE_UUID] ?? ''; - const statusDate = logEntry[TIMESTAMP]; - const lastFailureAt = lastFailure?.[TIMESTAMP]; - const lastFailureMessage = lastFailure?.[MESSAGE]; - const lastSuccessAt = lastSuccess?.[TIMESTAMP]; - const lastSuccessMessage = lastSuccess?.[MESSAGE]; - const status = (logEntry[RULE_STATUS] as RuleExecutionStatus) || null; - const lastLookBackDate = lookBack?.[getMetricField(ExecutionMetric.indexingLookback)]; - const gap = executionGap ? moment.duration(executionGap).humanize() : null; - const bulkCreateTimeDurations = indexingDuration - ? [makeFloatString(indexingDuration)] - : null; - const searchAfterTimeDurations = searchDuration - ? [makeFloatString(searchDuration)] - : null; - - return { - alertId, - statusDate, - lastFailureAt, - lastFailureMessage, - lastSuccessAt, - lastSuccessMessage, - status, - lastLookBackDate, - gap, - bulkCreateTimeDurations, - searchAfterTimeDurations, - }; - }), - ] - ) - ); - } - - public async logExecutionMetric({ - ruleId, - namespace, - metric, - value, - spaceId, - }: ExecutionMetricArgs) { - await this.create( - { - [SPACE_IDS]: [spaceId], - [EVENT_ACTION]: metric, - [EVENT_KIND]: 'metric', - [getMetricField(metric)]: value, - [ALERT_RULE_UUID]: ruleId ?? '', - [TIMESTAMP]: new Date().toISOString(), - [ALERT_RULE_CONSUMER]: SERVER_APP_ID, - [ALERT_RULE_TYPE_ID]: SERVER_APP_ID, - }, - namespace - ); - } - - public async logStatusChange({ - ruleId, - newStatus, - namespace, - message, - spaceId, - }: LogStatusChangeArgs) { - await this.create( - { - [SPACE_IDS]: [spaceId], - [EVENT_ACTION]: 'status-change', - [EVENT_KIND]: 'event', - [EVENT_SEQUENCE]: this.sequence++, - [MESSAGE]: message, - [ALERT_RULE_UUID]: ruleId ?? '', - [RULE_STATUS_SEVERITY]: statusSeverityDict[newStatus], - [RULE_STATUS]: newStatus, - [TIMESTAMP]: new Date().toISOString(), - [ALERT_RULE_CONSUMER]: SERVER_APP_ID, - [ALERT_RULE_TYPE_ID]: SERVER_APP_ID, - }, - namespace - ); - } - - public async create(event: RuleExecutionEvent, namespace?: string) { - await this.ruleDataClient.getWriter({ namespace }).bulk({ - body: [{ index: {} }, event], - }); - } -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts deleted file mode 100644 index 713cf73890e7..000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts +++ /dev/null @@ -1,76 +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 { SearchSort } from '@elastic/elasticsearch/api/types'; -import { EVENT_ACTION, TIMESTAMP } from '@kbn/rule-data-utils'; -import { RuleExecutionStatus } from '../../../../../../common/detection_engine/schemas/common/schemas'; -import { ExecutionMetric } from '../../types'; -import { RULE_STATUS, EVENT_SEQUENCE, EVENT_DURATION, EVENT_END } from './constants'; - -const METRIC_FIELDS = { - [ExecutionMetric.executionGap]: EVENT_DURATION, - [ExecutionMetric.searchDurationMax]: EVENT_DURATION, - [ExecutionMetric.indexingDurationMax]: EVENT_DURATION, - [ExecutionMetric.indexingLookback]: EVENT_END, -}; - -/** - * Returns ECS field in which metric value is stored - * @deprecated getMetricField is kept here only as a reference. It will be superseded with EventLog implementation - * - * @param metric - execution metric - * @returns ECS field - */ -export const getMetricField = (metric: T) => METRIC_FIELDS[metric]; - -/** - * @deprecated sortByTimeDesc is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const sortByTimeDesc: SearchSort = [{ [TIMESTAMP]: 'desc' }, { [EVENT_SEQUENCE]: 'desc' }]; - -/** - * Builds aggregation to retrieve the most recent metric value - * @deprecated getMetricAggregation is kept here only as a reference. It will be superseded with EventLog implementation - * - * @param metric - execution metric - * @returns aggregation - */ -export const getMetricAggregation = (metric: ExecutionMetric) => ({ - filter: { - term: { [EVENT_ACTION]: metric }, - }, - aggs: { - event: { - top_hits: { - size: 1, - sort: sortByTimeDesc, - _source: [TIMESTAMP, getMetricField(metric)], - }, - }, - }, -}); - -/** - * Builds aggregation to retrieve the most recent log entry with the given status - * @deprecated getLastEntryAggregation is kept here only as a reference. It will be superseded with EventLog implementation - * - * @param status - rule execution status - * @returns aggregation - */ -export const getLastEntryAggregation = (status: RuleExecutionStatus) => ({ - filter: { - term: { [RULE_STATUS]: status }, - }, - aggs: { - event: { - top_hits: { - sort: sortByTimeDesc, - size: 1, - }, - }, - }, -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts index 27329ebf8f90..ca806bd58e36 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts @@ -14,12 +14,11 @@ import { ruleStatusSavedObjectsClientFactory, } from './rule_status_saved_objects_client'; import { - ExecutionMetric, - ExecutionMetricArgs, + LogExecutionMetricsArgs, FindBulkExecutionLogArgs, FindExecutionLogArgs, IRuleExecutionLogClient, - LegacyMetrics, + ExecutionMetrics, LogStatusChangeArgs, UpdateExecutionLogArgs, } from '../types'; @@ -28,14 +27,16 @@ import { assertUnreachable } from '../../../../../common'; // 1st is mutable status, followed by 5 most recent failures export const MAX_RULE_STATUSES = 6; -const METRIC_FIELDS = { - [ExecutionMetric.executionGap]: 'gap', - [ExecutionMetric.searchDurationMax]: 'searchAfterTimeDurations', - [ExecutionMetric.indexingDurationMax]: 'bulkCreateTimeDurations', - [ExecutionMetric.indexingLookback]: 'lastLookBackDate', -} as const; - -const getMetricField = (metric: T) => METRIC_FIELDS[metric]; +const convertMetricFields = ( + metrics: ExecutionMetrics +): Pick< + IRuleStatusSOAttributes, + 'gap' | 'searchAfterTimeDurations' | 'bulkCreateTimeDurations' +> => ({ + gap: metrics.executionGap?.humanize(), + searchAfterTimeDurations: metrics.searchDurations, + bulkCreateTimeDurations: metrics.indexingDurations, +}); export class SavedObjectsAdapter implements IRuleExecutionLogClient { private ruleStatusClient: RuleStatusSavedObjectsClient; @@ -66,16 +67,12 @@ export class SavedObjectsAdapter implements IRuleExecutionLogClient { await this.ruleStatusClient.delete(id); } - public async logExecutionMetric({ - ruleId, - metric, - value, - }: ExecutionMetricArgs) { + public async logExecutionMetrics({ ruleId, metrics }: LogExecutionMetricsArgs) { const [currentStatus] = await this.getOrCreateRuleStatuses(ruleId); await this.ruleStatusClient.update(currentStatus.id, { ...currentStatus.attributes, - [getMetricField(metric)]: value, + ...convertMetricFields(metrics), }); } @@ -158,11 +155,11 @@ export class SavedObjectsAdapter implements IRuleExecutionLogClient { const buildRuleStatusAttributes: ( status: RuleExecutionStatus, message?: string, - metrics?: LegacyMetrics + metrics?: ExecutionMetrics ) => Partial = (status, message, metrics = {}) => { const now = new Date().toISOString(); const baseAttributes: Partial = { - ...metrics, + ...convertMetricFields(metrics), status: status === RuleExecutionStatus.warning ? RuleExecutionStatus['partial failure'] : status, statusDate: now, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts index 9c66032f681d..e38f974ddee2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts @@ -5,28 +5,16 @@ * 2.0. */ -import { PublicMethodsOf } from '@kbn/utility-types'; +import { Duration } from 'moment'; import { SavedObjectsFindResult } from '../../../../../../../src/core/server'; -import { RuleDataPluginService } from '../../../../../rule_registry/server'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; import { IRuleStatusSOAttributes } from '../rules/types'; -export enum ExecutionMetric { - 'executionGap' = 'executionGap', - 'searchDurationMax' = 'searchDurationMax', - 'indexingDurationMax' = 'indexingDurationMax', - 'indexingLookback' = 'indexingLookback', +export enum UnderlyingLogClient { + 'savedObjects' = 'savedObjects', + 'eventLog' = 'eventLog', } -export type IRuleDataPluginService = PublicMethodsOf; - -export type ExecutionMetricValue = { - [ExecutionMetric.executionGap]: number; - [ExecutionMetric.searchDurationMax]: number; - [ExecutionMetric.indexingDurationMax]: number; - [ExecutionMetric.indexingLookback]: Date; -}[T]; - export interface FindExecutionLogArgs { ruleId: string; spaceId: string; @@ -39,29 +27,34 @@ export interface FindBulkExecutionLogArgs { logsCount?: number; } -/** - * @deprecated LegacyMetrics are only kept here for backward compatibility - * and should be replaced by ExecutionMetric in the future - */ -export interface LegacyMetrics { - searchAfterTimeDurations?: string[]; - bulkCreateTimeDurations?: string[]; +export interface ExecutionMetrics { + searchDurations?: string[]; + indexingDurations?: string[]; + /** + * @deprecated lastLookBackDate is logged only by SavedObjectsAdapter and should be removed in the future + */ lastLookBackDate?: string; - gap?: string; + executionGap?: Duration; } export interface LogStatusChangeArgs { ruleId: string; + ruleName: string; + ruleType: string; spaceId: string; newStatus: RuleExecutionStatus; - namespace?: string; message?: string; - metrics?: LegacyMetrics; + /** + * @deprecated Use RuleExecutionLogClient.logExecutionMetrics to write metrics instead + */ + metrics?: ExecutionMetrics; } export interface UpdateExecutionLogArgs { id: string; attributes: IRuleStatusSOAttributes; + ruleName: string; + ruleType: string; spaceId: string; } @@ -70,12 +63,12 @@ export interface CreateExecutionLogArgs { spaceId: string; } -export interface ExecutionMetricArgs { +export interface LogExecutionMetricsArgs { ruleId: string; + ruleName: string; + ruleType: string; spaceId: string; - namespace?: string; - metric: T; - value: ExecutionMetricValue; + metrics: ExecutionMetrics; } export interface FindBulkExecutionLogResponse { @@ -90,5 +83,5 @@ export interface IRuleExecutionLogClient { update: (args: UpdateExecutionLogArgs) => Promise; delete: (id: string) => Promise; logStatusChange: (args: LogStatusChangeArgs) => Promise; - logExecutionMetric: (args: ExecutionMetricArgs) => Promise; + logExecutionMetrics: (args: LogExecutionMetricsArgs) => Promise; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts index bcab8a0af5ff..c6f818f04fc5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts @@ -14,6 +14,7 @@ import { mlPluginServerMock } from '../../../../../../ml/server/mocks'; import type { IRuleDataClient } from '../../../../../../rule_registry/server'; import { ruleRegistryMocks } from '../../../../../../rule_registry/server/mocks'; +import { eventLogServiceMock } from '../../../../../../event_log/server/mocks'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../../../alerting/server'; import { ConfigType } from '../../../../config'; import { AlertAttributes } from '../../signals/types'; @@ -55,6 +56,7 @@ export const createRuleTypeMocks = ( references: [], attributes: { actions: [], + alertTypeId: 'siem.signals', enabled: true, name: 'mock rule', tags: [], @@ -89,7 +91,7 @@ export const createRuleTypeMocks = ( ruleDataClient: ruleRegistryMocks.createRuleDataClient( '.alerts-security.alerts' ) as IRuleDataClient, - ruleDataService: ruleRegistryMocks.createRuleDataPluginService(), + eventLogService: eventLogServiceMock.create(), }, services, scheduleActions, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts index df15d4b2c011..9ea36abe997c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts @@ -40,8 +40,9 @@ import { scheduleThrottledNotificationActions } from '../notifications/schedule_ /* eslint-disable complexity */ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = - ({ lists, logger, mergeStrategy, ignoreFields, ruleDataClient, ruleDataService }) => + ({ lists, logger, config, ruleDataClient, eventLogService }) => (type) => { + const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; const persistenceRuleType = createPersistenceRuleTypeFactory({ ruleDataClient, logger }); return persistenceRuleType({ ...type, @@ -65,13 +66,15 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = const ruleStatusClient = new RuleExecutionLogClient({ savedObjectsClient, - ruleDataService, + eventLogService, + underlyingClient: config.ruleExecutionLog.underlyingClient, }); const ruleSO = await savedObjectsClient.get('alert', alertId); const { actions, name, + alertTypeId, schedule: { interval }, } = ruleSO.attributes; const refresh = actions.length ? 'wait_for' : false; @@ -87,9 +90,14 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = logger.debug(buildRuleMessage(`interval: ${interval}`)); let wroteWarningStatus = false; - await ruleStatusClient.logStatusChange({ + const basicLogArguments = { spaceId, ruleId: alertId, + ruleName: name, + ruleType: alertTypeId, + }; + await ruleStatusClient.logStatusChange({ + ...basicLogArguments, newStatus: RuleExecutionStatus['going to run'], }); @@ -125,8 +133,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = tryCatch( () => hasReadIndexPrivileges({ - spaceId, - ruleId: alertId, + ...basicLogArguments, privileges, logger, buildRuleMessage, @@ -138,8 +145,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = tryCatch( () => hasTimestampFields({ - spaceId, - ruleId: alertId, + ...basicLogArguments, wroteStatus: wroteStatus as boolean, timestampField: hasTimestampOverride ? (timestampOverride as string) @@ -179,11 +185,10 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = logger.warn(gapMessage); hasError = true; await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: gapMessage, - metrics: { gap: gapString }, + metrics: { executionGap: remainingGap }, }); } @@ -262,8 +267,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = if (result.warningMessages.length) { const warningMessage = buildRuleMessage(result.warningMessages.join()); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus['partial failure'], message: warningMessage, }); @@ -327,13 +331,12 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = if (!hasError && !wroteWarningStatus && !result.warning) { await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.succeeded, message: 'succeeded', metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookbackDate?.toISOString(), }, }); @@ -356,13 +359,12 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = ); logger.error(errorMessage); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: errorMessage, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookbackDate?.toISOString(), }, }); @@ -376,13 +378,12 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = logger.error(message); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookbackDate?.toISOString(), }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts index 868419179c76..43860d396ac5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts @@ -12,6 +12,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { createEqlAlertType } from './create_eql_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { getEqlRuleParams } from '../../schemas/rule_schemas.mock'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../rule_execution_log/rule_execution_log_client'); @@ -26,10 +27,9 @@ describe('Event correlation alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - ignoreFields: [], - mergeStrategy: 'allFields', + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); dependencies.alerting.registerType(eqlAlertType); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts index 5893c6fdc86c..9324b469bf64 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts @@ -14,23 +14,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createEqlAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - ignoreFields, - mergeStrategy, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - ignoreFields, - mergeStrategy, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: EQL_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts index 6daafbfae40f..a7accc4ae8a0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts @@ -44,6 +44,7 @@ describe('buildAlert', () => { const ruleSO = { attributes: { actions: [], + alertTypeId: 'siem.signals', createdAt: new Date().toISOString(), createdBy: 'gandalf', params: getQueryRuleParams(), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts index fe836c872dca..3db4f5686abd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts @@ -16,6 +16,7 @@ import { createIndicatorMatchAlertType } from './create_indicator_match_alert_ty import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { CountResponse } from 'kibana/server'; import { RuleParams } from '../../schemas/rule_schemas'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../utils/get_list_client', () => ({ getListClient: jest.fn().mockReturnValue({ @@ -56,10 +57,9 @@ describe('Indicator Match Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - ignoreFields: [], - mergeStrategy: 'allFields', + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); @@ -97,10 +97,9 @@ describe('Indicator Match Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); @@ -136,10 +135,9 @@ describe('Indicator Match Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts index e2d5da1def70..c30fdd7d99c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts @@ -14,23 +14,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createIndicatorMatchAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - mergeStrategy, - ignoreFields, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: INDICATOR_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts index 23cd2e94aedf..bffc20c3df1e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts @@ -14,6 +14,7 @@ import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { createMlAlertType } from './create_ml_alert_type'; import { RuleParams } from '../../schemas/rule_schemas'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../signals/bulk_create_ml_signals'); @@ -97,11 +98,10 @@ describe('Machine Learning Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ml: mlMock, ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts index ec2f5dd10464..ac2d3f14831a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts @@ -14,15 +14,13 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createMlAlertType = (createOptions: CreateRuleOptions) => { - const { lists, logger, mergeStrategy, ignoreFields, ml, ruleDataClient, ruleDataService } = - createOptions; + const { lists, logger, config, ml, ruleDataClient, eventLogService } = createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: ML_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index e45d8440386f..4fdeac8047b1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -14,6 +14,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { createQueryAlertType } from './create_query_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../utils/get_list_client', () => ({ getListClient: jest.fn().mockReturnValue({ @@ -31,10 +32,9 @@ describe('Custom Query Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); @@ -79,10 +79,9 @@ describe('Custom Query Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts index d5af7a4c8b5a..469c237112dc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts @@ -14,23 +14,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createQueryAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - mergeStrategy, - ignoreFields, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: QUERY_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts index 74435cb30047..aff57dbdf3cd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts @@ -9,6 +9,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { createThresholdAlertType } from './create_threshold_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { getThresholdRuleParams } from '../../schemas/rule_schemas.mock'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../rule_execution_log/rule_execution_log_client'); @@ -20,10 +21,9 @@ describe('Threshold Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); dependencies.alerting.registerType(thresholdAlertTpe); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts index a503cf5aedbe..789e4525c58a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts @@ -16,23 +16,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createThresholdAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - mergeStrategy, - ignoreFields, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: THRESHOLD_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 6280a50d4981..c94339da03b9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -30,12 +30,12 @@ import { import { BaseHit } from '../../../../common/detection_engine/types'; import { ConfigType } from '../../../config'; import { SetupPlugins } from '../../../plugin'; -import { IRuleDataPluginService } from '../rule_execution_log/types'; import { RuleParams } from '../schemas/rule_schemas'; import { BuildRuleMessage } from '../signals/rule_messages'; import { AlertAttributes, BulkCreate, WrapHits, WrapSequences } from '../signals/types'; import { AlertsFieldMap, RulesFieldMap } from './field_maps'; import { ExperimentalFeatures } from '../../../../common/experimental_features'; +import { IEventLogService } from '../../../../../event_log/server'; export interface SecurityAlertTypeReturnValue { bulkCreateTimes: string[]; @@ -98,10 +98,9 @@ type SecurityAlertTypeWithExecutor< export type CreateSecurityRuleTypeFactory = (options: { lists: SetupPlugins['lists']; logger: Logger; - mergeStrategy: ConfigType['alertMergeStrategy']; - ignoreFields: ConfigType['alertIgnoreFields']; + config: ConfigType; ruleDataClient: IRuleDataClient; - ruleDataService: IRuleDataPluginService; + eventLogService: IEventLogService; }) => < TParams extends RuleParams & { index?: string[] | undefined }, TAlertInstanceContext extends AlertInstanceContext, @@ -127,10 +126,9 @@ export interface CreateRuleOptions { experimentalFeatures: ExperimentalFeatures; lists: SetupPlugins['lists']; logger: Logger; - mergeStrategy: ConfigType['alertMergeStrategy']; - ignoreFields: ConfigType['alertIgnoreFields']; + config: ConfigType; ml?: SetupPlugins['ml']; ruleDataClient: IRuleDataClient; version: string; - ruleDataService: IRuleDataPluginService; + eventLogService: IEventLogService; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts index c6a5c0038024..2f3d05e0c958 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts @@ -44,6 +44,8 @@ export const enableRule = async ({ const currentStatusToDisable = ruleCurrentStatus[0]; await ruleStatusClient.update({ id: currentStatusToDisable.id, + ruleName: rule.name, + ruleType: rule.alertTypeId, attributes: { ...currentStatusToDisable.attributes, status: RuleExecutionStatus['going to run'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 850eee3993b6..207ea497c7e8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -33,6 +33,7 @@ export const sampleRuleSO = (params: T): SavedObject { updated_at: '2020-03-27T22:55:59.577Z', attributes: { actions: [], + alertTypeId: 'siem.signals', enabled: true, name: 'rule-name', tags: ['some fake tag 1', 'some fake tag 2'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts index 5766390099e2..11145405dcc9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts @@ -30,6 +30,7 @@ describe('threshold_executor', () => { updated_at: '2020-03-27T22:55:59.577Z', attributes: { actions: [], + alertTypeId: 'siem.signals', enabled: true, name: 'rule-name', tags: ['some fake tag 1', 'some fake tag 2'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 2696d6981083..c2923b566175 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -32,10 +32,11 @@ import { mlExecutor } from './executors/ml'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { allowedExperimentalValues } from '../../../../common/experimental_features'; -import { ruleRegistryMocks } from '../../../../../rule_registry/server/mocks'; import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; +import { eventLogServiceMock } from '../../../../../event_log/server/mocks'; +import { createMockConfig } from '../routes/__mocks__'; jest.mock('./utils', () => { const original = jest.requireActual('./utils'); @@ -124,12 +125,12 @@ describe('signal_rule_alert_type', () => { let alert: ReturnType; let logger: ReturnType; let alertServices: AlertServicesMock; - let ruleDataService: ReturnType; + let eventLogService: ReturnType; beforeEach(() => { alertServices = alertsMock.createAlertServices(); logger = loggingSystemMock.createLogger(); - ruleDataService = ruleRegistryMocks.createRuleDataPluginService(); + eventLogService = eventLogServiceMock.create(); (getListsClient as jest.Mock).mockReturnValue({ listClient: getListClientMock(), exceptionsClient: getExceptionListClientMock(), @@ -194,9 +195,8 @@ describe('signal_rule_alert_type', () => { version, ml: mlMock, lists: listMock.createSetup(), - mergeStrategy: 'missingFields', - ignoreFields: [], - ruleDataService, + config: createMockConfig(), + eventLogService, }); mockRuleExecutionLogClient.logStatusChange.mockClear(); @@ -217,11 +217,18 @@ describe('signal_rule_alert_type', () => { payload.previousStartedAt = moment().subtract(100, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); - expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith( + expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + newStatus: RuleExecutionStatus['going to run'], + }) + ); + expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ newStatus: RuleExecutionStatus.failed, metrics: { - gap: 'an hour', + executionGap: expect.any(Object), }, }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 9a6c099ed176..1e3a8a513c4a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -70,9 +70,9 @@ import { ConfigType } from '../../../config'; import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { injectReferences, extractReferences } from './saved_object_references'; import { RuleExecutionLogClient } from '../rule_execution_log/rule_execution_log_client'; -import { IRuleDataPluginService } from '../rule_execution_log/types'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; import { scheduleThrottledNotificationActions } from '../notifications/schedule_throttle_notification_actions'; +import { IEventLogService } from '../../../../../event_log/server'; export const signalRulesAlertType = ({ logger, @@ -81,9 +81,8 @@ export const signalRulesAlertType = ({ version, ml, lists, - mergeStrategy, - ignoreFields, - ruleDataService, + config, + eventLogService, }: { logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; @@ -91,10 +90,10 @@ export const signalRulesAlertType = ({ version: string; ml: SetupPlugins['ml']; lists: SetupPlugins['lists'] | undefined; - mergeStrategy: ConfigType['alertMergeStrategy']; - ignoreFields: ConfigType['alertIgnoreFields']; - ruleDataService: IRuleDataPluginService; + config: ConfigType; + eventLogService: IEventLogService; }): SignalRuleAlertTypeDefinition => { + const { alertMergeStrategy: mergeStrategy, alertIgnoreFields: ignoreFields } = config; return { id: SIGNALS_ID, name: 'SIEM signal', @@ -138,14 +137,16 @@ export const signalRulesAlertType = ({ let hasError: boolean = false; let result = createSearchAfterReturnType(); const ruleStatusClient = new RuleExecutionLogClient({ - ruleDataService, + eventLogService, savedObjectsClient: services.savedObjectsClient, + underlyingClient: config.ruleExecutionLog.underlyingClient, }); const savedObject = await services.savedObjectsClient.get('alert', alertId); const { actions, name, + alertTypeId, schedule: { interval }, } = savedObject.attributes; const refresh = actions.length ? 'wait_for' : false; @@ -159,10 +160,16 @@ export const signalRulesAlertType = ({ logger.debug(buildRuleMessage('[+] Starting Signal Rule execution')); logger.debug(buildRuleMessage(`interval: ${interval}`)); let wroteWarningStatus = false; - await ruleStatusClient.logStatusChange({ + const basicLogArguments = { + spaceId, ruleId: alertId, + ruleName: name, + ruleType: alertTypeId, + }; + + await ruleStatusClient.logStatusChange({ + ...basicLogArguments, newStatus: RuleExecutionStatus['going to run'], - spaceId, }); // check if rule has permissions to access given index pattern @@ -194,8 +201,7 @@ export const signalRulesAlertType = ({ tryCatch( () => hasReadIndexPrivileges({ - spaceId, - ruleId: alertId, + ...basicLogArguments, privileges, logger, buildRuleMessage, @@ -207,13 +213,11 @@ export const signalRulesAlertType = ({ tryCatch( () => hasTimestampFields({ - spaceId, - ruleId: alertId, + ...basicLogArguments, wroteStatus: wroteStatus as boolean, timestampField: hasTimestampOverride ? (timestampOverride as string) : '@timestamp', - ruleName: name, timestampFieldCapsResponse: timestampFieldCaps, inputIndices, ruleStatusClient, @@ -247,11 +251,10 @@ export const signalRulesAlertType = ({ logger.warn(gapMessage); hasError = true; await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: gapMessage, - metrics: { gap: gapString }, + metrics: { executionGap: remainingGap }, }); } try { @@ -383,8 +386,7 @@ export const signalRulesAlertType = ({ if (result.warningMessages.length) { const warningMessage = buildRuleMessage(result.warningMessages.join()); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus['partial failure'], message: warningMessage, }); @@ -445,13 +447,12 @@ export const signalRulesAlertType = ({ ); if (!hasError && !wroteWarningStatus && !result.warning) { await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.succeeded, message: 'succeeded', metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookBackDate?.toISOString(), }, }); @@ -474,13 +475,12 @@ export const signalRulesAlertType = ({ ); logger.error(errorMessage); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: errorMessage, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookBackDate?.toISOString(), }, }); @@ -494,13 +494,12 @@ export const signalRulesAlertType = ({ logger.error(message); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookBackDate?.toISOString(), }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index c1e7e23c3b16..82b4a46f482b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -255,6 +255,7 @@ export interface SignalHit { export interface AlertAttributes { actions: RuleAlertAction[]; + alertTypeId: string; enabled: boolean; name: string; tags: string[]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index c3e95d6d196c..7d2eafa46d38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -789,6 +789,7 @@ describe('utils', () => { inputIndices: ['myfa*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, @@ -832,6 +833,7 @@ describe('utils', () => { inputIndices: ['myfa*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, @@ -861,6 +863,7 @@ describe('utils', () => { inputIndices: ['logs-endpoint.alerts-*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, @@ -890,6 +893,7 @@ describe('utils', () => { inputIndices: ['logs-endpoint.alerts-*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 5993dd626729..2aefc7ea0bd6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -99,9 +99,20 @@ export const hasReadIndexPrivileges = async (args: { buildRuleMessage: BuildRuleMessage; ruleStatusClient: IRuleExecutionLogClient; ruleId: string; + ruleName: string; + ruleType: string; spaceId: string; }): Promise => { - const { privileges, logger, buildRuleMessage, ruleStatusClient, ruleId, spaceId } = args; + const { + privileges, + logger, + buildRuleMessage, + ruleStatusClient, + ruleId, + ruleName, + ruleType, + spaceId, + } = args; const indexNames = Object.keys(privileges.index); const [indexesWithReadPrivileges, indexesWithNoReadPrivileges] = partition( @@ -119,6 +130,8 @@ export const hasReadIndexPrivileges = async (args: { await ruleStatusClient.logStatusChange({ message: errorString, ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); @@ -136,6 +149,8 @@ export const hasReadIndexPrivileges = async (args: { await ruleStatusClient.logStatusChange({ message: errorString, ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); @@ -156,6 +171,7 @@ export const hasTimestampFields = async (args: { ruleStatusClient: IRuleExecutionLogClient; ruleId: string; spaceId: string; + ruleType: string; logger: Logger; buildRuleMessage: BuildRuleMessage; }): Promise => { @@ -167,6 +183,7 @@ export const hasTimestampFields = async (args: { inputIndices, ruleStatusClient, ruleId, + ruleType, spaceId, logger, buildRuleMessage, @@ -184,6 +201,8 @@ export const hasTimestampFields = async (args: { await ruleStatusClient.logStatusChange({ message: errorString.trimEnd(), ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); @@ -210,6 +229,8 @@ export const hasTimestampFields = async (args: { await ruleStatusClient.logStatusChange({ message: errorString, ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9c4d739e0f43..bffcc823d047 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -109,11 +109,14 @@ import { ctiFieldMap } from './lib/detection_engine/rule_types/field_maps/cti'; import { legacyRulesNotificationAlertType } from './lib/detection_engine/notifications/legacy_rules_notification_alert_type'; // eslint-disable-next-line no-restricted-imports import { legacyIsNotificationAlertExecutor } from './lib/detection_engine/notifications/legacy_types'; +import { IEventLogClientService, IEventLogService } from '../../event_log/server'; +import { registerEventLogProvider } from './lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider'; export interface SetupPlugins { alerting: AlertingSetup; data: DataPluginSetup; encryptedSavedObjects?: EncryptedSavedObjectsSetup; + eventLog: IEventLogService; features: FeaturesSetup; lists?: ListPluginSetup; ml?: MlSetup; @@ -121,20 +124,21 @@ export interface SetupPlugins { security?: SecuritySetup; spaces?: SpacesSetup; taskManager?: TaskManagerSetupContract; - usageCollection?: UsageCollectionSetup; telemetry?: TelemetryPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface StartPlugins { alerting: AlertPluginStartContract; + cases?: CasesPluginStartContract; data: DataPluginStart; + eventLog: IEventLogClientService; fleet?: FleetStartContract; licensing: LicensingPluginStart; ruleRegistry: RuleRegistryPluginStartContract; + security: SecurityPluginStart; taskManager?: TaskManagerStartContract; telemetry?: TelemetryPluginStart; - security: SecurityPluginStart; - cases?: CasesPluginStartContract; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -202,6 +206,9 @@ export class Plugin implements IPlugin(); core.http.registerRouteHandlerContext( APP_ID, @@ -210,8 +217,9 @@ export class Plugin implements IPlugin plugins.spaces?.spacesService?.getSpaceId(request) || DEFAULT_SPACE_ID, getExecutionLogClient: () => new RuleExecutionLogClient({ - ruleDataService: plugins.ruleRegistry.ruleDataService, savedObjectsClient: context.core.savedObjects.client, + eventLogService, + underlyingClient: config.ruleExecutionLog.underlyingClient, }), }) ); @@ -262,11 +270,10 @@ export class Plugin implements IPlugin Date: Mon, 11 Oct 2021 13:49:54 +0200 Subject: [PATCH 13/19] [bfetch] Fix memory leak (#113756) --- .../create_streaming_batched_function.test.ts | 50 ++++++++--------- .../create_streaming_batched_function.ts | 7 +-- src/plugins/bfetch/public/plugin.ts | 29 ++++------ .../public/streaming/fetch_streaming.test.ts | 29 +++++----- .../public/streaming/fetch_streaming.ts | 55 ++++++++----------- 5 files changed, 76 insertions(+), 94 deletions(-) diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index 0b6dbe49d0e8..32adc0d7df0c 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -9,7 +9,7 @@ import { createStreamingBatchedFunction } from './create_streaming_batched_function'; import { fetchStreaming as fetchStreamingReal } from '../streaming/fetch_streaming'; import { AbortError, defer, of } from '../../../kibana_utils/public'; -import { Subject, of as rxof } from 'rxjs'; +import { Subject } from 'rxjs'; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); @@ -61,7 +61,7 @@ describe('createStreamingBatchedFunction()', () => { const fn = createStreamingBatchedFunction({ url: '/test', fetchStreaming, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(typeof fn).toBe('function'); }); @@ -71,7 +71,7 @@ describe('createStreamingBatchedFunction()', () => { const fn = createStreamingBatchedFunction({ url: '/test', fetchStreaming, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const res = fn({}); expect(typeof res.then).toBe('function'); @@ -85,7 +85,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(fetchStreaming).toHaveBeenCalledTimes(0); @@ -105,7 +105,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(fetchStreaming).toHaveBeenCalledTimes(0); @@ -120,7 +120,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ foo: 'bar' }); @@ -139,7 +139,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ foo: 'bar' }); @@ -161,7 +161,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(fetchStreaming).toHaveBeenCalledTimes(0); @@ -180,7 +180,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const abortController = new AbortController(); @@ -203,7 +203,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ a: '1' }); @@ -227,7 +227,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ a: '1' }); @@ -248,7 +248,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = fn({ a: '1' }); @@ -266,7 +266,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); await flushPromises(); @@ -310,7 +310,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = fn({ a: '1' }); @@ -348,7 +348,7 @@ describe('createStreamingBatchedFunction()', () => { fn({ a: '1' }); - const dontCompress = await fetchStreaming.mock.calls[0][0].compressionDisabled$.toPromise(); + const dontCompress = await fetchStreaming.mock.calls[0][0].getIsCompressionDisabled(); expect(dontCompress).toBe(false); }); @@ -359,7 +359,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = fn({ a: '1' }); @@ -401,7 +401,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise = fn({ a: '1' }); @@ -430,7 +430,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -483,7 +483,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const abortController = new AbortController(); @@ -514,7 +514,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const abortController = new AbortController(); @@ -554,7 +554,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -585,7 +585,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -623,7 +623,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -656,7 +656,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -693,7 +693,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); await flushPromises(); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts index d5f955f517d1..3ff8da08cfce 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { Observable, of } from 'rxjs'; import { AbortError, abortSignalToPromise, defer } from '../../../kibana_utils/public'; import { ItemBufferParams, @@ -51,7 +50,7 @@ export interface StreamingBatchedFunctionParams { /** * Disabled zlib compression of response chunks. */ - compressionDisabled$?: Observable; + getIsCompressionDisabled?: () => boolean; } /** @@ -69,7 +68,7 @@ export const createStreamingBatchedFunction = ( fetchStreaming: fetchStreamingInjected = fetchStreaming, flushOnMaxItems = 25, maxItemAge = 10, - compressionDisabled$ = of(false), + getIsCompressionDisabled = () => false, } = params; const [fn] = createBatchedFunction({ onCall: (payload: Payload, signal?: AbortSignal) => { @@ -125,7 +124,7 @@ export const createStreamingBatchedFunction = ( body: JSON.stringify({ batch }), method: 'POST', signal: abortController.signal, - compressionDisabled$, + getIsCompressionDisabled, }); const handleStreamError = (error: any) => { diff --git a/src/plugins/bfetch/public/plugin.ts b/src/plugins/bfetch/public/plugin.ts index 3ad451c7713e..54bcb305d867 100644 --- a/src/plugins/bfetch/public/plugin.ts +++ b/src/plugins/bfetch/public/plugin.ts @@ -6,13 +6,12 @@ * Side Public License, v 1. */ -import { CoreStart, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/public'; -import { from, Observable, of } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { fetchStreaming as fetchStreamingStatic, FetchStreamingParams } from './streaming'; import { DISABLE_BFETCH_COMPRESSION, removeLeadingSlash } from '../common'; import { createStreamingBatchedFunction, StreamingBatchedFunctionParams } from './batching'; import { BatchedFunc } from './batching/types'; +import { createStartServicesGetter } from '../../kibana_utils/public'; // eslint-disable-next-line export interface BfetchPublicSetupDependencies {} @@ -50,16 +49,12 @@ export class BfetchPublicPlugin const { version } = this.initializerContext.env.packageInfo; const basePath = core.http.basePath.get(); - const compressionDisabled$ = from(core.getStartServices()).pipe( - switchMap((deps) => { - return of(deps[0]); - }), - switchMap((coreStart) => { - return coreStart.uiSettings.get$(DISABLE_BFETCH_COMPRESSION); - }) - ); - const fetchStreaming = this.fetchStreaming(version, basePath, compressionDisabled$); - const batchedFunction = this.batchedFunction(fetchStreaming, compressionDisabled$); + const startServices = createStartServicesGetter(core.getStartServices); + const getIsCompressionDisabled = () => + startServices().core.uiSettings.get(DISABLE_BFETCH_COMPRESSION); + + const fetchStreaming = this.fetchStreaming(version, basePath, getIsCompressionDisabled); + const batchedFunction = this.batchedFunction(fetchStreaming, getIsCompressionDisabled); this.contract = { fetchStreaming, @@ -79,7 +74,7 @@ export class BfetchPublicPlugin ( version: string, basePath: string, - compressionDisabled$: Observable + getIsCompressionDisabled: () => boolean ): BfetchPublicSetup['fetchStreaming'] => (params) => fetchStreamingStatic({ @@ -90,18 +85,18 @@ export class BfetchPublicPlugin 'kbn-version': version, ...(params.headers || {}), }, - compressionDisabled$, + getIsCompressionDisabled, }); private batchedFunction = ( fetchStreaming: BfetchPublicContract['fetchStreaming'], - compressionDisabled$: Observable + getIsCompressionDisabled: () => boolean ): BfetchPublicContract['batchedFunction'] => (params) => createStreamingBatchedFunction({ ...params, - compressionDisabled$, + getIsCompressionDisabled, fetchStreaming: params.fetchStreaming || fetchStreaming, }); } diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts index a5d066f6d9a2..67ebf8d5a1c2 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts @@ -8,7 +8,6 @@ import { fetchStreaming } from './fetch_streaming'; import { mockXMLHttpRequest } from '../test_helpers/xhr'; -import { of } from 'rxjs'; import { promisify } from 'util'; import { deflate } from 'zlib'; const pDeflate = promisify(deflate); @@ -30,7 +29,7 @@ test('returns XHR request', () => { setup(); const { xhr } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(typeof xhr.readyState).toBe('number'); }); @@ -39,7 +38,7 @@ test('returns stream', () => { setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(typeof stream.subscribe).toBe('function'); }); @@ -48,7 +47,7 @@ test('promise resolves when request completes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); let resolved = false; @@ -81,7 +80,7 @@ test('promise resolves when compressed request completes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(false), + getIsCompressionDisabled: () => false, }); let resolved = false; @@ -116,7 +115,7 @@ test('promise resolves when compressed chunked request completes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(false), + getIsCompressionDisabled: () => false, }); let resolved = false; @@ -160,7 +159,7 @@ test('streams incoming text as it comes through, according to separators', async const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -201,7 +200,7 @@ test('completes stream observable when request finishes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -226,7 +225,7 @@ test('completes stream observable when aborted', async () => { const { stream } = fetchStreaming({ url: 'http://example.com', signal: abort.signal, - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -252,7 +251,7 @@ test('promise throws when request errors', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -279,7 +278,7 @@ test('stream observable errors when request errors', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -312,7 +311,7 @@ test('sets custom headers', async () => { 'Content-Type': 'text/plain', Authorization: 'Bearer 123', }, - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); @@ -326,7 +325,7 @@ test('uses credentials', async () => { fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.withCredentials).toBe(true); @@ -342,7 +341,7 @@ test('opens XHR request and sends specified body', async () => { url: 'http://elastic.co', method: 'GET', body: 'foobar', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.open).toHaveBeenCalledTimes(1); @@ -355,7 +354,7 @@ test('uses POST request method by default', async () => { const env = setup(); fetchStreaming({ url: 'http://elastic.co', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.open).toHaveBeenCalledWith('POST', 'http://elastic.co'); }); diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index 1af35ef68fb8..a94c8d3980cb 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { Observable, of } from 'rxjs'; -import { map, share, switchMap } from 'rxjs/operators'; +import { map, share } from 'rxjs/operators'; import { inflateResponse } from '.'; import { fromStreamingXhr } from './from_streaming_xhr'; import { split } from './split'; @@ -18,7 +17,7 @@ export interface FetchStreamingParams { method?: 'GET' | 'POST'; body?: string; signal?: AbortSignal; - compressionDisabled$?: Observable; + getIsCompressionDisabled?: () => boolean; } /** @@ -31,49 +30,39 @@ export function fetchStreaming({ method = 'POST', body = '', signal, - compressionDisabled$ = of(false), + getIsCompressionDisabled = () => false, }: FetchStreamingParams) { const xhr = new window.XMLHttpRequest(); - const msgStream = compressionDisabled$.pipe( - switchMap((compressionDisabled) => { - // Begin the request - xhr.open(method, url); - xhr.withCredentials = true; + // Begin the request + xhr.open(method, url); + xhr.withCredentials = true; - if (!compressionDisabled) { - headers['X-Chunk-Encoding'] = 'deflate'; - } + const isCompressionDisabled = getIsCompressionDisabled(); - // Set the HTTP headers - Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); + if (!isCompressionDisabled) { + headers['X-Chunk-Encoding'] = 'deflate'; + } - const stream = fromStreamingXhr(xhr, signal); + // Set the HTTP headers + Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); - // Send the payload to the server - xhr.send(body); + const stream = fromStreamingXhr(xhr, signal); - // Return a stream of chunked decompressed messages - return stream.pipe( - split('\n'), - map((msg) => { - return compressionDisabled ? msg : inflateResponse(msg); - }) - ); + // Send the payload to the server + xhr.send(body); + + // Return a stream of chunked decompressed messages + const stream$ = stream.pipe( + split('\n'), + map((msg) => { + return isCompressionDisabled ? msg : inflateResponse(msg); }), share() ); - // start execution - const msgStreamSub = msgStream.subscribe({ - error: (e) => {}, - complete: () => { - msgStreamSub.unsubscribe(); - }, - }); - return { xhr, - stream: msgStream, + stream: stream$, }; } From 5ff38a122b7fb4b0c77f9b3bbc43c6878ebb060c Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:10:52 +0100 Subject: [PATCH 14/19] skip failing es promotion suites (#114471) --- x-pack/test/api_integration/apis/maps/get_tile.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index 03a16175931a..b153cc1ff030 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -13,7 +13,8 @@ import { MVT_SOURCE_LAYER_NAME } from '../../../../plugins/maps/common/constants export default function ({ getService }) { const supertest = getService('supertest'); - describe('getTile', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/114471 + describe.skip('getTile', () => { it('should return vector tile containing document', async () => { const resp = await supertest .get( From 76546920fc3daab9fb3ddb891e1973775f4c61aa Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:17:33 +0100 Subject: [PATCH 15/19] skip failing es promotion suites (#114473, #114474) --- .../functional/apps/index_lifecycle_management/home_page.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts index c51e2968baee..e2540d80280c 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts @@ -16,7 +16,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const retry = getService('retry'); const esClient = getService('es'); - describe('Home page', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/114473 and https://github.com/elastic/kibana/issues/114474 + describe.skip('Home page', function () { before(async () => { await pageObjects.common.navigateToApp('indexLifecycleManagement'); }); From c34e99ee73ede89c3425dfdd13ad05747637c4b7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:37:04 +0100 Subject: [PATCH 16/19] skip flaky suites (#100951) --- .../__jest__/client_integration/follower_indices_list.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index b9e47b029e30..bcd4aaf82eeb 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -314,7 +314,8 @@ describe('', () => { }); }); - describe('detail panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/100951 + describe.skip('detail panel', () => { test('should open a detail panel when clicking on a follower index', async () => { expect(exists('followerIndexDetail')).toBe(false); From f2bfa595ee4cbc4cdea33947f8c539c40ffbf1ed Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:50:27 +0100 Subject: [PATCH 17/19] skip flaky suite (#106053) --- .../test/functional/apps/ml/anomaly_detection/custom_urls.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts index 0dcb76730960..7d4df75ccdcf 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts @@ -81,7 +81,8 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const browser = getService('browser'); - describe('custom urls', function () { + // FLAKY: https://github.com/elastic/kibana/issues/106053 + describe.skip('custom urls', function () { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); From 2ffbf6e58eadade0bb9b5386de7d3406d3cc5ae8 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Mon, 11 Oct 2021 15:20:27 +0200 Subject: [PATCH 18/19] [Security Solution] Add host isolation exception IPs UI (#113762) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../host_isolation_exceptions/service.ts | 14 ++ .../host_isolation_exceptions/store/action.ts | 17 +- .../store/builders.ts | 4 + .../store/middleware.test.ts | 80 ++++++- .../store/middleware.ts | 46 +++- .../store/reducer.test.ts | 10 + .../store/reducer.ts | 18 ++ .../pages/host_isolation_exceptions/types.ts | 5 + .../pages/host_isolation_exceptions/utils.ts | 41 ++++ .../view/components/empty.tsx | 12 +- .../view/components/form.test.tsx | 75 +++++++ .../view/components/form.tsx | 206 ++++++++++++++++++ .../view/components/form_flyout.test.tsx | 114 ++++++++++ .../view/components/form_flyout.tsx | 180 +++++++++++++++ .../view/components/translations.ts | 64 ++++++ .../host_isolation_exceptions_list.test.tsx | 22 +- .../view/host_isolation_exceptions_list.tsx | 33 ++- 17 files changed, 925 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts index 79ca595fbb61..8af353a3c953 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts @@ -6,6 +6,7 @@ */ import { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -65,6 +66,19 @@ export async function getHostIsolationExceptionItems({ return entries; } +export async function createHostIsolationExceptionItem({ + http, + exception, +}: { + http: HttpStart; + exception: CreateExceptionListItemSchema; +}): Promise { + await ensureHostIsolationExceptionsListExists(http); + return http.post(EXCEPTION_LIST_ITEM_URL, { + body: JSON.stringify(exception), + }); +} + export async function deleteHostIsolationExceptionItems(http: HttpStart, id: string) { await ensureHostIsolationExceptionsListExists(http); return http.delete(EXCEPTION_LIST_ITEM_URL, { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts index 0a9f77665537..a5fae36486f9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts @@ -14,6 +14,20 @@ export type HostIsolationExceptionsPageDataChanged = payload: HostIsolationExceptionsPageState['entries']; }; +export type HostIsolationExceptionsFormStateChanged = + Action<'hostIsolationExceptionsFormStateChanged'> & { + payload: HostIsolationExceptionsPageState['form']['status']; + }; + +export type HostIsolationExceptionsFormEntryChanged = + Action<'hostIsolationExceptionsFormEntryChanged'> & { + payload: HostIsolationExceptionsPageState['form']['entry']; + }; + +export type HostIsolationExceptionsCreateEntry = Action<'hostIsolationExceptionsCreateEntry'> & { + payload: HostIsolationExceptionsPageState['form']['entry']; +}; + export type HostIsolationExceptionsDeleteItem = Action<'hostIsolationExceptionsMarkToDelete'> & { payload?: ExceptionListItemSchema; }; @@ -24,9 +38,10 @@ export type HostIsolationExceptionsDeleteStatusChanged = Action<'hostIsolationExceptionsDeleteStatusChanged'> & { payload: HostIsolationExceptionsPageState['deletion']['status']; }; - export type HostIsolationExceptionsPageAction = | HostIsolationExceptionsPageDataChanged + | HostIsolationExceptionsCreateEntry + | HostIsolationExceptionsFormStateChanged | HostIsolationExceptionsDeleteItem | HostIsolationExceptionsSubmitDelete | HostIsolationExceptionsDeleteStatusChanged; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts index 68a50f9c813f..8f32d9cf8d45 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts @@ -16,6 +16,10 @@ export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptio page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, filter: '', }, + form: { + entry: undefined, + status: createUninitialisedResourceState(), + }, deletion: { item: undefined, status: createUninitialisedResourceState(), diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts index 984794e074eb..266853fdab5e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts @@ -5,10 +5,11 @@ * 2.0. */ +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { applyMiddleware, createStore, Store } from 'redux'; -import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { AppAction } from '../../../../common/store/actions'; import { createSpyMiddleware, @@ -19,8 +20,13 @@ import { isLoadedResourceState, isLoadingResourceState, } from '../../../state'; -import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../service'; +import { + createHostIsolationExceptionItem, + deleteHostIsolationExceptionItems, + getHostIsolationExceptionItems, +} from '../service'; import { HostIsolationExceptionsPageState } from '../types'; +import { createEmptyHostIsolationException } from '../utils'; import { initialHostIsolationExceptionsPageState } from './builders'; import { createHostIsolationExceptionsPageMiddleware } from './middleware'; import { hostIsolationExceptionsPageReducer } from './reducer'; @@ -29,6 +35,7 @@ import { getListFetchError } from './selector'; jest.mock('../service'); const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock; +const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.Mock; const fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); @@ -81,7 +88,7 @@ describe('Host isolation exceptions middleware', () => { }; beforeEach(() => { - getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockReset(); getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); }); @@ -145,11 +152,74 @@ describe('Host isolation exceptions middleware', () => { }); }); + describe('When adding an item to host isolation exceptions', () => { + let entry: CreateExceptionListItemSchema; + beforeEach(() => { + createHostIsolationExceptionItemMock.mockReset(); + entry = { + ...createEmptyHostIsolationException(), + name: 'test name', + description: 'description', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: '10.0.0.1', + }, + ], + }; + }); + it('should dispatch a form loading state when an entry is submited', async () => { + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isLoadingResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + }); + it('should dispatch a form success state when an entry is confirmed by the API', async () => { + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + expect(createHostIsolationExceptionItemMock).toHaveBeenCalledWith({ + http: fakeCoreStart.http, + exception: entry, + }); + }); + it('should dispatch a form failure state when an entry is rejected by the API', async () => { + createHostIsolationExceptionItemMock.mockRejectedValue({ + body: { message: 'error message', statusCode: 500, error: 'Not today' }, + }); + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isFailedResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + }); + }); + describe('When deleting an item from host isolation exceptions', () => { beforeEach(() => { - deleteHostIsolationExceptionItemsMock.mockClear(); + deleteHostIsolationExceptionItemsMock.mockReset(); deleteHostIsolationExceptionItemsMock.mockReturnValue(undefined); - getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockReset(); getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); store.dispatch({ type: 'hostIsolationExceptionsMarkToDelete', diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts index 4946cac48870..bbc754e8155b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts @@ -6,11 +6,13 @@ */ import { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { CoreStart, HttpSetup, HttpStart } from 'kibana/public'; import { matchPath } from 'react-router-dom'; +import { transformNewItemOutput } from '@kbn/securitysolution-list-hooks'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { ImmutableMiddleware, ImmutableMiddlewareAPI } from '../../../../common/store'; import { AppAction } from '../../../../common/store/actions'; @@ -20,7 +22,11 @@ import { createFailedResourceState, createLoadedResourceState, } from '../../../state/async_resource_builders'; -import { deleteHostIsolationExceptionItems, getHostIsolationExceptionItems } from '../service'; +import { + deleteHostIsolationExceptionItems, + getHostIsolationExceptionItems, + createHostIsolationExceptionItem, +} from '../service'; import { HostIsolationExceptionsPageState } from '../types'; import { getCurrentListPageDataState, getCurrentLocation, getItemToDelete } from './selector'; @@ -39,12 +45,50 @@ export const createHostIsolationExceptionsPageMiddleware = ( if (action.type === 'userChangedUrl' && isHostIsolationExceptionsPage(action.payload)) { loadHostIsolationExceptionsList(store, coreStart.http); } + + if (action.type === 'hostIsolationExceptionsCreateEntry') { + createHostIsolationException(store, coreStart.http); + } + if (action.type === 'hostIsolationExceptionsSubmitDelete') { deleteHostIsolationExceptionsItem(store, coreStart.http); } }; }; +async function createHostIsolationException( + store: ImmutableMiddlewareAPI, + http: HttpStart +) { + const { dispatch } = store; + const entry = transformNewItemOutput( + store.getState().form.entry as CreateExceptionListItemSchema + ); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) + previousState: entry, + }, + }); + try { + const response = await createHostIsolationExceptionItem({ + http, + exception: entry, + }); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: createLoadedResourceState(response), + }); + } catch (error) { + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } +} + async function loadHostIsolationExceptionsList( store: ImmutableMiddlewareAPI, http: HttpStart diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts index 211b03f36d96..98b459fac41d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts @@ -11,6 +11,7 @@ import { initialHostIsolationExceptionsPageState } from './builders'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { hostIsolationExceptionsPageReducer } from './reducer'; import { getCurrentLocation } from './selector'; +import { createEmptyHostIsolationException } from '../utils'; describe('Host Isolation Exceptions Reducer', () => { let initialState: HostIsolationExceptionsPageState; @@ -41,4 +42,13 @@ describe('Host Isolation Exceptions Reducer', () => { }); }); }); + it('should set an initial loading state when creating new entries', () => { + const entry = createEmptyHostIsolationException(); + const result = hostIsolationExceptionsPageReducer(initialState, { + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + expect(result.form.status).toEqual({ type: 'UninitialisedResourceState' }); + expect(result.form.entry).toBe(entry); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts index 09182661a80b..d97295598f44 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts @@ -38,6 +38,24 @@ export const hostIsolationExceptionsPageReducer: StateReducer = ( action ) => { switch (action.type) { + case 'hostIsolationExceptionsCreateEntry': { + return { + ...state, + form: { + entry: action.payload, + status: createUninitialisedResourceState(), + }, + }; + } + case 'hostIsolationExceptionsFormStateChanged': { + return { + ...state, + form: { + ...state.form, + status: action.payload, + }, + }; + } case 'hostIsolationExceptionsPageDataChanged': { return { ...state, diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts index 443a86fefab8..1a74042fb652 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts @@ -6,6 +6,7 @@ */ import type { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -27,4 +28,8 @@ export interface HostIsolationExceptionsPageState { item?: ExceptionListItemSchema; status: AsyncResourceState; }; + form: { + entry?: CreateExceptionListItemSchema; + status: AsyncResourceState; + }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts new file mode 100644 index 000000000000..bfb1ac048e28 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import ipaddr from 'ipaddr.js'; + +export function createEmptyHostIsolationException(): CreateExceptionListItemSchema { + return { + comments: [], + description: '', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: '', + }, + ], + item_id: undefined, + list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + name: '', + namespace_type: 'agnostic', + os_types: ['windows', 'linux', 'macos'], + tags: ['policy:all'], + type: 'simple', + }; +} + +export function isValidIPv4OrCIDR(maybeIp: string): boolean { + try { + ipaddr.IPv4.parseCIDR(maybeIp); + return true; + } catch (e) { + return ipaddr.IPv4.isValid(maybeIp); + } +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx index d7c512794173..eb53268a9fbd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import styled, { css } from 'styled-components'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; const EmptyPrompt = styled(EuiEmptyPrompt)` @@ -16,7 +16,7 @@ const EmptyPrompt = styled(EuiEmptyPrompt)` `} `; -export const HostIsolationExceptionsEmptyState = memo<{}>(() => { +export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({ onAdd }) => { return ( (() => { defaultMessage="There are currently no host isolation exceptions" /> } + actions={ + + + + } /> ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx new file mode 100644 index 000000000000..b06449de69d8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createEmptyHostIsolationException } from '../../utils'; +import { HostIsolationExceptionsForm } from './form'; +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import userEvent from '@testing-library/user-event'; + +describe('When on the host isolation exceptions add entry form', () => { + let render: ( + exception: CreateExceptionListItemSchema + ) => ReturnType; + let renderResult: ReturnType; + const onChange = jest.fn(); + const onError = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + onError.mockReset(); + const mockedContext = createAppRootMockRenderer(); + render = (exception: CreateExceptionListItemSchema) => { + return mockedContext.render( + + ); + }; + }); + + describe('When creating a new exception', () => { + let newException: CreateExceptionListItemSchema; + beforeEach(() => { + newException = createEmptyHostIsolationException(); + renderResult = render(newException); + }); + it('should render the form with empty inputs', () => { + expect(renderResult.getByTestId('hostIsolationExceptions-form-name-input')).toHaveValue(''); + expect(renderResult.getByTestId('hostIsolationExceptions-form-ip-input')).toHaveValue(''); + expect( + renderResult.getByTestId('hostIsolationExceptions-form-description-input') + ).toHaveValue(''); + }); + it('should call onError with true when a wrong ip value is introduced', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + userEvent.type(ipInput, 'not an ip'); + expect(onError).toHaveBeenCalledWith(true); + }); + it('should call onError with false when a correct values are introduced', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + const nameInput = renderResult.getByTestId('hostIsolationExceptions-form-name-input'); + + userEvent.type(nameInput, 'test name'); + userEvent.type(ipInput, '10.0.0.1'); + + expect(onError).toHaveBeenLastCalledWith(false); + }); + it('should call onChange when a value is introduced in a field', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + userEvent.type(ipInput, '10.0.0.1'); + expect(onChange).toHaveBeenLastCalledWith({ + ...newException, + entries: [ + { field: 'destination.ip', operator: 'included', type: 'match', value: '10.0.0.1' }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx new file mode 100644 index 000000000000..84263f9d07c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx @@ -0,0 +1,206 @@ +/* + * 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 { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiHorizontalRule, + EuiSpacer, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { isValidIPv4OrCIDR } from '../../utils'; +import { + DESCRIPTION_LABEL, + DESCRIPTION_PLACEHOLDER, + IP_ERROR, + IP_LABEL, + IP_PLACEHOLDER, + NAME_ERROR, + NAME_LABEL, + NAME_PLACEHOLDER, +} from './translations'; + +interface ExceptionIpEntry { + field: 'destination.ip'; + operator: 'included'; + type: 'match'; + value: ''; +} + +export const HostIsolationExceptionsForm: React.FC<{ + exception: CreateExceptionListItemSchema; + onError: (error: boolean) => void; + onChange: (exception: CreateExceptionListItemSchema) => void; +}> = memo(({ exception, onError, onChange }) => { + const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); + const [hasBeenInputIpVisited, setHasBeenInputIpVisited] = useState(false); + const [hasNameError, setHasNameError] = useState(true); + const [hasIpError, setHasIpError] = useState(true); + + useEffect(() => { + onError(hasNameError || hasIpError); + }, [hasNameError, hasIpError, onError]); + + const handleOnChangeName = useCallback( + (event: React.ChangeEvent) => { + const name = event.target.value; + if (!name.trim()) { + setHasNameError(true); + return; + } + setHasNameError(false); + onChange({ ...exception, name }); + }, + [exception, onChange] + ); + + const handleOnIpChange = useCallback( + (event: React.ChangeEvent) => { + const ip = event.target.value; + if (!isValidIPv4OrCIDR(ip)) { + setHasIpError(true); + return; + } + setHasIpError(false); + onChange({ + ...exception, + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: ip, + }, + ], + }); + }, + [exception, onChange] + ); + + const handleOnDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + onChange({ ...exception, description: event.target.value }); + }, + [exception, onChange] + ); + + const nameInput = useMemo( + () => ( + + !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} + /> + + ), + [hasNameError, hasBeenInputNameVisited, exception.name, handleOnChangeName] + ); + + const ipInput = useMemo( + () => ( + + !hasBeenInputIpVisited && setHasBeenInputIpVisited(true)} + /> + + ), + [hasIpError, hasBeenInputIpVisited, exception.entries, handleOnIpChange] + ); + + const descriptionInput = useMemo( + () => ( + + + + ), + [exception.description, handleOnDescriptionChange] + ); + + return ( + + +

+ +

+
+ + + + + {nameInput} + {descriptionInput} + + + +

+ +

+
+ + + + + {ipInput} +
+ ); +}); + +HostIsolationExceptionsForm.displayName = 'HostIsolationExceptionsForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx new file mode 100644 index 000000000000..6cfc9f56bead --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import { HostIsolationExceptionsFormFlyout } from './form_flyout'; +import { act } from 'react-dom/test-utils'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../../common/constants'; + +jest.mock('../../service.ts'); + +describe('When on the host isolation exceptions flyout form', () => { + let mockedContext: AppContextTestRender; + let render: () => ReturnType; + let renderResult: ReturnType; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + + // const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.mock; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + render = () => { + return mockedContext.render(); + }; + waitForAction = mockedContext.middlewareSpy.waitForAction; + }); + + describe('When creating a new exception', () => { + describe('with invalid data', () => { + it('should show disabled buttons when the form first load', () => { + renderResult = render(); + expect(renderResult.getByTestId('add-exception-cancel-button')).not.toHaveAttribute( + 'disabled' + ); + expect(renderResult.getByTestId('add-exception-confirm-button')).toHaveAttribute( + 'disabled' + ); + }); + }); + describe('with valid data', () => { + beforeEach(() => { + renderResult = render(); + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + const nameInput = renderResult.getByTestId('hostIsolationExceptions-form-name-input'); + userEvent.type(nameInput, 'test name'); + userEvent.type(ipInput, '10.0.0.1'); + }); + it('should show enable buttons when the form is valid', () => { + expect(renderResult.getByTestId('add-exception-cancel-button')).not.toHaveAttribute( + 'disabled' + ); + expect(renderResult.getByTestId('add-exception-confirm-button')).not.toHaveAttribute( + 'disabled' + ); + }); + it('should submit the entry data when submit is pressed with valid data', async () => { + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).not.toHaveAttribute('disabled'); + const waiter = waitForAction('hostIsolationExceptionsCreateEntry'); + userEvent.click(confirmButton); + await waiter; + }); + it('should disable the submit button when an operation is in progress', () => { + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).toHaveAttribute('disabled'); + }); + it('should show a toast and close the flyout when the operation is finished', () => { + mockedContext.history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadedResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + expect(mockedContext.history.location.search).toBe(''); + }); + it('should show an error toast operation fails and enable the submit button', () => { + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'FailedResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalled(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).not.toHaveAttribute('disabled'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx new file mode 100644 index 000000000000..5502a1b8ea2b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import { Loader } from '../../../../../common/components/loader'; +import { useToasts } from '../../../../../common/lib/kibana'; +import { + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, +} from '../../../../state/async_resource_state'; +import { HostIsolationExceptionsPageAction } from '../../store/action'; +import { createEmptyHostIsolationException } from '../../utils'; +import { + useHostIsolationExceptionsNavigateCallback, + useHostIsolationExceptionsSelector, +} from '../hooks'; +import { HostIsolationExceptionsForm } from './form'; + +export const HostIsolationExceptionsFormFlyout: React.FC<{}> = memo(() => { + const dispatch = useDispatch>(); + const toasts = useToasts(); + + const creationInProgress = useHostIsolationExceptionsSelector((state) => + isLoadingResourceState(state.form.status) + ); + const creationSuccessful = useHostIsolationExceptionsSelector((state) => + isLoadedResourceState(state.form.status) + ); + const creationFailure = useHostIsolationExceptionsSelector((state) => + isFailedResourceState(state.form.status) + ); + + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); + + const [formHasError, setFormHasError] = useState(true); + const [exception, setException] = useState(undefined); + + const onCancel = useCallback( + () => + navigateCallback({ + show: undefined, + id: undefined, + }), + [navigateCallback] + ); + + useEffect(() => { + setException(createEmptyHostIsolationException()); + }, []); + + useEffect(() => { + if (creationSuccessful) { + onCancel(); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'UninitialisedResourceState', + }, + }); + toasts.addSuccess( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.creationSuccessToastTitle', + { + defaultMessage: '"{name}" has been added to the host isolation exceptions list.', + values: { name: exception?.name }, + } + ) + ); + } + }, [creationSuccessful, onCancel, dispatch, toasts, exception?.name]); + + useEffect(() => { + if (creationFailure) { + toasts.addDanger( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.creationFailureToastTitle', + { + defaultMessage: 'There was an error creating the exception', + } + ) + ); + } + }, [dispatch, toasts, creationFailure]); + + const handleOnCancel = useCallback(() => { + if (creationInProgress) return; + onCancel(); + }, [creationInProgress, onCancel]); + + const handleOnSubmit = useCallback(() => { + dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: exception, + }); + }, [dispatch, exception]); + + const confirmButtonMemo = useMemo( + () => ( + + + + ), + [formHasError, creationInProgress, handleOnSubmit] + ); + + return exception ? ( + + + +

+ +

+
+
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ) : ( + + ); +}); + +HostIsolationExceptionsFormFlyout.displayName = 'HostIsolationExceptionsFormFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts new file mode 100644 index 000000000000..df179c7a2221 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.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 { i18n } from '@kbn/i18n'; + +export const NAME_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.placeholder', + { + defaultMessage: 'New IP', + } +); + +export const NAME_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.label', + { + defaultMessage: 'Name your host isolation exceptions', + } +); + +export const NAME_ERROR = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.error', + { + defaultMessage: "The name can't be empty", + } +); + +export const DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.description.placeholder', + { + defaultMessage: 'Describe your Host Isolation Exception', + } +); + +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.description.label', + { + defaultMessage: 'Description', + } +); + +export const IP_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.placeholder', + { + defaultMessage: 'Ex 0.0.0.0/24', + } +); + +export const IP_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.label', + { + defaultMessage: 'Enter IP Address', + } +); + +export const IP_ERROR = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.error', + { + defaultMessage: 'The ip is invalid. Only IPv4 with optional CIDR is supported', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index 53b8bc33c252..ac472fdae4d7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -5,16 +5,18 @@ * 2.0. */ -import React from 'react'; import { act } from '@testing-library/react'; -import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; -import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { isFailedResourceState, isLoadedResourceState } from '../../../state'; import { getHostIsolationExceptionItems } from '../service'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; jest.mock('../service'); + const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; describe('When on the host isolation exceptions page', () => { @@ -103,5 +105,17 @@ describe('When on the host isolation exceptions page', () => { ).toEqual(' Server is too far away'); }); }); + it('should show the create flyout when the add button is pressed', () => { + render(); + act(() => { + userEvent.click(renderResult.getByTestId('hostIsolationExceptionsListAddButton')); + }); + expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); + }); + it('should show the create flyout when the show location is create', () => { + history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); + render(); + expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index 53fb74d5bd8f..cfb0121396e2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -8,7 +8,7 @@ import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { i18n } from '@kbn/i18n'; import React, { Dispatch, useCallback } from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; @@ -32,6 +32,7 @@ import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/a import { HostIsolationExceptionsEmptyState } from './components/empty'; import { HostIsolationExceptionsPageAction } from '../store/action'; import { HostIsolationExceptionDeleteModal } from './components/delete_modal'; +import { HostIsolationExceptionsFormFlyout } from './components/form_flyout'; type HostIsolationExceptionPaginatedContent = PaginatedContentProps< Immutable, @@ -54,6 +55,8 @@ export const HostIsolationExceptionsList = () => { const dispatch = useDispatch>(); const itemToDelete = useHostIsolationExceptionsSelector(getItemToDelete); + const showFlyout = !!location.show; + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); const handleOnSearch = useCallback( @@ -92,6 +95,15 @@ export const HostIsolationExceptionsList = () => { [navigateCallback] ); + const handleAddButtonClick = useCallback( + () => + navigateCallback({ + show: 'create', + id: undefined, + }), + [navigateCallback] + ); + return ( { defaultMessage="Host Isolation Exceptions" /> } - actions={[]} + actions={ + + + + } > + {showFlyout && } + { pagination={pagination} contentClassName="host-isolation-exceptions-container" data-test-subj="hostIsolationExceptionsContent" - noItemsMessage={} + noItemsMessage={} /> ); From 7c5c566696fa3837b48d6147ae61dd97526f1a1d Mon Sep 17 00:00:00 2001 From: Orhan Toy Date: Mon, 11 Oct 2021 15:52:15 +0200 Subject: [PATCH 19/19] [Enterprise Search] Fix typo (#114462) Fixing a small typo (`recieve` -> `receive`) I noticed in comments. --- .../crawler_status_indicator/crawler_status_indicator.test.tsx | 2 +- .../applications/shared/flash_messages/handle_api_errors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx index 9d585789d8e5..c46c360934d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx @@ -37,7 +37,7 @@ describe('CrawlerStatusIndicator', () => { describe('when status is not a valid status', () => { it('is disabled', () => { // this tests a codepath that should be impossible to reach, status should always be a CrawlerStatus - // but we use a switch statement and need to test the default case for this to recieve 100% coverage + // but we use a switch statement and need to test the default case for this to receive 100% coverage setMockValues({ ...MOCK_VALUES, mostRecentCrawlRequestStatus: null, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts index 4fe8ad1cb851..abaa67e06f60 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts @@ -14,7 +14,7 @@ import { IFlashMessage } from './types'; /** * The API errors we are handling can come from one of two ways: - * - When our http calls recieve a response containing an error code, such as a 404 or 500 + * - When our http calls receive a response containing an error code, such as a 404 or 500 * - Our own JS while handling a successful response * * In the first case, if it is a purposeful error (like a 404) we will receive an