diff --git a/docs/developer/architecture/code-exploration.asciidoc b/docs/developer/architecture/code-exploration.asciidoc index bed54277c82b4a..2f67ae002c916c 100644 --- a/docs/developer/architecture/code-exploration.asciidoc +++ b/docs/developer/architecture/code-exploration.asciidoc @@ -11,7 +11,7 @@ NOTE: //// [[code-exploration]] -=== Exploring Kibana code +== Exploring Kibana code The goals of our folder heirarchy are: @@ -28,10 +28,10 @@ To that aim, we strive to: [discrete] [[kibana-services-applications]] -==== Services and Applications +=== Services and Applications [discrete] -===== src/plugins +==== src/plugins - {kib-repo}blob/{branch}/src/plugins/advanced_settings[advancedSettings] @@ -283,7 +283,7 @@ WARNING: Missing README. [discrete] -===== x-pack/plugins +==== x-pack/plugins - {kib-repo}blob/{branch}/x-pack/plugins/actions/README.md[actions] diff --git a/docs/drilldowns/explore-underlying-data.asciidoc b/docs/drilldowns/explore-underlying-data.asciidoc new file mode 100644 index 00000000000000..e0f940f73e96e9 --- /dev/null +++ b/docs/drilldowns/explore-underlying-data.asciidoc @@ -0,0 +1,41 @@ +[[explore-underlying-data]] +== Explore the underlying data for a visualization + +++++ +Explore the underlying data +++++ + +Dashboard panels have an *Explore underlying data* action that navigates you to *Discover*, +where you can narrow your documents to the ones you'll most likely use in a visualization. +This action is available for visualizations backed by a single index pattern. + +You can access *Explore underlying data* in two ways: from the panel context +menu or from the menu that appears when you interact with the chart. + +[float] +[[explore-data-from-panel-context-menu]] +=== Explore data from panel context menu + +The *Explore underlying data* action in the panel menu navigates you to Discover, +carrying over the index pattern, filters, query, and time range for the visualization. + +[role="screenshot"] +image::images/explore_data_context_menu.png[Explore underlying data from panel context menu] + +[float] +[[explore-data-from-chart]] +=== Explore data from chart action + +Initiating *Explore underlying data* from the chart also navigates to Discover, +carrying over the current context for the visualization. In addition, this action +applies the filters and time range created by the events that triggered the action. + +[role="screenshot"] +image::images/explore_data_in_chart.png[Explore underlying data from chart] + +You can disable this action by adding the following line to your `kibana.yml` config. + +["source","yml"] +----------- +xpack.discoverEnhanced.actions.exploreDataInChart.enabled: false +----------- diff --git a/docs/drilldowns/images/explore_data_context_menu.png b/docs/drilldowns/images/explore_data_context_menu.png new file mode 100644 index 00000000000000..5742991030c892 Binary files /dev/null and b/docs/drilldowns/images/explore_data_context_menu.png differ diff --git a/docs/drilldowns/images/explore_data_in_chart.png b/docs/drilldowns/images/explore_data_in_chart.png new file mode 100644 index 00000000000000..05d4f5fac9b2fa Binary files /dev/null and b/docs/drilldowns/images/explore_data_in_chart.png differ diff --git a/docs/maps/heatmap-layer.asciidoc b/docs/maps/heatmap-layer.asciidoc index 7149bc56231694..9dc2781db44a36 100644 --- a/docs/maps/heatmap-layer.asciidoc +++ b/docs/maps/heatmap-layer.asciidoc @@ -7,8 +7,8 @@ Heat map layers cluster point data to show locations with higher densities. [role="screenshot"] image::maps/images/heatmap_layer.png[] -To add a heat map layer to your map, click *Add layer*, then select the *Heat map* layer. -The index must contain at least one field mapped as {ref}/geo-point.html[geo_point]. +To add a heat map layer to your map, click *Add layer*, then select *Heat map*. +The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. NOTE: Only count, sum, unique count metric aggregations are available with the grid aggregation source and heat map layers. Average, min, and max are turned off because the heat map will blend nearby values. diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index 872ed1cdedb7e0..6b2dc8077bc300 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -47,7 +47,7 @@ Grid aggregation layers use {ref}/search-aggregations-bucket-geotilegrid-aggrega Symbolize grid aggregation metrics as: *Clusters*:: Creates a <> with a cluster symbol for each gridded cell. -The cluster location is the weighted centroid for all geo-points in the gridded cell. +The cluster location is the weighted centroid for all documents in the gridded cell. *Grid rectangles*:: Creates a <> with a bounding box polygon for each gridded cell. @@ -60,7 +60,7 @@ To enable a grid aggregation layer: To enable a blended layer that dynamically shows clusters or documents: . Click *Add layer*, then select the *Documents* layer. -. Configure *Index pattern* and the *Geospatial field*. To enable clustering, the *Geospatial field* must be set to a field mapped as {ref}/geo-point.html[geo_point]. +. Configure *Index pattern* and the *Geospatial field*. . In *Scaling*, select *Show clusters when results exceed 10000*. diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index 09a4dc61cae284..e0d43a571a3310 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -68,40 +68,17 @@ The first layer you'll add is a choropleth layer to shade world countries by web log traffic. Darker shades symbolize countries with more web log traffic, and lighter shades symbolize countries with less traffic. -==== Add a vector layer to display world country boundaries - . Click *Add layer*. -. Select the *EMS Boundaries* layer. +. Select *Choropleth*. . From the *Layer* dropdown menu, select *World Countries*. +. Under *Statistics source*, set *Index pattern* to *kibana_sample_data_logs*. +. Set *Join field* to *geo.src*. . Click the *Add layer* button. . Set *Name* to `Total Requests by Country`. . Set *Opacity* to 50%. . Click *Add* under *Tooltip fields*. . In the popover, select *ISO 3166-1 alpha-2 code* and *name* and click *Add*. - -===== Join the vector layer with the sample web log index - -You now have a vector layer containing the world countries. -To symbolize countries by web traffic, you'll need to augment the world country features with the count of Elasticsearch weblog documents originating from each country. -To do this, you'll create a <> to link the vector source *World Countries* to -the {es} index `kibana_sample_data_logs` on the shared key iso2 = geo.src. - -. Click plus image:maps/images/gs_plus_icon.png[] next to the *Term Joins* label. -. Click *Join --select--* -. Set *Left field* to *ISO 3166-1 alpha-2 code*. -. Set *Right source* to *kibana_sample_data_logs*. -. Set *Right field* to *geo.src*. -. Click *and use metric count*. -. Set *Custom label* to *web logs count*. - -===== Set the layer style - -All of the world countries are still a single color because the layer is using <>. -To shade the world countries based on which country is sending the most requests, you'll need to use <>. - -. Under *Fill color*, change the selected value from *Solid* to *By value*. -. In the field select input, select *web logs count*. -. Select the grey color ramp. +. Under *Fill color*, select the grey color ramp. . Under *Border color*, change the selected color to *white*. . Click *Save & close*. + @@ -127,7 +104,7 @@ This layer displays web log documents as points. The layer is only visible when users zoom in the map past zoom level 9. . Click *Add layer*. -. Click the *Documents* layer. +. Select *Documents*. . Set *Index pattern* to *kibana_sample_data_logs*. . Click the *Add layer* button. . Set *Name* to `Actual Requests`. @@ -161,7 +138,7 @@ image::maps/images/grid_metrics_both.png[] ===== Add the layer . Click *Add layer*. -. Click the *Clusters and grids* layer. +. Select *Clusters and grids*. . Set *Index pattern* to *kibana_sample_data_logs*. . Click the *Add layer* button. . Set *Name* to `Total Requests and Bytes`. diff --git a/docs/maps/tile-layer.asciidoc b/docs/maps/tile-layer.asciidoc index 6da8dbad0a66d3..2a60504c3c7908 100644 --- a/docs/maps/tile-layer.asciidoc +++ b/docs/maps/tile-layer.asciidoc @@ -7,7 +7,7 @@ Tile layers display image tiles served from a tile server. [role="screenshot"] image::maps/images/tile_layer.png[] -To add a tile layer to your map, click *Add layer*, then select one of the following layers: +To add a tile layer to your map, click *Add layer*, then select one of the following: *Configured Tile Map Service*:: Tile map service configured in kibana.yml. See map.tilemap.url in <> for details. @@ -16,4 +16,6 @@ See map.tilemap.url in <> for details. *Tile Map Service*:: Tile map service configured in interface. +*Vector tiles*:: Data service implementing the Mapbox vector tile specification. + *Web Map Service*:: Maps from OGC Standard WMS. diff --git a/docs/maps/vector-layer.asciidoc b/docs/maps/vector-layer.asciidoc index d6a5931659a400..494bd915b7f56f 100644 --- a/docs/maps/vector-layer.asciidoc +++ b/docs/maps/vector-layer.asciidoc @@ -7,15 +7,14 @@ Vector layers display points, lines, and polygons. [role="screenshot"] image::maps/images/vector_layer.png[] -To add a vector layer to your map, click *Add layer*, then select one of the following layers: +To add a vector layer to your map, click *Add layer*, then select one of the following: -*Clusters and grids*:: Geospatial data grouped in grids with metrics for each gridded cell. -The index must contain at least one field mapped as {ref}/geo-point.html[geo_point]. +*Choropleth*:: Shaded areas to compare statistics across boundaries. -*Configured GeoJSON*:: Vector data from hosted GeoJSON configured in kibana.yml. -See map.regionmap.* in <> for details. +*Clusters and grids*:: Geospatial data grouped in grids with metrics for each gridded cell. +The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. -*Documents*:: Vector data from a Kibana index pattern. +*Documents*:: Points, lines, and polyons from Elasticsearch. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. NOTE: Document results are limited to the `index.max_result_window` index setting, which defaults to 10000. diff --git a/docs/user/dashboard.asciidoc b/docs/user/dashboard.asciidoc index a812d4e3bdd2de..b812af7e981bf3 100644 --- a/docs/user/dashboard.asciidoc +++ b/docs/user/dashboard.asciidoc @@ -160,6 +160,7 @@ When you're finished adding and arranging the panels, save the dashboard. . Enter the dashboard *Title* and optional *Description*, then *Save* the dashboard. include::{kib-repo-dir}/drilldowns/drilldowns.asciidoc[] +include::{kib-repo-dir}/drilldowns/explore-underlying-data.asciidoc[] [[sharing-dashboards]] == Share the dashboard diff --git a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts index f3f8817299bb17..f0f799862e24e7 100644 --- a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts +++ b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts @@ -54,7 +54,7 @@ NOTE: //// [[code-exploration]] -=== Exploring Kibana code +== Exploring Kibana code The goals of our folder heirarchy are: @@ -71,14 +71,14 @@ To that aim, we strive to: [discrete] [[kibana-services-applications]] -==== Services and Applications +=== Services and Applications [discrete] -===== src/plugins +==== src/plugins ${Array.from(printPlugins(ossPlugins)).join('\n')} [discrete] -===== x-pack/plugins +==== x-pack/plugins ${Array.from(printPlugins(xpackPlugins)).join('\n')} `; } diff --git a/x-pack/plugins/graph/server/lib/license_state.ts b/x-pack/plugins/graph/server/lib/license_state.ts index d86cb5380a2e15..8d64c826d8fa1b 100644 --- a/x-pack/plugins/graph/server/lib/license_state.ts +++ b/x-pack/plugins/graph/server/lib/license_state.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import { map } from 'rxjs/operators'; import { Observable, Subscription } from 'rxjs'; +import { LicensingPluginStart } from '../../../licensing/server'; import { ILicense } from '../../../licensing/common/types'; import { checkLicense, GraphLicenseInformation } from '../../common/check_license'; @@ -14,6 +15,7 @@ export class LicenseState { private licenseInformation: GraphLicenseInformation = checkLicense(undefined); private subscription: Subscription | null = null; private observable: Observable | null = null; + private _notifyUsage: LicensingPluginStart['featureUsage']['notifyUsage'] | null = null; private updateInformation(licenseInformation: GraphLicenseInformation) { this.licenseInformation = licenseInformation; @@ -24,6 +26,17 @@ export class LicenseState { this.subscription = this.observable.subscribe(this.updateInformation.bind(this)); } + public setNotifyUsage(notifyUsage: LicensingPluginStart['featureUsage']['notifyUsage']) { + this._notifyUsage = notifyUsage; + } + + // 'Graph' is the only allowed feature here at the moment, if this gets extended in the future, add to the union type + public notifyUsage(featureName: 'Graph') { + if (this._notifyUsage) { + this._notifyUsage(featureName); + } + } + public stop() { if (this.subscription) { this.subscription.unsubscribe(); diff --git a/x-pack/plugins/graph/server/plugin.ts b/x-pack/plugins/graph/server/plugin.ts index 141d5d0ea8db4c..b2b825fa4683be 100644 --- a/x-pack/plugins/graph/server/plugin.ts +++ b/x-pack/plugins/graph/server/plugin.ts @@ -5,8 +5,8 @@ */ import { i18n } from '@kbn/i18n'; -import { Plugin, CoreSetup } from 'src/core/server'; -import { LicensingPluginSetup } from '../../licensing/server'; +import { Plugin, CoreSetup, CoreStart } from 'src/core/server'; +import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { LicenseState } from './lib/license_state'; import { registerSearchRoute } from './routes/search'; import { registerExploreRoute } from './routes/explore'; @@ -34,6 +34,7 @@ export class GraphPlugin implements Plugin { licenseState.start(licensing.license$); this.licenseState = licenseState; core.savedObjects.registerType(graphWorkspace); + licensing.featureUsage.register('Graph', 'platinum'); if (home) { registerSampleData(home.sampleData, licenseState); @@ -79,7 +80,10 @@ export class GraphPlugin implements Plugin { registerExploreRoute({ licenseState, router }); } - public start() {} + public start(core: CoreStart, { licensing }: { licensing: LicensingPluginStart }) { + this.licenseState!.setNotifyUsage(licensing.featureUsage.notifyUsage); + } + public stop() { if (this.licenseState) { this.licenseState.stop(); diff --git a/x-pack/plugins/graph/server/routes/explore.ts b/x-pack/plugins/graph/server/routes/explore.ts index b0b8cf14ff6997..c436fbd1c79afb 100644 --- a/x-pack/plugins/graph/server/routes/explore.ts +++ b/x-pack/plugins/graph/server/routes/explore.ts @@ -42,6 +42,7 @@ export function registerExploreRoute({ response ) => { verifyApiAccess(licenseState); + licenseState.notifyUsage('Graph'); try { return response.ok({ body: { diff --git a/x-pack/plugins/graph/server/routes/search.ts b/x-pack/plugins/graph/server/routes/search.ts index 645e6b520013fd..e1d430eeb311a5 100644 --- a/x-pack/plugins/graph/server/routes/search.ts +++ b/x-pack/plugins/graph/server/routes/search.ts @@ -42,6 +42,7 @@ export function registerSearchRoute({ response ) => { verifyApiAccess(licenseState); + licenseState.notifyUsage('Graph'); const includeFrozen = await uiSettings.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); try { return response.ok({ diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index 3b9e60124b0343..2b8756ea0cb46d 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -11,7 +11,8 @@ "kibanaLegacy", "triggers_actions_ui", "alerts", - "actions" + "actions", + "encryptedSavedObjects" ], "optionalPlugins": ["infra", "telemetryCollectionManager", "usageCollection", "home", "cloud"], "server": true, diff --git a/x-pack/plugins/monitoring/public/alerts/lib/security_toasts.tsx b/x-pack/plugins/monitoring/public/alerts/lib/security_toasts.tsx new file mode 100644 index 00000000000000..918c0b5c9b6091 --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/lib/security_toasts.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiLink, EuiCode, EuiText } from '@elastic/eui'; +import { Legacy } from '../../legacy_shims'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; + +export interface AlertingFrameworkHealth { + isSufficientlySecure: boolean; + hasPermanentEncryptionKey: boolean; +} + +const showTlsAndEncryptionError = () => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; + + Legacy.shims.toastNotifications.addWarning({ + title: toMountPoint( + + ), + text: toMountPoint( +
+

+ {i18n.translate('xpack.monitoring.healthCheck.tlsAndEncryptionError', { + defaultMessage: `You must enable Transport Layer Security between Kibana and Elasticsearch + and configure an encryption key in your kibana.yml file to use the Alerting feature.`, + })} +

+ + + {i18n.translate('xpack.monitoring.healthCheck.encryptionErrorAction', { + defaultMessage: 'Learn how.', + })} + +
+ ), + }); +}; + +const showEncryptionError = () => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; + + Legacy.shims.toastNotifications.addWarning( + { + title: toMountPoint( + + ), + text: toMountPoint( +
+ {i18n.translate('xpack.monitoring.healthCheck.encryptionErrorBeforeKey', { + defaultMessage: 'To create an alert, set a value for ', + })} + + {'xpack.encryptedSavedObjects.encryptionKey'} + + {i18n.translate('xpack.monitoring.healthCheck.encryptionErrorAfterKey', { + defaultMessage: ' in your kibana.yml file. ', + })} + + {i18n.translate('xpack.monitoring.healthCheck.encryptionErrorAction', { + defaultMessage: 'Learn how.', + })} + +
+ ), + }, + {} + ); +}; + +const showTlsError = () => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; + + Legacy.shims.toastNotifications.addWarning({ + title: toMountPoint( + + ), + text: toMountPoint( +
+ {i18n.translate('xpack.monitoring.healthCheck.tlsError', { + defaultMessage: + 'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ', + })} + + {i18n.translate('xpack.monitoring.healthCheck.tlsErrorAction', { + defaultMessage: 'Learn how to enable TLS.', + })} + +
+ ), + }); +}; + +export const showSecurityToast = (alertingHealth: AlertingFrameworkHealth) => { + const { isSufficientlySecure, hasPermanentEncryptionKey } = alertingHealth; + if ( + Array.isArray(alertingHealth) || + (!alertingHealth.hasOwnProperty('isSufficientlySecure') && + !alertingHealth.hasOwnProperty('hasPermanentEncryptionKey')) + ) { + return; + } + + if (!isSufficientlySecure && !hasPermanentEncryptionKey) { + showTlsAndEncryptionError(); + } else if (!isSufficientlySecure) { + showTlsError(); + } else if (!hasPermanentEncryptionKey) { + showEncryptionError(); + } +}; diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index f3eadcaf9831b7..5173984dbe868a 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -7,6 +7,7 @@ import { ajaxErrorHandlersProvider } from '../lib/ajax_error_handler'; import { Legacy } from '../legacy_shims'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../common/constants'; +import { showSecurityToast } from '../alerts/lib/security_toasts'; function formatClusters(clusters) { return clusters.map(formatCluster); @@ -66,7 +67,8 @@ export function monitoringClustersProvider($injector) { return getClusters().then((clusters) => { if (clusters.length) { return ensureAlertsEnabled() - .then(() => { + .then(({ data }) => { + showSecurityToast(data); once = true; return clusters; }) diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts new file mode 100644 index 00000000000000..047b14bd37fbcb --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'kibana/server'; +import { EncryptedSavedObjectsPluginSetup } from '../../../../encrypted_saved_objects/server'; + +export interface AlertingFrameworkHealth { + isSufficientlySecure: boolean; + hasPermanentEncryptionKey: boolean; +} + +export interface XPackUsageSecurity { + security?: { + enabled?: boolean; + ssl?: { + http?: { + enabled?: boolean; + }; + }; + }; +} + +export class AlertingSecurity { + public static readonly getSecurityHealth = async ( + context: RequestHandlerContext, + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup + ): Promise => { + const { + security: { + enabled: isSecurityEnabled = false, + ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, + } = {}, + }: XPackUsageSecurity = await context.core.elasticsearch.legacy.client.callAsInternalUser( + 'transport.request', + { + method: 'GET', + path: '/_xpack/usage', + } + ); + + return { + isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), + hasPermanentEncryptionKey: !encryptedSavedObjects.usingEphemeralEncryptionKey, + }; + }; +} diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index a08734ff765bbc..86022a0e863d5f 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -204,6 +204,7 @@ export class Plugin { requireUIRoutes(this.monitoringCore, { router, licenseService: this.licenseService, + encryptedSavedObjects: plugins.encryptedSavedObjects, }); initInfraSource(config, plugins.infra); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts index b7cc088d2716c8..64beb5c58dc07b 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts @@ -10,18 +10,36 @@ import { AlertsFactory } from '../../../../alerts'; import { RouteDependencies } from '../../../../types'; import { ALERT_ACTION_TYPE_LOG } from '../../../../../common/constants'; import { ActionResult } from '../../../../../../actions/common'; -// import { fetchDefaultEmailAddress } from '../../../../lib/alerts/fetch_default_email_address'; +import { AlertingSecurity } from '../../../../lib/elasticsearch/verify_alerting_security'; const DEFAULT_SERVER_LOG_NAME = 'Monitoring: Write to Kibana log'; -export function enableAlertsRoute(server: any, npRoute: RouteDependencies) { +export function enableAlertsRoute(_server: unknown, npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alerts/enable', validate: false, }, - async (context, request, response) => { + async (context, _request, response) => { try { + const alerts = AlertsFactory.getAll().filter((a) => a.isEnabled(npRoute.licenseService)); + + if (alerts.length) { + const { + isSufficientlySecure, + hasPermanentEncryptionKey, + } = await AlertingSecurity.getSecurityHealth(context, npRoute.encryptedSavedObjects); + + if (!isSufficientlySecure || !hasPermanentEncryptionKey) { + return response.ok({ + body: { + isSufficientlySecure, + hasPermanentEncryptionKey, + }, + }); + } + } + const alertsClient = context.alerting?.getAlertsClient(); const actionsClient = context.actions?.getActionsClient(); const types = context.actions?.listTypes(); @@ -57,7 +75,6 @@ export function enableAlertsRoute(server: any, npRoute: RouteDependencies) { }, ]; - const alerts = AlertsFactory.getAll().filter((a) => a.isEnabled(npRoute.licenseService)); const createdAlerts = await Promise.all( alerts.map( async (alert) => await alert.createIfDoesNotExist(alertsClient, actionsClient, actions) diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 0c346c8082475b..1e7a5acb33644d 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -16,6 +16,7 @@ import { import { InfraPluginSetup } from '../../infra/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { PluginSetupContract as FeaturesPluginSetupContract } from '../../features/server'; +import { EncryptedSavedObjectsPluginSetup } from '../../encrypted_saved_objects/server'; export interface MonitoringLicenseService { refresh: () => Promise; @@ -36,6 +37,7 @@ export interface LegacyAPI { } export interface PluginsSetup { + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; telemetryCollectionManager?: TelemetryCollectionManagerPluginSetup; usageCollection?: UsageCollectionSetup; licensing: LicensingPluginSetup; @@ -56,6 +58,7 @@ export interface MonitoringCoreConfig { export interface RouteDependencies { router: IRouter; licenseService: MonitoringLicenseService; + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; } export interface MonitoringCore { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx index 7e4cbe34f9a64e..9486008e708ea1 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx @@ -16,26 +16,12 @@ addDecorator((storyFn) => ( )); storiesOf('Components|Exceptions|BuilderButtonOptions', module) - .add('init button', () => { - return ( - - ); - }) .add('and/or buttons', () => { return ( { isAndDisabled={false} isOrDisabled={false} showNestedButton={false} - displayInitButton={false} onOrClicked={jest.fn()} onAndClicked={jest.fn()} onNestedClicked={jest.fn()} @@ -31,44 +30,6 @@ describe('BuilderButtonOptions', () => { expect(wrapper.find('[data-test-subj="exceptionsNestedButton"] button')).toHaveLength(0); }); - test('it renders "add exception" button if "displayInitButton" is true', () => { - const wrapper = mount( - - ); - - expect(wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button')).toHaveLength( - 1 - ); - }); - - test('it invokes "onAddExceptionClicked" when "add exception" button is clicked', () => { - const onOrClicked = jest.fn(); - - const wrapper = mount( - - ); - - wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button').simulate('click'); - - expect(onOrClicked).toHaveBeenCalledTimes(1); - }); - test('it invokes "onOrClicked" when "or" button is clicked', () => { const onOrClicked = jest.fn(); @@ -77,7 +38,6 @@ describe('BuilderButtonOptions', () => { isAndDisabled={false} isOrDisabled={false} showNestedButton={false} - displayInitButton={false} onOrClicked={onOrClicked} onAndClicked={jest.fn()} onNestedClicked={jest.fn()} @@ -97,7 +57,6 @@ describe('BuilderButtonOptions', () => { isAndDisabled={false} isOrDisabled={false} showNestedButton={false} - displayInitButton={false} onOrClicked={jest.fn()} onAndClicked={onAndClicked} onNestedClicked={jest.fn()} @@ -113,7 +72,6 @@ describe('BuilderButtonOptions', () => { const wrapper = mount( { const wrapper = mount( { isAndDisabled={false} isOrDisabled={false} showNestedButton - displayInitButton={false} onOrClicked={jest.fn()} onAndClicked={jest.fn()} onNestedClicked={onNestedClicked} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx index ff1556bcc4d25e..eb224b82d756ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx @@ -16,7 +16,6 @@ const MyEuiButton = styled(EuiButton)` interface BuilderButtonOptionsProps { isOrDisabled: boolean; isAndDisabled: boolean; - displayInitButton: boolean; showNestedButton: boolean; onAndClicked: () => void; onOrClicked: () => void; @@ -26,64 +25,47 @@ interface BuilderButtonOptionsProps { export const BuilderButtonOptions: React.FC = ({ isOrDisabled = false, isAndDisabled = false, - displayInitButton, showNestedButton = false, onAndClicked, onOrClicked, onNestedClicked, }) => ( - {displayInitButton ? ( + + + {i18n.AND} + + + + + {i18n.OR} + + + {showNestedButton && ( - {i18n.ADD_EXCEPTION_TITLE} + {i18n.ADD_NESTED_DESCRIPTION} - ) : ( - <> - - - {i18n.AND} - - - - - {i18n.OR} - - - {showNestedButton && ( - - - {i18n.ADD_NESTED_DESCRIPTION} - - - )} - )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx new file mode 100644 index 00000000000000..9ca7a371ce81b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionListItemComponent } from './builder_exception_item'; +import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { + getEntryMatchMock, + getEntryMatchAnyMock, +} from '../../../../../../lists/common/schemas/types/entries.mock'; + +describe('ExceptionListItemComponent', () => { + describe('and badge logic', () => { + test('it renders "and" badge with extra top padding for the first exception item when "andLogicIncluded" is "true"', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryFirstRowAndBadge"]').exists() + ).toBeTruthy(); + }); + + test('it renders "and" badge when more than one exception item entry exists and it is not the first exception item', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionItemEntryAndBadge"]').exists()).toBeTruthy(); + }); + + test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock()]; + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryInvisibleAndBadge"]').exists() + ).toBeTruthy(); + }); + + test('it renders no "and" badge when "andLogicIncluded" is "false"', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock()]; + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryInvisibleAndBadge"]').exists() + ).toBeFalsy(); + expect(wrapper.find('[data-test-subj="exceptionItemEntryAndBadge"]').exists()).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="exceptionItemEntryFirstRowAndBadge"]').exists() + ).toBeFalsy(); + }); + }); + + describe('delete button logic', () => { + test('it renders delete button disabled when it is only entry left in builder', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock()]; + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryDeleteButton"] button').props().disabled + ).toBeTruthy(); + }); + + test('it does not render delete button disabled when it is not the only entry left in builder', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock()]; + + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryDeleteButton"] button').props().disabled + ).toBeFalsy(); + }); + + test('it does not render delete button disabled when "exceptionItemIndex" is not "0"', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock()]; + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryDeleteButton"] button').props().disabled + ).toBeFalsy(); + }); + + test('it does not render delete button disabled when more than one entry exists', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="exceptionItemEntryDeleteButton"] button').at(0).props() + .disabled + ).toBeFalsy(); + }); + + test('it invokes "onChangeExceptionItem" when delete button clicked', () => { + const mockOnDeleteExceptionItem = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()]; + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="exceptionItemEntryDeleteButton"] button') + .at(0) + .simulate('click'); + + expect(mockOnDeleteExceptionItem).toHaveBeenCalledWith( + { + ...exceptionItem, + entries: [getEntryMatchAnyMock()], + }, + 0 + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx new file mode 100644 index 00000000000000..8e57e83d0e7e42 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { AndOrBadge } from '../../and_or_badge'; +import { EntryItemComponent } from './entry_item'; +import { getFormattedBuilderEntries } from '../helpers'; +import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; + +const MyInvisibleAndBadge = styled(EuiFlexItem)` + visibility: hidden; +`; + +const MyFirstRowContainer = styled(EuiFlexItem)` + padding-top: 20px; +`; + +interface ExceptionListItemProps { + exceptionItem: ExceptionsBuilderExceptionItem; + exceptionId: string; + exceptionItemIndex: number; + isLoading: boolean; + indexPattern: IIndexPattern; + andLogicIncluded: boolean; + isOnlyItem: boolean; + onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; + onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; +} + +export const ExceptionListItemComponent = React.memo( + ({ + exceptionItem, + exceptionId, + exceptionItemIndex, + indexPattern, + isLoading, + isOnlyItem, + andLogicIncluded, + onDeleteExceptionItem, + onChangeExceptionItem, + }) => { + const handleEntryChange = useCallback( + (entry: BuilderEntry, entryIndex: number): void => { + const updatedEntries: BuilderEntry[] = [ + ...exceptionItem.entries.slice(0, entryIndex), + { ...entry }, + ...exceptionItem.entries.slice(entryIndex + 1), + ]; + const updatedExceptionItem: ExceptionsBuilderExceptionItem = { + ...exceptionItem, + entries: updatedEntries, + }; + onChangeExceptionItem(updatedExceptionItem, exceptionItemIndex); + }, + [onChangeExceptionItem, exceptionItem, exceptionItemIndex] + ); + + const handleDeleteEntry = useCallback( + (entryIndex: number): void => { + const updatedEntries: BuilderEntry[] = [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ]; + const updatedExceptionItem: ExceptionsBuilderExceptionItem = { + ...exceptionItem, + entries: updatedEntries, + }; + + onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex); + }, + [exceptionItem, onDeleteExceptionItem, exceptionItemIndex] + ); + + const entries = useMemo( + (): FormattedBuilderEntry[] => + indexPattern != null ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) : [], + [indexPattern, exceptionItem] + ); + + const andBadge = useMemo((): JSX.Element => { + const badge = ; + if (entries.length > 1 && exceptionItemIndex === 0) { + return ( + + {badge} + + ); + } else if (entries.length > 1) { + return ( + + {badge} + + ); + } else { + return ( + + {badge} + + ); + } + }, [entries.length, exceptionItemIndex]); + + const getDeleteButton = useCallback( + (index: number): JSX.Element => { + const button = ( + handleDeleteEntry(index)} + isDisabled={isOnlyItem && entries.length === 1 && exceptionItemIndex === 0} + aria-label="entryDeleteButton" + className="exceptionItemEntryDeleteButton" + data-test-subj="exceptionItemEntryDeleteButton" + /> + ); + if (index === 0 && exceptionItemIndex === 0) { + return {button}; + } else { + return {button}; + } + }, + [entries.length, exceptionItemIndex, handleDeleteEntry, isOnlyItem] + ); + + return ( + + {andLogicIncluded && andBadge} + + + {entries.map((item, index) => ( + + + + + + {getDeleteButton(index)} + + + ))} + + + + ); + } +); + +ExceptionListItemComponent.displayName = 'ExceptionListItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx deleted file mode 100644 index 5e53ce3ba65783..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useMemo } from 'react'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import styled from 'styled-components'; - -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { AndOrBadge } from '../../and_or_badge'; -import { EntryItemComponent } from './entry_item'; -import { getFormattedBuilderEntries } from '../helpers'; -import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; - -const MyInvisibleAndBadge = styled(EuiFlexItem)` - visibility: hidden; -`; - -const MyFirstRowContainer = styled(EuiFlexItem)` - padding-top: 20px; -`; - -interface ExceptionListItemProps { - exceptionItem: ExceptionsBuilderExceptionItem; - exceptionId: string; - exceptionItemIndex: number; - isLoading: boolean; - indexPattern: IIndexPattern; - andLogicIncluded: boolean; - onCheckAndLogic: (item: ExceptionsBuilderExceptionItem[]) => void; - onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; - onExceptionItemChange: (item: ExceptionsBuilderExceptionItem, index: number) => void; -} - -export const ExceptionListItemComponent = React.memo( - ({ - exceptionItem, - exceptionId, - exceptionItemIndex, - indexPattern, - isLoading, - andLogicIncluded, - onCheckAndLogic, - onDeleteExceptionItem, - onExceptionItemChange, - }) => { - const handleEntryChange = (entry: BuilderEntry, entryIndex: number): void => { - const updatedEntries: BuilderEntry[] = [ - ...exceptionItem.entries.slice(0, entryIndex), - { ...entry }, - ...exceptionItem.entries.slice(entryIndex + 1), - ]; - const updatedExceptionItem: ExceptionsBuilderExceptionItem = { - ...exceptionItem, - entries: updatedEntries, - }; - onExceptionItemChange(updatedExceptionItem, exceptionItemIndex); - }; - - const handleDeleteEntry = (entryIndex: number): void => { - const updatedEntries: BuilderEntry[] = [ - ...exceptionItem.entries.slice(0, entryIndex), - ...exceptionItem.entries.slice(entryIndex + 1), - ]; - const updatedExceptionItem: ExceptionsBuilderExceptionItem = { - ...exceptionItem, - entries: updatedEntries, - }; - - onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex); - }; - - const entries = useMemo((): FormattedBuilderEntry[] => { - onCheckAndLogic([exceptionItem]); - return indexPattern != null - ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) - : []; - }, [indexPattern, exceptionItem, onCheckAndLogic]); - - const andBadge = useMemo((): JSX.Element => { - const badge = ; - if (entries.length > 1 && exceptionItemIndex === 0) { - return {badge}; - } else if (entries.length > 1) { - return {badge}; - } else { - return {badge}; - } - }, [entries.length, exceptionItemIndex]); - - const getDeleteButton = (index: number): JSX.Element => { - const button = ( - handleDeleteEntry(index)} - aria-label="entryDeleteButton" - className="exceptionItemEntryDeleteButton" - data-test-subj="exceptionItemEntryDeleteButton" - /> - ); - if (index === 0 && exceptionItemIndex === 0) { - return {button}; - } else { - return {button}; - } - }; - - return ( - - {andLogicIncluded && andBadge} - - - {entries.map((item, index) => ( - - - - - - {getDeleteButton(index)} - - - ))} - - - - ); - } -); - -ExceptionListItemComponent.displayName = 'ExceptionListItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 6bff33afaf70ce..08e5b49073ecf3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -3,11 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { ExceptionListItemComponent } from './exception_item'; +import { ExceptionListItemComponent } from './builder_exception_item'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules/fetch_index_patterns'; import { ExceptionListItemSchema, @@ -80,20 +80,9 @@ export const ExceptionBuilder = ({ ); const handleCheckAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => { - setAndLogicIncluded((includesAnd: boolean): boolean => { - if (includesAnd) { - return true; - } else { - return items.filter(({ entries }) => entries.length > 1).length > 0; - } - }); + setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0); }; - // Bubble up changes to parent - useEffect(() => { - onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete }); - }, [onChange, exceptionsToDelete, exceptions]); - const handleDeleteExceptionItem = ( item: ExceptionsBuilderExceptionItem, itemIndex: number @@ -164,16 +153,6 @@ export const ExceptionBuilder = ({ setExceptions((existingExceptions) => [...existingExceptions, { ...newException }]); }, [setExceptions, listType, listId, listNamespaceType, ruleName]); - // An exception item can have an empty array for `entries` - const displayInitialAddExceptionButton = useMemo((): boolean => { - return ( - exceptions.length === 0 || - (exceptions.length === 1 && - exceptions[0].entries != null && - exceptions[0].entries.length === 0) - ); - }, [exceptions]); - // Filters index pattern fields by exceptionable fields if list type is endpoint const filterIndexPatterns = useCallback(() => { if (listType === 'endpoint') { @@ -199,6 +178,22 @@ export const ExceptionBuilder = ({ } }; + // Bubble up changes to parent + useEffect(() => { + onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete }); + }, [onChange, exceptionsToDelete, exceptions]); + + useEffect(() => { + if ( + exceptions.length === 0 || + (exceptions.length === 1 && + exceptions[0].entries != null && + exceptions[0].entries.length === 0) + ) { + handleAddNewExceptionItem(); + } + }, [exceptions, handleAddNewExceptionItem]); + return ( {(isLoading || indexPatternLoading) && ( @@ -233,9 +228,9 @@ export const ExceptionBuilder = ({ isLoading={indexPatternLoading} exceptionItemIndex={index} andLogicIncluded={andLogicIncluded} - onCheckAndLogic={handleCheckAndLogic} + isOnlyItem={exceptions.length === 1} onDeleteExceptionItem={handleDeleteExceptionItem} - onExceptionItemChange={handleExceptionItemChange} + onChangeExceptionItem={handleExceptionItemChange} /> @@ -253,7 +248,6 @@ export const ExceptionBuilder = ({