diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2da78ed653a0d..ac389064e2986 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -776,21 +776,20 @@ packages/kbn-yarn-lock-validator @elastic/kibana-operations ### Observability Plugins -# Observability Shared -/x-pack/plugins/observability/public/components/shared/date_picker/ @elastic/uptime +# Observability Shared App +x-pack/plugins/observability_shared @elastic/observability-ui -# Unified Observability - on hold due to team capacity shortage -# For now, if you're changing these pages, get a review from someone who understand the changes -# /x-pack/plugins/observability/public/context @elastic/unified-observability -# /x-pack/test/observability_functional @elastic/unified-observability +# Observability App +x-pack/plugins/observability @elastic/actionable-observability + +# Observability App > Overview page +x-pack/plugins/observability/public/pages/overview @elastic/observability-ui -# Home/Overview/Landing Pages -/x-pack/plugins/observability/public/pages/home @elastic/observability-ui -/x-pack/plugins/observability/public/pages/landing @elastic/observability-ui -/x-pack/plugins/observability/public/pages/overview @elastic/observability-ui +# Observability App > Alert Details +x-pack/packages/observability/alert_details @elastic/actionable-observability -# Actionable Observability -/x-pack/test/observability_functional @elastic/actionable-observability +# Observability App > Functional Tests +x-pack/test/observability_functional @elastic/actionable-observability # Observability robots /.github/workflows/deploy-my-kibana.yml @elastic/observablt-robots diff --git a/fleet_packages.json b/fleet_packages.json index f9734cdd91380..eef982351e455 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -40,6 +40,12 @@ "name": "fleet_server", "version": "1.3.0" }, + { + "name": "profiler_symbolizer", + "version": "8.8.0-preview", + "forceAlignStackVersion": true, + "allowSyncToPrerelease": true + }, { "name": "synthetics", "version": "0.12.1" diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap index 387dbd87bbafe..0e05eed1e99b7 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap @@ -54,6 +54,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -272,6 +277,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -494,6 +504,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -720,6 +735,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -994,6 +1014,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -1223,6 +1248,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts index 46ddd23251217..2f9dd57afa294 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts @@ -47,6 +47,8 @@ export const REMOVED_TYPES: string[] = [ 'csp_rule', // Removed in 8.8 https://github.com/elastic/kibana/pull/151116 'upgrade-assistant-telemetry', + // Removed in 8.8 https://github.com/elastic/kibana/pull/155204 + 'endpoint:user-artifact', ].sort(); export const excludeUnusedTypesQuery: QueryDslQueryContainer = { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts index d49e784f77633..3ee201605f1ac 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts @@ -107,6 +107,11 @@ describe('createInitialState', () => { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts index 808894dab55ef..d5efb1dbc2c97 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts @@ -22,6 +22,7 @@ export type ApmApplicationMetricFields = Partial<{ 'faas.timeout': number; 'faas.coldstart_duration': number; 'faas.duration': number; + 'application.launch.time': number; }>; export type ApmUserAgentFields = Partial<{ @@ -88,6 +89,7 @@ export type ApmFields = Fields<{ 'error.grouping_key': string; 'error.grouping_name': string; 'error.id': string; + 'error.type': string; 'event.ingested': number; 'event.name': string; 'event.outcome': string; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts index eddb7d6c99d18..252590104e7a2 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts @@ -9,8 +9,10 @@ import { Entity } from '../entity'; import { Span } from './span'; import { Transaction } from './transaction'; -import { ApmFields, SpanParams, GeoLocation } from './apm_fields'; +import { ApmFields, SpanParams, GeoLocation, ApmApplicationMetricFields } from './apm_fields'; import { generateLongId } from '../utils/generate_id'; +import { Metricset } from './metricset'; +import { ApmError } from './apm_error'; export interface DeviceInfo { manufacturer: string; @@ -115,6 +117,7 @@ export class MobileDevice extends Entity { return this; } + // FIXME synthtrace shouldn't have side-effects like this. We should use an API like .session() which returns a session startNewSession() { this.fields['session.id'] = generateLongId(); return this; @@ -238,4 +241,21 @@ export class MobileDevice extends Entity { return this.span(spanParameters); } + + appMetrics(metrics: ApmApplicationMetricFields) { + return new Metricset({ + ...this.fields, + 'metricset.name': 'app', + ...metrics, + }); + } + + crash({ message, groupingName }: { message: string; groupingName?: string }) { + return new ApmError({ + ...this.fields, + 'error.type': 'crash', + 'error.exception': [{ message, ...{ type: 'crash' } }], + 'error.grouping_name': groupingName || message, + }); + } } diff --git a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts index 6db2d17b624f9..0ca4abf07bf91 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts @@ -20,6 +20,16 @@ const ENVIRONMENT = getSynthtraceEnvironment(__filename); type DeviceMetadata = DeviceInfo & OSInfo; +const modelIdentifiersWithCrashes = [ + 'SM-G930F', + 'HUAWEI P2-0000', + 'Pixel 3a', + 'LG K10', + 'iPhone11,8', + 'Watch6,8', + 'iPad12,2', +]; + const ANDROID_DEVICES: DeviceMetadata[] = [ { manufacturer: 'Samsung', @@ -354,34 +364,40 @@ const scenario: Scenario = async ({ scenarioOpts, logger }) => { device.startNewSession(); const framework = device.fields['device.manufacturer'] === 'Apple' ? 'iOS' : 'Android Activity'; + const couldCrash = modelIdentifiersWithCrashes.includes( + device.fields['device.model.identifier'] ?? '' + ); + const startTx = device + .transaction('Start View - View Appearing', framework) + .timestamp(timestamp) + .duration(500) + .success() + .children( + device + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + device + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .failure() + .timestamp(timestamp + 400) + ); return [ - device - .transaction('Start View - View Appearing', framework) - .timestamp(timestamp) - .duration(500) - .success() - .children( - device - .span({ - spanName: 'onCreate', - spanType: 'app', - spanSubtype: 'external', - 'service.target.type': 'http', - 'span.destination.service.resource': 'external', - }) - .duration(50) - .success() - .timestamp(timestamp + 20), - device - .httpSpan({ - spanName: 'GET backend:1234', - httpMethod: 'GET', - httpUrl: 'https://backend:1234/api/start', - }) - .duration(800) - .failure() - .timestamp(timestamp + 400) - ), + couldCrash && index % 2 === 0 + ? startTx.errors(device.crash({ message: 'error' }).timestamp(timestamp)) + : startTx, device .transaction('Second View - View Appearing', framework) .timestamp(10000 + timestamp) @@ -418,7 +434,23 @@ const scenario: Scenario = async ({ scenarioOpts, logger }) => { }); }; - return [...androidDevices, ...iOSDevices].map((device) => sessionTransactions(device)); + const appLaunchMetrics = (device: MobileDevice) => { + return clickRate.generator((timestamp, index) => + device + .appMetrics({ + 'application.launch.time': 100 * (index + 1), + }) + .timestamp(timestamp) + ); + }; + + return [ + ...androidDevices.flatMap((device) => [ + sessionTransactions(device), + appLaunchMetrics(device), + ]), + ...iOSDevices.map((device) => sessionTransactions(device)), + ]; }, }; }; diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index dc75ebee9a5eb..29ac5472e37dd 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -419,6 +419,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { dashboardSettings: `${KIBANA_DOCS}advanced-options.html#kibana-dashboard-settings`, indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, kibanaSearchSettings: `${KIBANA_DOCS}advanced-options.html#kibana-search-settings`, + discoverSettings: `${KIBANA_DOCS}advanced-options.html#kibana-discover-settings`, visualizationSettings: `${KIBANA_DOCS}advanced-options.html#kibana-visualization-settings`, timelionSettings: `${KIBANA_DOCS}advanced-options.html#kibana-timelion-settings`, savedObjectsApiList: `${KIBANA_DOCS}saved-objects-api.html#saved-objects-api`, diff --git a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index d803d6ca503b8..c5ba5f59fb384 100644 --- a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -94,7 +94,7 @@ const ALERT_RULE_TAGS = `${ALERT_RULE_NAMESPACE}.tags` as const; // kibana.alert.rule_type_id - rule type id for rule that generated this alert const ALERT_RULE_TYPE_ID = `${ALERT_RULE_NAMESPACE}.rule_type_id` as const; -// kibana.alert.url - allow our user to go back to the details url in kibana +// kibana.alert.url - url which will redirect users to a page related to the given alert const ALERT_URL = `${ALERT_NAMESPACE}.url` as const; // kibana.alert.rule.uuid - rule ID for rule that generated this alert diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 6c7e00b1822b7..05124ffad8140 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -81,8 +81,7 @@ describe('checking migration metadata changes on all registered SO types', () => "core-usage-stats": "b3c04da317c957741ebcdedfea4524049fdc79ff", "csp-rule-template": "099c229bf97578d9ca72b3a672d397559b84ee0b", "dashboard": "71e3f8dfcffeb5fbd410dec81ce46f5691763c43", - "endpoint:user-artifact": "a5b154962fb6cdf5d9e7452e58690054c95cc72a", - "endpoint:user-artifact-manifest": "5989989c0f84dd2d02da1eb46b6254e334bd2ccd", + "endpoint:user-artifact-manifest": "8ad9bd235dcfdc18b567aef0dc36ac686193dc89", "enterprise_search_telemetry": "4b41830e3b28a16eb92dee0736b44ae6276ced9b", "epm-packages": "8755f947a00613f994b1bc5d5580e104043e27f6", "epm-packages-assets": "00c8b5e5bf059627ffc9fbde920e1ac75926c5f6", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts index 8c5f78a574db1..06b8c169cc396 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts @@ -188,7 +188,6 @@ describe('split .kibana index into multiple system indices', () => { "connector_token", "core-usage-stats", "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "enterprise_search_telemetry", "epm-packages", diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 3c0fe05348324..d6985033cdb1d 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -137,6 +137,12 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record input', name, { @@ -50,6 +51,47 @@ export class FieldEditorService extends FtrService { await this.testSubjects.click('fieldSaveButton'); } + async setUrlFieldFormat(template: string) { + const urlTemplateField = await this.find.byCssSelector( + 'input[data-test-subj="urlEditorUrlTemplate"]' + ); + await urlTemplateField.type(template); + } + + public async setStaticLookupFormat(oldValue: string, newValue: string) { + await this.testSubjects.click('staticLookupEditorAddEntry'); + await this.testSubjects.setValue('~staticLookupEditorKey', oldValue); + await this.testSubjects.setValue('~staticLookupEditorValue', newValue); + } + + public async setColorFormat(value: string, color: string, backgroundColor?: string) { + await this.testSubjects.click('colorEditorAddColor'); + await this.testSubjects.setValue('~colorEditorKeyPattern', value); + await this.testSubjects.setValue('~colorEditorColorPicker', color); + if (backgroundColor) { + await this.testSubjects.setValue('~colorEditorBackgroundPicker', backgroundColor); + } + } + + public async setStringFormat(transform: string) { + await this.testSubjects.selectValue('stringEditorTransform', transform); + } + + public async setTruncateFormatLength(length: string) { + await this.testSubjects.setValue('truncateEditorLength', length); + } + + public async setFieldFormat(format: string) { + await this.find.clickByCssSelector( + 'select[data-test-subj="editorSelectedFormatId"] > option[value="' + format + '"]' + ); + } + + public async setFormat(format: string) { + await this.testSubjects.setEuiSwitch('formatRow > toggle', 'check'); + await this.setFieldFormat(format); + } + public async confirmSave() { await this.retry.try(async () => { await this.testSubjects.setValue('saveModalConfirmText', 'change'); diff --git a/x-pack/plugins/apm/common/data_source.ts b/x-pack/plugins/apm/common/data_source.ts index b951677a8cb65..9282fb372ac72 100644 --- a/x-pack/plugins/apm/common/data_source.ts +++ b/x-pack/plugins/apm/common/data_source.ts @@ -13,7 +13,8 @@ type AnyApmDocumentType = | ApmDocumentType.TransactionMetric | ApmDocumentType.TransactionEvent | ApmDocumentType.ServiceDestinationMetric - | ApmDocumentType.ServiceSummaryMetric; + | ApmDocumentType.ServiceSummaryMetric + | ApmDocumentType.ErrorEvent; export interface ApmDataSource< TDocumentType extends AnyApmDocumentType = AnyApmDocumentType diff --git a/x-pack/plugins/apm/common/document_type.ts b/x-pack/plugins/apm/common/document_type.ts index 333b9f69e0d0f..92a17c3125a96 100644 --- a/x-pack/plugins/apm/common/document_type.ts +++ b/x-pack/plugins/apm/common/document_type.ts @@ -11,6 +11,7 @@ export enum ApmDocumentType { TransactionEvent = 'transactionEvent', ServiceDestinationMetric = 'serviceDestinationMetric', ServiceSummaryMetric = 'serviceSummaryMetric', + ErrorEvent = 'error', } export type ApmServiceTransactionDocumentType = diff --git a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index deb363ab5a21b..9dfb15ed9cb05 100644 --- a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -90,6 +90,8 @@ exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Error ERROR_PAGE_URL 1`] = `undefined`; +exports[`Error ERROR_TYPE 1`] = `undefined`; + exports[`Error EVENT_NAME 1`] = `undefined`; exports[`Error EVENT_OUTCOME 1`] = `undefined`; @@ -156,10 +158,10 @@ exports[`Error LABEL_GC 1`] = `undefined`; exports[`Error LABEL_NAME 1`] = `undefined`; -exports[`Error LABEL_TYPE 1`] = `undefined`; - exports[`Error LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`; +exports[`Error LABEL_TYPE 1`] = `undefined`; + exports[`Error METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; exports[`Error METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; @@ -417,6 +419,8 @@ exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Span ERROR_PAGE_URL 1`] = `undefined`; +exports[`Span ERROR_TYPE 1`] = `undefined`; + exports[`Span EVENT_NAME 1`] = `undefined`; exports[`Span EVENT_OUTCOME 1`] = `"unknown"`; @@ -479,10 +483,10 @@ exports[`Span LABEL_GC 1`] = `undefined`; exports[`Span LABEL_NAME 1`] = `undefined`; -exports[`Span LABEL_TYPE 1`] = `undefined`; - exports[`Span LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`; +exports[`Span LABEL_TYPE 1`] = `undefined`; + exports[`Span METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; exports[`Span METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; @@ -740,6 +744,8 @@ exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`; +exports[`Transaction ERROR_TYPE 1`] = `undefined`; + exports[`Transaction EVENT_NAME 1`] = `undefined`; exports[`Transaction EVENT_OUTCOME 1`] = `"unknown"`; @@ -812,10 +818,10 @@ exports[`Transaction LABEL_GC 1`] = `undefined`; exports[`Transaction LABEL_NAME 1`] = `undefined`; -exports[`Transaction LABEL_TYPE 1`] = `undefined`; - exports[`Transaction LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`; +exports[`Transaction LABEL_TYPE 1`] = `undefined`; + exports[`Transaction METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; exports[`Transaction METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/es_fields/apm.ts b/x-pack/plugins/apm/common/es_fields/apm.ts index 9a1dd15f94a75..141be32365956 100644 --- a/x-pack/plugins/apm/common/es_fields/apm.ts +++ b/x-pack/plugins/apm/common/es_fields/apm.ts @@ -109,6 +109,7 @@ export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used i export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array export const ERROR_EXC_TYPE = 'error.exception.type'; export const ERROR_PAGE_URL = 'error.page.url'; +export const ERROR_TYPE = 'error.type'; // METRICS export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; diff --git a/x-pack/plugins/apm/common/rules/apm_rule_types.ts b/x-pack/plugins/apm/common/rules/apm_rule_types.ts index b5a640b2d72af..d1009cebc70da 100644 --- a/x-pack/plugins/apm/common/rules/apm_rule_types.ts +++ b/x-pack/plugins/apm/common/rules/apm_rule_types.ts @@ -15,6 +15,14 @@ import type { import type { ActionGroup } from '@kbn/alerting-plugin/common'; import { formatDurationFromTimeUnitChar } from '@kbn/observability-plugin/common'; import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../ml_constants'; +import { + ERROR_GROUP_ID, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../es_fields/apm'; +import { getEnvironmentLabel } from '../environment_filter_values'; export const APM_SERVER_FEATURE_ID = 'apm'; @@ -40,29 +48,66 @@ const THRESHOLD_MET_GROUP: ActionGroup = { }), }; +const getFieldNameLabel = (field: string): string => { + switch (field) { + case SERVICE_NAME: + return 'service'; + case SERVICE_ENVIRONMENT: + return 'env'; + case TRANSACTION_TYPE: + return 'type'; + case TRANSACTION_NAME: + return 'name'; + case ERROR_GROUP_ID: + return 'error key'; + default: + return field; + } +}; + +export const getFieldValueLabel = ( + field: string, + fieldValue: string +): string => { + return field === SERVICE_ENVIRONMENT + ? getEnvironmentLabel(fieldValue) + : fieldValue; +}; + +const formatGroupByFields = (groupByFields: Record): string => { + const groupByFieldLabels = Object.keys(groupByFields).map( + (field) => + `${getFieldNameLabel(field)}: ${getFieldValueLabel( + field, + groupByFields[field] + )}` + ); + return groupByFieldLabels.join(', '); +}; + export function formatErrorCountReason({ threshold, measured, - serviceName, windowSize, windowUnit, + groupByFields, }: { threshold: number; measured: number; - serviceName: string; windowSize: number; windowUnit: string; + groupByFields: Record; }) { return i18n.translate('xpack.apm.alertTypes.errorCount.reason', { - defaultMessage: `Error count is {measured} in the last {interval} for {serviceName}. Alert when > {threshold}.`, + defaultMessage: `Error count is {measured} in the last {interval} for {group}. Alert when > {threshold}.`, values: { threshold, measured, - serviceName, interval: formatDurationFromTimeUnitChar( windowSize, windowUnit as TimeUnitChar ), + group: formatGroupByFields(groupByFields), }, }); } @@ -70,19 +115,19 @@ export function formatErrorCountReason({ export function formatTransactionDurationReason({ threshold, measured, - serviceName, asDuration, aggregationType, windowSize, windowUnit, + groupByFields, }: { threshold: number; measured: number; - serviceName: string; asDuration: AsDuration; aggregationType: string; windowSize: number; windowUnit: string; + groupByFields: Record; }) { let aggregationTypeFormatted = aggregationType.charAt(0).toUpperCase() + aggregationType.slice(1); @@ -90,16 +135,16 @@ export function formatTransactionDurationReason({ aggregationTypeFormatted = aggregationTypeFormatted + '.'; return i18n.translate('xpack.apm.alertTypes.transactionDuration.reason', { - defaultMessage: `{aggregationType} latency is {measured} in the last {interval} for {serviceName}. Alert when > {threshold}.`, + defaultMessage: `{aggregationType} latency is {measured} in the last {interval} for {group}. Alert when > {threshold}.`, values: { threshold: asDuration(threshold), measured: asDuration(measured), - serviceName, aggregationType: aggregationTypeFormatted, interval: formatDurationFromTimeUnitChar( windowSize, windowUnit as TimeUnitChar ), + group: formatGroupByFields(groupByFields), }, }); } @@ -107,28 +152,28 @@ export function formatTransactionDurationReason({ export function formatTransactionErrorRateReason({ threshold, measured, - serviceName, asPercent, windowSize, windowUnit, + groupByFields, }: { threshold: number; measured: number; - serviceName: string; asPercent: AsPercent; windowSize: number; windowUnit: string; + groupByFields: Record; }) { return i18n.translate('xpack.apm.alertTypes.transactionErrorRate.reason', { - defaultMessage: `Failed transactions is {measured} in the last {interval} for {serviceName}. Alert when > {threshold}.`, + defaultMessage: `Failed transactions is {measured} in the last {interval} for {group}. Alert when > {threshold}.`, values: { threshold: asPercent(threshold, 100), measured: asPercent(measured, 100), - serviceName, interval: formatDurationFromTimeUnitChar( windowSize, windowUnit as TimeUnitChar ), + group: formatGroupByFields(groupByFields), }, }); } diff --git a/x-pack/plugins/apm/common/rules/schema.ts b/x-pack/plugins/apm/common/rules/schema.ts index ca77e76f6f156..656f6efbe4b25 100644 --- a/x-pack/plugins/apm/common/rules/schema.ts +++ b/x-pack/plugins/apm/common/rules/schema.ts @@ -15,6 +15,7 @@ export const errorCountParamsSchema = schema.object({ threshold: schema.number(), serviceName: schema.maybe(schema.string()), environment: schema.string(), + groupBy: schema.maybe(schema.arrayOf(schema.string())), errorGroupingKey: schema.maybe(schema.string()), }); @@ -31,6 +32,7 @@ export const transactionDurationParamsSchema = schema.object({ schema.literal(AggregationType.P99), ]), environment: schema.string(), + groupBy: schema.maybe(schema.arrayOf(schema.string())), }); export const anomalyParamsSchema = schema.object({ @@ -55,6 +57,7 @@ export const transactionErrorRateParamsSchema = schema.object({ transactionName: schema.maybe(schema.string()), serviceName: schema.maybe(schema.string()), environment: schema.string(), + groupBy: schema.maybe(schema.arrayOf(schema.string())), }); type ErrorCountParamsType = TypeOf; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts new file mode 100644 index 0000000000000..5b12bd58b76be --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts @@ -0,0 +1,221 @@ +/* + * 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 { apm, timerange } from '@kbn/apm-synthtrace-client'; + +export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1']; + +export function generateMobileData({ from, to }: { from: number; to: number }) { + const galaxy10 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] }) + .deviceInfo({ + manufacturer: 'Samsung', + modelIdentifier: 'SM-G973F', + modelName: 'Galaxy S10', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '223.72.43.22', + cityName: 'Beijing', + continentName: 'Asia', + countryIsoCode: 'CN', + countryName: 'China', + regionIsoCode: 'CN-BJ', + regionName: 'Beijing', + location: { coordinates: [116.3861, 39.9143], type: 'Point' }, + }) + .setNetworkConnection({ type: 'wifi' }); + + const galaxy7 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] }) + .deviceInfo({ + manufacturer: 'Samsung', + modelIdentifier: 'SM-G930F', + modelName: 'Galaxy S7', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '223.72.43.22', + cityName: 'Beijing', + continentName: 'Asia', + countryIsoCode: 'CN', + countryName: 'China', + regionIsoCode: 'CN-BJ', + regionName: 'Beijing', + location: { coordinates: [116.3861, 39.9143], type: 'Point' }, + }) + .setNetworkConnection({ + type: 'cell', + subType: 'edge', + carrierName: 'M1 Limited', + carrierMNC: '03', + carrierICC: 'SG', + carrierMCC: '525', + }); + + const huaweiP2 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] }) + .deviceInfo({ + manufacturer: 'Huawei', + modelIdentifier: 'HUAWEI P2-0000', + modelName: 'HuaweiP2', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '20.24.184.101', + cityName: 'Singapore', + continentName: 'Asia', + countryIsoCode: 'SG', + countryName: 'Singapore', + location: { coordinates: [103.8554, 1.3036], type: 'Point' }, + }) + .setNetworkConnection({ + type: 'cell', + subType: 'edge', + carrierName: 'Osaka Gas Business Create Co., Ltd.', + carrierMNC: '17', + carrierICC: 'JP', + carrierMCC: '440', + }); + + return timerange(from, to) + .interval('5m') + .rate(1) + .generator((timestamp) => { + galaxy10.startNewSession(); + galaxy7.startNewSession(); + huaweiP2.startNewSession(); + return [ + galaxy10 + .transaction('Start View - View Appearing', 'Android Activity') + .timestamp(timestamp) + .duration(500) + .success() + .children( + galaxy10 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + galaxy10 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + galaxy10 + .transaction('Second View - View Appearing', 'Android Activity') + .timestamp(10000 + timestamp) + .duration(300) + .failure() + .children( + galaxy10 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/second', + }) + .duration(400) + .success() + .timestamp(10000 + timestamp + 250) + ), + huaweiP2 + .transaction('Start View - View Appearing', 'huaweiP2 Activity') + .timestamp(timestamp) + .duration(20) + .success() + .children( + huaweiP2 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + huaweiP2 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + galaxy7 + .transaction('Start View - View Appearing', 'Android Activity') + .timestamp(timestamp) + .duration(20) + .success() + .children( + galaxy7 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + galaxy7 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + ]; + }); +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts new file mode 100644 index 0000000000000..85cf055507f3b --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import url from 'url'; +import { synthtrace } from '../../../../synthtrace'; +import { generateMobileData } from './generate_data'; + +const start = '2021-10-10T00:00:00.000Z'; +const end = '2021-10-10T00:15:00.000Z'; + +const mobileTransactionsPageHref = url.format({ + pathname: '/app/apm/mobile-services/synth-android/transactions', + query: { + rangeFrom: start, + rangeTo: end, + }, +}); + +describe('Mobile transactions page', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + }); + + describe('when data is loaded', () => { + before(() => { + synthtrace.index( + generateMobileData({ + from: new Date(start).getTime(), + to: new Date(end).getTime(), + }) + ); + }); + + after(() => { + synthtrace.clean(); + }); + + describe('when click on tab shows correct table', () => { + it('shows version tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmAppVersionTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=app_version_tab'); + }); + + it('shows OS version tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmOsVersionTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=os_version_tab'); + }); + + it('shows devices tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmDevicesTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=devices_tab'); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx b/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx index f23dc6f4fb362..f95f117cedc5b 100644 --- a/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx @@ -7,13 +7,15 @@ import { i18n } from '@kbn/i18n'; import { defaults, omit } from 'lodash'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { CoreStart } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ForLastExpression, TIME_UNITS, } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import { asInteger } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; @@ -27,6 +29,13 @@ import { } from '../../utils/fields'; import { AlertMetadata, getIntervalAndTimeRange } from '../../utils/helper'; import { ApmRuleParamsContainer } from '../../ui_components/apm_rule_params_container'; +import { APMRuleGroupBy } from '../../ui_components/apm_rule_group_by'; +import { + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, + ERROR_GROUP_ID, +} from '../../../../../common/es_fields/apm'; export interface RuleParams { windowSize?: number; @@ -34,6 +43,7 @@ export interface RuleParams { threshold?: number; serviceName?: string; environment?: string; + groupBy?: string[] | undefined; errorGroupingKey?: string; } @@ -95,6 +105,13 @@ export function ErrorCountRuleType(props: Props) { ] ); + const onGroupByChange = useCallback( + (group: string[] | null) => { + setRuleParams('groupBy', group ?? []); + }, + [setRuleParams] + ); + const fields = [ ); + const groupAlertsBy = ( + <> + + + + + + ); + return ( = { @@ -146,6 +156,13 @@ export function TransactionDurationRuleType(props: Props) { /> ); + const onGroupByChange = useCallback( + (group: string[] | null) => { + setRuleParams('groupBy', group ?? []); + }, + [setRuleParams] + ); + const fields = [ , ]; + const groupAlertsBy = ( + <> + + + + + + ); + return ( diff --git a/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx b/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx index f161ef085b3ea..6f4f36b84778d 100644 --- a/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx @@ -6,13 +6,16 @@ */ import { defaults, omit } from 'lodash'; -import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect } from 'react'; import { CoreStart } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ForLastExpression, TIME_UNITS, } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import { asPercent } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; @@ -27,6 +30,13 @@ import { } from '../../utils/fields'; import { AlertMetadata, getIntervalAndTimeRange } from '../../utils/helper'; import { ApmRuleParamsContainer } from '../../ui_components/apm_rule_params_container'; +import { APMRuleGroupBy } from '../../ui_components/apm_rule_group_by'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME, +} from '../../../../../common/es_fields/apm'; export interface RuleParams { windowSize?: number; @@ -36,6 +46,7 @@ export interface RuleParams { transactionType?: string; transactionName?: string; environment?: string; + groupBy?: string[] | undefined; } export interface Props { @@ -100,6 +111,13 @@ export function TransactionErrorRateRuleType(props: Props) { ] ); + const onGroupByChange = useCallback( + (group: string[] | null) => { + setRuleParams('groupBy', group ?? []); + }, + [setRuleParams] + ); + const fields = [ ); + const groupAlertsBy = ( + <> + + + + + + ); + return ( void; + errorOptions?: string[]; +} + +export function APMRuleGroupBy({ + options, + fields, + preSelectedOptions, + onChange, + errorOptions, +}: Props) { + const handleChange = useCallback( + (selectedOptions: Array<{ label: string }>) => { + const groupByOption = selectedOptions.map((option) => option.label); + onChange([...new Set(preSelectedOptions.concat(groupByOption))]); + }, + [onChange, preSelectedOptions] + ); + + const getPreSelectedOptions = () => { + return preSelectedOptions.map((field) => ({ + label: field, + color: 'lightgray', + disabled: true, + })); + }; + + const getUserSelectedOptions = (groupBy: string[] | undefined) => { + return (groupBy ?? []) + .filter((group) => !preSelectedOptions.includes(group)) + .map((field) => ({ + label: field, + color: errorOptions?.includes(field) ? 'danger' : undefined, + })); + }; + + const selectedOptions = [ + ...getPreSelectedOptions(), + ...getUserSelectedOptions(options.groupBy), + ]; + + return ( + ({ label: field }))} + onChange={handleChange} + isClearable={false} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx index 57d27c6cb6e31..b651fb29a824f 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx @@ -24,6 +24,7 @@ interface Props { setRuleProperty: (key: string, value: any) => void; defaultParams: Record; fields: React.ReactNode[]; + groupAlertsBy?: React.ReactNode; chartPreview?: React.ReactNode; minimumWindowSize?: MinimumWindowSize; } @@ -31,6 +32,7 @@ interface Props { export function ApmRuleParamsContainer(props: Props) { const { fields, + groupAlertsBy, setRuleParams, defaultParams, chartPreview, @@ -72,6 +74,7 @@ export function ApmRuleParamsContainer(props: Props) { {chartPreview} + {groupAlertsBy} ); } diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx index be6963994c639..ce06e04683af9 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx @@ -16,11 +16,11 @@ import { useHistory } from 'react-router-dom'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useTimeRange } from '../../../../hooks/use_time_range'; -import { TransactionsTable } from '../../../shared/transactions_table'; import { replace } from '../../../shared/links/url_helpers'; import { getKueryWithMobileFilters } from '../../../../../common/utils/get_kuery_with_mobile_filters'; import { MobileTransactionCharts } from './transaction_charts'; import { MobileTreemap } from '../charts/mobile_treemap'; +import { TransactionOverviewTabs } from './transaction_overview_tabs'; export function MobileTransactionOverview() { const { @@ -37,6 +37,7 @@ export function MobileTransactionOverview() { kuery, offset, comparisonEnabled, + mobileSelectedTab, }, } = useApmParams('/mobile-services/{serviceName}/transactions'); @@ -88,15 +89,14 @@ export function MobileTransactionOverview() { /> - diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx new file mode 100644 index 0000000000000..deafdeb59d3c5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { SERVICE_VERSION } from '../../../../../../common/es_fields/apm'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; + +function AppVersionTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: SERVICE_VERSION, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const appVersionTab = { + dataTestSubj: 'apmAppVersionTab', + key: 'app_version_tab', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.app.version', + { + defaultMessage: 'App version', + } + ), + component: AppVersionTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx new file mode 100644 index 0000000000000..4d2f18b046709 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; +import { DEVICE_MODEL_IDENTIFIER } from '../../../../../../common/es_fields/apm'; + +function DevicesTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: DEVICE_MODEL_IDENTIFIER, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const devicesTab = { + dataTestSubj: 'apmDevicesTab', + key: 'devices_tab', + label: i18n.translate('xpack.apm.mobile.transactions.overview.tabs.devices', { + defaultMessage: 'Devices', + }), + component: DevicesTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx new file mode 100644 index 0000000000000..c986f5903b7b5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; +import { push } from '../../../../shared/links/url_helpers'; +import { transactionsTab } from './transactions_tab'; +import { osVersionTab } from './os_version_tab'; +import { appVersionTab } from './app_version_tab'; +import { devicesTab } from './devices_tab'; + +export interface TabContentProps { + agentName?: string; + environment: string; + start: string; + end: string; + kuery: string; + comparisonEnabled: boolean; + offset?: string; + mobileSelectedTab?: string; +} + +const tabs = [transactionsTab, appVersionTab, osVersionTab, devicesTab]; + +export function TransactionOverviewTabs({ + agentName, + environment, + start, + end, + kuery, + comparisonEnabled, + offset, + mobileSelectedTab, +}: TabContentProps) { + const history = useHistory(); + + const { component: TabContent } = + tabs.find((tab) => tab.key === mobileSelectedTab) ?? transactionsTab; + return ( + <> + + {tabs.map(({ dataTestSubj, key, label }) => ( + { + push(history, { + query: { + mobileSelectedTab: key, + }, + }); + }} + > + {label} + + ))} + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx new file mode 100644 index 0000000000000..6eee1f01aae9f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; +import { HOST_OS_VERSION } from '../../../../../../common/es_fields/apm'; + +function OSVersionTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: HOST_OS_VERSION, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const osVersionTab = { + dataTestSubj: 'apmOsVersionTab', + key: 'os_version_tab', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.os.version', + { + defaultMessage: 'OS version', + } + ), + component: OSVersionTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx new file mode 100644 index 0000000000000..18ac252011357 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx @@ -0,0 +1,148 @@ +/* + * 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 { RIGHT_ALIGNMENT, EuiText } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ValuesType } from 'utility-types'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { + ChartType, + getTimeSeriesColor, +} from '../../../../../shared/charts/helper/get_timeseries_color'; +import { SparkPlot } from '../../../../../shared/charts/spark_plot'; +import { isTimeComparison } from '../../../../../shared/time_comparison/get_comparison_options'; +import { + asMillisecondDuration, + asPercent, + asTransactionRate, +} from '../../../../../../../common/utils/formatters'; +import { ITableColumn } from '../../../../../shared/managed_table'; + +type MobileMainStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>; + +type MobileMainStatisticsByFieldItem = ValuesType< + MobileMainStatisticsByField['mainStatistics'] +>; + +type MobileDetailedStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +export function getColumns({ + agentName, + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, +}: { + agentName?: string; + detailedStatisticsLoading: boolean; + detailedStatistics: MobileDetailedStatisticsByField; + comparisonEnabled?: boolean; + offset?: string; +}): Array> { + return [ + // version/device + { + field: 'name', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.nameColumnLabel', + { + defaultMessage: 'Name', + } + ), + }, + // latency + { + field: 'latency', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.latencyColumnAvgLabel', + { + defaultMessage: 'Latency (avg.)', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { latency, name }) => { + const currentPeriodTimeseries = + detailedStatistics?.currentPeriod?.[name]?.latency; + const previousPeriodTimeseries = + detailedStatistics?.previousPeriod?.[name]?.latency; + + const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( + ChartType.LATENCY_AVG + ); + + return ( + + ); + }, + }, + // throughput + { + field: 'throughput', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.throughputColumnAvgLabel', + { defaultMessage: 'Throughput' } + ), + align: RIGHT_ALIGNMENT, + render: (_, { throughput, name }) => { + const currentPeriodTimeseries = + detailedStatistics?.currentPeriod?.[name]?.throughput; + const previousPeriodTimeseries = + detailedStatistics?.previousPeriod?.[name]?.throughput; + + const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( + ChartType.THROUGHPUT + ); + + return ( + + ); + }, + }, + // crash rate + { + field: 'crashRate', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.crashRateColumnLabel', + { + defaultMessage: 'Crash rate', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { crashRate }) => { + return ( + + {asPercent(crashRate, 1)} + + ); + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx new file mode 100644 index 0000000000000..ab71f49421ddd --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ManagedTable } from '../../../../../shared/managed_table'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { getColumns } from './get_columns'; + +type MobileMainStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>['mainStatistics']; + +type MobileDetailedStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +interface Props { + isLoading: boolean; + mainStatistics: MobileMainStatisticsByField; + detailedStatisticsLoading: boolean; + detailedStatistics: MobileDetailedStatisticsByField; + comparisonEnabled?: boolean; + offset?: string; +} +export function StatsList({ + isLoading, + mainStatistics, + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, +}: Props) { + const columns = useMemo(() => { + return getColumns({ + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, + }); + }, [ + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, + ]); + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx new file mode 100644 index 0000000000000..4fef8262e6305 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { TransactionsTable } from '../../../../shared/transactions_table'; + +function TransactionsTab({ environment, kuery, start, end }: TabContentProps) { + return ( + + ); +} + +export const transactionsTab = { + dataTestSubj: 'apmTransactionsTab', + key: 'transactions', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.transactions', + { + defaultMessage: 'Transactions', + } + ), + component: TransactionsTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts new file mode 100644 index 0000000000000..4c3bd48e5e089 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context'; +import { useFetcher } from '../../../../../hooks/use_fetcher'; +import { isTimeComparison } from '../../../../shared/time_comparison/get_comparison_options'; + +const INITIAL_STATE_MAIN_STATISTICS = { + mainStatistics: [], + requestId: undefined, + totalItems: 0, +}; + +const INITIAL_STATE_DETAILED_STATISTICS = { + currentPeriod: {}, + previousPeriod: {}, +}; + +interface Props { + field: string; + environment: string; + start: string; + end: string; + kuery: string; + comparisonEnabled: boolean; + offset?: string; +} + +export function useMobileStatisticsFetcher({ + field, + environment, + start, + end, + kuery, + comparisonEnabled, + offset, +}: Props) { + const { serviceName } = useApmServiceContext(); + + const { data = INITIAL_STATE_MAIN_STATISTICS, status: mainStatisticsStatus } = + useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi( + 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + field, + }, + }, + } + ).then((response) => { + return { + // Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched. + requestId: uuidv4(), + mainStatistics: response.mainStatistics, + totalItems: response.mainStatistics.length, + }; + }); + } + }, + [environment, start, end, kuery, serviceName, field] + ); + + const { mainStatistics, requestId, totalItems } = data; + + const { + data: detailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if (totalItems && start && end) { + return callApmApi( + 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + field, + fieldValues: JSON.stringify( + data?.mainStatistics.map(({ name }) => name).sort() + ), + offset: + comparisonEnabled && isTimeComparison(offset) + ? offset + : undefined, + }, + }, + } + ); + } + }, + // only fetches agg results when requestId changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId], + { preservePreviousData: false } + ); + + return { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + }; +} diff --git a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx index 52e127c63f805..7b8e4a120c298 100644 --- a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx @@ -146,6 +146,7 @@ export const mobileServiceDetail = { osVersion: t.string, appVersion: t.string, netConnectionType: t.string, + mobileSelectedTab: t.string, }), }), children: { diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 42d03902bc313..c4df8a4f7e9a9 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -65,6 +65,7 @@ const DEFAULT_SORT = { }; interface Props { + hideTitle?: boolean; hideViewTransactionsLink?: boolean; isSingleColumn?: boolean; numberOfTransactionsPerPage?: number; @@ -81,6 +82,7 @@ interface Props { export function TransactionsTable({ fixedHeight = false, hideViewTransactionsLink = false, + hideTitle = false, isSingleColumn = true, numberOfTransactionsPerPage = 5, showPerPageOptions = true, @@ -294,32 +296,35 @@ export function TransactionsTable({ gutterSize="s" data-test-subj="transactionsGroupTable" > - - - - -

- {i18n.translate('xpack.apm.transactionsTable.title', { - defaultMessage: 'Transactions', - })} -

-
-
- {!hideViewTransactionsLink && ( + {!hideTitle && ( + + - - {i18n.translate('xpack.apm.transactionsTable.linkText', { - defaultMessage: 'View transactions', - })} - + +

+ {i18n.translate('xpack.apm.transactionsTable.title', { + defaultMessage: 'Transactions', + })} +

+
- )} -
-
+ {!hideViewTransactionsLink && ( + + + {i18n.translate('xpack.apm.transactionsTable.linkText', { + defaultMessage: 'View transactions', + })} + + + )} +
+
+ )} + {showMaxTransactionGroupsExceededWarning && maxTransactionGroupsExceeded && ( = diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts index ccfd76b760d66..e34957083d3e4 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts @@ -17,6 +17,7 @@ import { MlPluginSetup } from '@kbn/ml-plugin/server'; import { legacyExperimentalFieldMap } from '@kbn/alerts-as-data-utils'; import { AGENT_NAME, + ERROR_GROUP_ID, PROCESSOR_EVENT, SERVICE_ENVIRONMENT, SERVICE_LANGUAGE_NAME, @@ -50,6 +51,10 @@ export const apmRuleTypeAlertFieldMap = { type: 'keyword', required: false, }, + [ERROR_GROUP_ID]: { + type: 'keyword', + required: false, + }, [PROCESSOR_EVENT]: { type: 'keyword', required: false, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts index 4513db455cf6d..781094ca55257 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts @@ -44,7 +44,11 @@ describe('Error count alert', () => { registerErrorCountRuleType(dependencies); - const params = { threshold: 2, windowSize: 5, windowUnit: 'm' }; + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + }; services.scopedClusterClient.asCurrentUser.search.mockResponse({ hits: { @@ -126,11 +130,208 @@ describe('Error count alert', () => { }, }); + await executor({ params }); + ['foo_env-foo', 'foo_env-foo-2', 'bar_env-bar'].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + }); + }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'transaction.name'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'env-foo', 'tx-name-foo'], + doc_count: 5, + }, + { + key: ['foo', 'env-foo-2', 'tx-name-foo-2'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar', 'tx-name-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2', 'tx-name-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + await executor({ params }); + [ + 'foo_env-foo_tx-name-foo', + 'foo_env-foo-2_tx-name-foo-2', + 'bar_env-bar_tx-name-bar', + ].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo, name: tx-name-foo. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + transactionName: 'tx-name-foo', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2, name: tx-name-foo-2. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + transactionName: 'tx-name-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar, name: tx-name-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + transactionName: 'tx-name-bar', + }); + }); + + it('sends alert when rule is configured with group by on error.grouping_key', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'error.grouping_key'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'env-foo', 'error-key-foo'], + doc_count: 5, + }, + { + key: ['foo', 'env-foo-2', 'error-key-foo-2'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar', 'error-key-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2', 'error-key-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + await executor({ params }); [ - 'apm.error_rate_foo_env-foo', - 'apm.error_rate_foo_env-foo-2', - 'apm.error_rate_bar_env-bar', + 'foo_env-foo_error-key-foo', + 'foo_env-foo-2_error-key-foo-2', + 'bar_env-bar_error-key-bar', ].forEach((instanceName) => expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) ); @@ -142,25 +343,225 @@ describe('Error count alert', () => { environment: 'env-foo', threshold: 2, triggerValue: 5, - reason: 'Error count is 5 in the last 5 mins for foo. Alert when > 2.', + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo, error key: error-key-foo. Alert when > 2.', interval: '5 mins', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + errorGroupingKey: 'error-key-foo', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', environment: 'env-foo-2', threshold: 2, triggerValue: 4, - reason: 'Error count is 4 in the last 5 mins for foo. Alert when > 2.', + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2, error key: error-key-foo-2. Alert when > 2.', interval: '5 mins', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + errorGroupingKey: 'error-key-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar, error key: error-key-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + errorGroupingKey: 'error-key-bar', + }); + }); + + it('sends alert when rule is configured with preselected group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'env-foo'], + doc_count: 5, + }, + { + key: ['foo', 'env-foo-2'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + await executor({ params }); + ['foo_env-foo', 'foo_env-foo-2', 'bar_env-bar'].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + }); + }); + + it('sends alert when service.environment field does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'ENVIRONMENT_NOT_DEFINED'], + doc_count: 5, + }, + { + key: ['foo', 'ENVIRONMENT_NOT_DEFINED'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + await executor({ params }); + [ + 'foo_ENVIRONMENT_NOT_DEFINED', + 'foo_ENVIRONMENT_NOT_DEFINED', + 'bar_env-bar', + ].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'Not defined', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: Not defined. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=ENVIRONMENT_ALL', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'Not defined', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: Not defined. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=ENVIRONMENT_ALL', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', environment: 'env-bar', - reason: 'Error count is 3 in the last 5 mins for bar. Alert when > 2.', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar. Alert when > 2.', threshold: 2, triggerValue: 3, interval: '5 mins', diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts index 695a9a579a35d..6ac9a87789136 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts @@ -19,11 +19,7 @@ import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server import { termQuery } from '@kbn/observability-plugin/server'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; -import { - ENVIRONMENT_NOT_DEFINED, - getEnvironmentEsField, - getEnvironmentLabel, -} from '../../../../../common/environment_filter_values'; +import { getEnvironmentEsField } from '../../../../../common/environment_filter_values'; import { ERROR_GROUP_ID, PROCESSOR_EVENT, @@ -50,6 +46,8 @@ import { getServiceGroupFields, getServiceGroupFieldsAgg, } from '../get_service_group_fields'; +import { getGroupByTerms } from '../utils/get_groupby_terms'; +import { getGroupByActionVariables } from '../utils/get_groupby_action_variables'; const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.ErrorCount]; @@ -78,6 +76,7 @@ export function registerErrorCountRuleType({ apmActionVariables.interval, apmActionVariables.reason, apmActionVariables.serviceName, + apmActionVariables.transactionName, apmActionVariables.errorGroupingKey, apmActionVariables.threshold, apmActionVariables.triggerValue, @@ -88,6 +87,12 @@ export function registerErrorCountRuleType({ minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ params: ruleParams, services, spaceId }) => { + const predefinedGroupby = [SERVICE_NAME, SERVICE_ENVIRONMENT]; + + const allGroupbyFields = Array.from( + new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])]) + ); + const config = await firstValueFrom(config$); const { savedObjectsClient, scopedClusterClient } = services; @@ -126,13 +131,7 @@ export function registerErrorCountRuleType({ aggs: { error_counts: { multi_terms: { - terms: [ - { field: SERVICE_NAME }, - { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - }, - ], + terms: getGroupByTerms(allGroupbyFields), size: 1000, order: { _count: 'desc' as const }, }, @@ -149,40 +148,42 @@ export function registerErrorCountRuleType({ const errorCountResults = response.aggregations?.error_counts.buckets.map((bucket) => { - const [serviceName, environment] = bucket.key; + const groupByFields = bucket.key.reduce( + (obj, bucketKey, bucketIndex) => { + obj[allGroupbyFields[bucketIndex]] = bucketKey; + return obj; + }, + {} as Record + ); + + const bucketKey = bucket.key; + return { - serviceName, - environment, errorCount: bucket.doc_count, sourceFields: getServiceGroupFields(bucket), + groupByFields, + bucketKey, }; }) ?? []; errorCountResults .filter((result) => result.errorCount >= ruleParams.threshold) .forEach((result) => { - const { serviceName, environment, errorCount, sourceFields } = + const { errorCount, sourceFields, groupByFields, bucketKey } = result; const alertReason = formatErrorCountReason({ - serviceName, threshold: ruleParams.threshold, measured: errorCount, windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, + groupByFields, }); - const id = [ - ApmRuleType.ErrorCount, - serviceName, - environment, - ruleParams.errorGroupingKey, - ] - .filter((name) => name) - .join('_'); - const relativeViewInAppUrl = getAlertUrlErrorCount( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT] + groupByFields[SERVICE_NAME], + getEnvironmentEsField(groupByFields[SERVICE_ENVIRONMENT])?.[ + SERVICE_ENVIRONMENT + ] ); const viewInAppUrl = addSpaceIdToPath( @@ -191,32 +192,33 @@ export function registerErrorCountRuleType({ relativeViewInAppUrl ); + const groupByActionVariables = + getGroupByActionVariables(groupByFields); + services .alertWithLifecycle({ - id, + id: bucketKey.join('_'), fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), [PROCESSOR_EVENT]: ProcessorEvent.error, [ALERT_EVALUATION_VALUE]: errorCount, [ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold, [ERROR_GROUP_ID]: ruleParams.errorGroupingKey, [ALERT_REASON]: alertReason, ...sourceFields, + ...groupByFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - environment: getEnvironmentLabel(environment), interval: formatDurationFromTimeUnitChar( ruleParams.windowSize, ruleParams.windowUnit as TimeUnitChar ), reason: alertReason, - serviceName, threshold: ruleParams.threshold, - errorGroupingKey: ruleParams.errorGroupingKey, + errorGroupingKey: ruleParams.errorGroupingKey, // When group by doesn't include error.grouping_key, the context.error.grouping_key action variable will contain value of the Error Grouping Key filter triggerValue: errorCount, viewInAppUrl, + ...groupByActionVariables, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts index 1f91cfc548469..f3993dfed7009 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts @@ -27,7 +27,7 @@ describe('registerTransactionDurationRuleType', () => { series: { buckets: [ { - key: ['opbeans-java', 'ENVIRONMENT_NOT_DEFINED', 'request'], + key: ['opbeans-java', 'development', 'request'], avgLatency: { value: 5500000, }, @@ -61,16 +61,226 @@ describe('registerTransactionDurationRuleType', () => { 'http://localhost:5601/eyr/app/observability/alerts/' ), transactionName: 'GET /orders', + environment: 'development', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: development, type: request. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=development', + }); + }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['opbeans-java', 'development', 'request', 'GET /products'], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'development', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: development, type: request, name: GET /products. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=development', + transactionName: 'GET /products', + }); + }); + + it('sends alert when rule is configured with preselected group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['opbeans-java', 'development', 'request'], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: ['service.name', 'service.environment', 'transaction.type'], + }; + + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'development', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: development, type: request. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=development', + }); + }); + + it('sends alert when service.environment field does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: [ + 'opbeans-java', + 'ENVIRONMENT_NOT_DEFINED', + 'request', + 'tx-java', + ], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), environment: 'Not defined', interval: `5 mins`, reason: - 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: Not defined, type: request, name: tx-java. Alert when > 3,000 ms.', transactionType: 'request', serviceName: 'opbeans-java', threshold: 3000, triggerValue: '5,500 ms', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', + transactionName: 'tx-java', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts index 9bb21324c22b7..0ac465261c954 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts @@ -22,12 +22,9 @@ import { import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; +import { getGroupByTerms } from '../utils/get_groupby_terms'; import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions'; -import { - ENVIRONMENT_NOT_DEFINED, - getEnvironmentEsField, - getEnvironmentLabel, -} from '../../../../../common/environment_filter_values'; +import { getEnvironmentEsField } from '../../../../../common/environment_filter_values'; import { PROCESSOR_EVENT, SERVICE_ENVIRONMENT, @@ -66,6 +63,7 @@ import { averageOrPercentileAgg, getMultiTermsSortOrder, } from './average_or_percentile_agg'; +import { getGroupByActionVariables } from '../utils/get_groupby_action_variables'; const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.TransactionDuration]; @@ -105,6 +103,16 @@ export function registerTransactionDurationRuleType({ minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ params: ruleParams, services, spaceId }) => { + const predefinedGroupby = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + ]; + + const allGroupbyFields = Array.from( + new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])]) + ); + const config = await firstValueFrom(config$); const { getAlertUuid, savedObjectsClient, scopedClusterClient } = @@ -164,14 +172,7 @@ export function registerTransactionDurationRuleType({ aggs: { series: { multi_terms: { - terms: [ - { field: SERVICE_NAME }, - { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - }, - { field: TRANSACTION_TYPE }, - ], + terms: [...getGroupByTerms(allGroupbyFields)], size: 1000, ...getMultiTermsSortOrder(ruleParams.aggregationType), }, @@ -202,7 +203,15 @@ export function registerTransactionDurationRuleType({ const triggeredBuckets = []; for (const bucket of response.aggregations.series.buckets) { - const [serviceName, environment, transactionType] = bucket.key; + const groupByFields = bucket.key.reduce( + (obj, bucketKey, bucketIndex) => { + obj[allGroupbyFields[bucketIndex]] = bucketKey; + return obj; + }, + {} as Record + ); + + const bucketKey = bucket.key; const transactionDuration = 'avgLatency' in bucket // only true if ruleParams.aggregationType === 'avg' @@ -214,24 +223,20 @@ export function registerTransactionDurationRuleType({ transactionDuration > thresholdMicroseconds ) { triggeredBuckets.push({ - environment, - serviceName, sourceFields: getServiceGroupFields(bucket), - transactionType, transactionDuration, + groupByFields, + bucketKey, }); } } for (const { - serviceName, - environment, - transactionType, transactionDuration, sourceFields, + groupByFields, + bucketKey, } of triggeredBuckets) { - const environmentLabel = getEnvironmentLabel(environment); - const durationFormatter = getDurationFormatter(transactionDuration); const transactionDurationFormatted = durationFormatter(transactionDuration).formatted; @@ -240,15 +245,13 @@ export function registerTransactionDurationRuleType({ aggregationType: String(ruleParams.aggregationType), asDuration, measured: transactionDuration, - serviceName, threshold: thresholdMicroseconds, windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, + groupByFields, }); - const id = `${ApmRuleType.TransactionDuration}_${environmentLabel}`; - - const alertUuid = getAlertUuid(id); + const alertUuid = getAlertUuid(bucketKey.join('_')); const alertDetailsUrl = getAlertDetailsUrl( basePath, @@ -260,41 +263,41 @@ export function registerTransactionDurationRuleType({ basePath.publicBaseUrl, spaceId, getAlertUrlTransaction( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], - transactionType + groupByFields[SERVICE_NAME], + getEnvironmentEsField(groupByFields[SERVICE_ENVIRONMENT])?.[ + SERVICE_ENVIRONMENT + ], + groupByFields[TRANSACTION_TYPE] ) ); + const groupByActionVariables = getGroupByActionVariables(groupByFields); + services .alertWithLifecycle({ - id, + id: bucketKey.join('_'), fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), - [TRANSACTION_TYPE]: transactionType, [TRANSACTION_NAME]: ruleParams.transactionName, [PROCESSOR_EVENT]: ProcessorEvent.transaction, [ALERT_EVALUATION_VALUE]: transactionDuration, [ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold, [ALERT_REASON]: reason, ...sourceFields, + ...groupByFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { alertDetailsUrl, - environment: environmentLabel, interval: formatDurationFromTimeUnitChar( ruleParams.windowSize, ruleParams.windowUnit as TimeUnitChar ), reason, - serviceName, - transactionName: ruleParams.transactionName, // #Note once we group by transactionName, use the transactionName key from the bucket + transactionName: ruleParams.transactionName, // When group by doesn't include transaction.name, the context.transaction.name action variable will contain value of the Transaction Name filter threshold: ruleParams.threshold, - transactionType, triggerValue: transactionDurationFormatted, viewInAppUrl, + ...groupByActionVariables, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts index a1c28f5dd77e4..708d5c533ba6b 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts @@ -107,17 +107,120 @@ describe('Transaction error rate alert', () => { }, }); - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + }; + + await executor({ params }); + + expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + + expect(services.alertFactory.create).toHaveBeenCalledWith( + 'foo_env-foo_type-foo' + ); + expect(services.alertFactory.create).not.toHaveBeenCalledWith( + 'bar_env-bar_type-bar' + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'env-foo', + reason: + 'Failed transactions is 10% in the last 5 mins for service: foo, env: env-foo, type: type-foo. Alert when > 10%.', + threshold: 10, + triggerValue: '10', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', + }); + }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionErrorRateRuleType({ + ...dependencies, + }); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['foo', 'env-foo', 'type-foo', 'tx-name-foo'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, + }, + ], + }, + }, + { + key: ['bar', 'env-bar', 'type-bar', 'tx-name-bar'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; await executor({ params }); expect(services.alertFactory.create).toHaveBeenCalledTimes(1); expect(services.alertFactory.create).toHaveBeenCalledWith( - 'apm.transaction_error_rate_foo_type-foo_env-foo' + 'foo_env-foo_type-foo_tx-name-foo' ); expect(services.alertFactory.create).not.toHaveBeenCalledWith( - 'apm.transaction_error_rate_bar_type-bar_env-bar' + 'bar_env-bar_type-bar_tx-name-bar' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { @@ -125,12 +228,201 @@ describe('Transaction error rate alert', () => { transactionType: 'type-foo', environment: 'env-foo', reason: - 'Failed transactions is 10% in the last 5 mins for foo. Alert when > 10%.', + 'Failed transactions is 10% in the last 5 mins for service: foo, env: env-foo, type: type-foo, name: tx-name-foo. Alert when > 10%.', threshold: 10, triggerValue: '10', interval: '5 mins', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', + transactionName: 'tx-name-foo', + }); + }); + + it('sends alert when rule is configured with preselected group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionErrorRateRuleType({ + ...dependencies, + }); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['foo', 'env-foo', 'type-foo'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, + }, + ], + }, + }, + { + key: ['bar', 'env-bar', 'type-bar'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'transaction.type'], + }; + + await executor({ params }); + + expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + + expect(services.alertFactory.create).toHaveBeenCalledWith( + 'foo_env-foo_type-foo' + ); + expect(services.alertFactory.create).not.toHaveBeenCalledWith( + 'bar_env-bar_type-bar' + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'env-foo', + reason: + 'Failed transactions is 10% in the last 5 mins for service: foo, env: env-foo, type: type-foo. Alert when > 10%.', + threshold: 10, + triggerValue: '10', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', + }); + }); + + it('sends alert when service.environment field does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionErrorRateRuleType({ + ...dependencies, + }); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['foo', 'ENVIRONMENT_NOT_DEFINED', 'type-foo'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, + }, + ], + }, + }, + { + key: ['bar', 'ENVIRONMENT_NOT_DEFINED', 'type-bar'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'transaction.type'], + }; + + await executor({ params }); + + expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + + expect(services.alertFactory.create).toHaveBeenCalledWith( + 'foo_ENVIRONMENT_NOT_DEFINED_type-foo' + ); + expect(services.alertFactory.create).not.toHaveBeenCalledWith( + 'bar_ENVIRONMENT_NOT_DEFINED_type-bar' + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'Not defined', + reason: + 'Failed transactions is 10% in the last 5 mins for service: foo, env: Not defined, type: type-foo. Alert when > 10%.', + threshold: 10, + triggerValue: '10', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=ENVIRONMENT_ALL', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts index 26b5847a205f1..6fa9319b71753 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts @@ -21,11 +21,7 @@ import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions'; -import { - ENVIRONMENT_NOT_DEFINED, - getEnvironmentEsField, - getEnvironmentLabel, -} from '../../../../../common/environment_filter_values'; +import { getEnvironmentEsField } from '../../../../../common/environment_filter_values'; import { EVENT_OUTCOME, PROCESSOR_EVENT, @@ -59,6 +55,8 @@ import { getServiceGroupFields, getServiceGroupFieldsAgg, } from '../get_service_group_fields'; +import { getGroupByTerms } from '../utils/get_groupby_terms'; +import { getGroupByActionVariables } from '../utils/get_groupby_action_variables'; const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.TransactionErrorRate]; @@ -90,6 +88,7 @@ export function registerTransactionErrorRateRuleType({ apmActionVariables.transactionName, apmActionVariables.threshold, apmActionVariables.transactionType, + apmActionVariables.transactionName, apmActionVariables.triggerValue, apmActionVariables.viewInAppUrl, ], @@ -98,6 +97,16 @@ export function registerTransactionErrorRateRuleType({ minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ services, spaceId, params: ruleParams }) => { + const predefinedGroupby = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + ]; + + const allGroupbyFields = Array.from( + new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])]) + ); + const config = await firstValueFrom(config$); const { savedObjectsClient, scopedClusterClient } = services; @@ -160,14 +169,7 @@ export function registerTransactionErrorRateRuleType({ aggs: { series: { multi_terms: { - terms: [ - { field: SERVICE_NAME }, - { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - }, - { field: TRANSACTION_TYPE }, - ], + terms: [...getGroupByTerms(allGroupbyFields)], size: 1000, order: { _count: 'desc' as const }, }, @@ -194,8 +196,17 @@ export function registerTransactionErrorRateRuleType({ } const results = []; + for (const bucket of response.aggregations.series.buckets) { - const [serviceName, environment, transactionType] = bucket.key; + const groupByFields = bucket.key.reduce( + (obj, bucketKey, bucketIndex) => { + obj[allGroupbyFields[bucketIndex]] = bucketKey; + return obj; + }, + {} as Record + ); + + const bucketKey = bucket.key; const failedOutcomeBucket = bucket.outcomes.buckets.find( (outcomeBucket) => outcomeBucket.key === EventOutcome.failure @@ -209,47 +220,32 @@ export function registerTransactionErrorRateRuleType({ if (errorRate >= ruleParams.threshold) { results.push({ - serviceName, - environment, - transactionType, errorRate, sourceFields: getServiceGroupFields(failedOutcomeBucket), + groupByFields, + bucketKey, }); } } results.forEach((result) => { - const { - serviceName, - environment, - transactionType, - errorRate, - sourceFields, - } = result; + const { errorRate, sourceFields, groupByFields, bucketKey } = result; const reasonMessage = formatTransactionErrorRateReason({ threshold: ruleParams.threshold, measured: errorRate, asPercent, - serviceName, windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, + groupByFields, }); - const id = [ - ApmRuleType.TransactionErrorRate, - serviceName, - transactionType, - environment, - ruleParams.transactionName, - ] - .filter((name) => name) - .join('_'); - const relativeViewInAppUrl = getAlertUrlTransaction( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], - transactionType + groupByFields[SERVICE_NAME], + getEnvironmentEsField(groupByFields[SERVICE_ENVIRONMENT])?.[ + SERVICE_ENVIRONMENT + ], + groupByFields[TRANSACTION_TYPE] ); const viewInAppUrl = addSpaceIdToPath( @@ -258,34 +254,33 @@ export function registerTransactionErrorRateRuleType({ relativeViewInAppUrl ); + const groupByActionVariables = + getGroupByActionVariables(groupByFields); + services .alertWithLifecycle({ - id, + id: bucketKey.join('_'), fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), - [TRANSACTION_TYPE]: transactionType, [TRANSACTION_NAME]: ruleParams.transactionName, [PROCESSOR_EVENT]: ProcessorEvent.transaction, [ALERT_EVALUATION_VALUE]: errorRate, [ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold, [ALERT_REASON]: reasonMessage, ...sourceFields, + ...groupByFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - environment: getEnvironmentLabel(environment), interval: formatDurationFromTimeUnitChar( ruleParams.windowSize, ruleParams.windowUnit as TimeUnitChar ), reason: reasonMessage, - serviceName, threshold: ruleParams.threshold, - transactionType, transactionName: ruleParams.transactionName, triggerValue: asDecimalOrInteger(errorRate), viewInAppUrl, + ...groupByActionVariables, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.ts new file mode 100644 index 0000000000000..96e0cbd7f09c1 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGroupByActionVariables } from './get_groupby_action_variables'; + +describe('getGroupByActionVariables', () => { + it('should rename action variables', () => { + const result = getGroupByActionVariables({ + 'service.name': 'opbeans-java', + 'service.environment': 'development', + 'transaction.type': 'request', + 'transaction.name': 'tx-java', + 'error.grouping_key': 'error-key-0', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "environment": "development", + "errorGroupingKey": "error-key-0", + "serviceName": "opbeans-java", + "transactionName": "tx-java", + "transactionType": "request", + } + `); + }); + + it('environment action variable should have value "Not defined"', () => { + const result = getGroupByActionVariables({ + 'service.name': 'opbeans-java', + 'service.environment': 'ENVIRONMENT_NOT_DEFINED', + 'transaction.type': 'request', + 'transaction.name': 'tx-java', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "environment": "Not defined", + "serviceName": "opbeans-java", + "transactionName": "tx-java", + "transactionType": "request", + } + `); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts new file mode 100644 index 0000000000000..fd245bb3a1d0c --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFieldValueLabel } from '../../../../../common/rules/apm_rule_types'; +import { + ERROR_GROUP_ID, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../../../common/es_fields/apm'; + +const renameActionVariable = (field: string): string => { + switch (field) { + case SERVICE_NAME: + return 'serviceName'; + case SERVICE_ENVIRONMENT: + return 'environment'; + case TRANSACTION_TYPE: + return 'transactionType'; + case TRANSACTION_NAME: + return 'transactionName'; + case ERROR_GROUP_ID: + return 'errorGroupingKey'; + default: + return field; + } +}; + +export const getGroupByActionVariables = ( + groupByFields: Record +): Record => { + return Object.keys(groupByFields).reduce>( + (acc, cur) => { + acc[renameActionVariable(cur)] = getFieldValueLabel( + cur, + groupByFields[cur] + ); + return acc; + }, + {} + ); +}; diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts new file mode 100644 index 0000000000000..01d96f787bd9b --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGroupByTerms } from './get_groupby_terms'; + +describe('get terms fields for multi-terms aggregation', () => { + it('returns terms array based on the group-by fields', () => { + const ruleParams = { + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([ + { field: 'service.name' }, + { field: 'service.environment', missing: 'ENVIRONMENT_NOT_DEFINED' }, + { field: 'transaction.type' }, + { field: 'transaction.name' }, + ]); + }); + + it('returns an empty terms array when group-by is undefined', () => { + const ruleParams = { groupBy: undefined }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([]); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts new file mode 100644 index 0000000000000..22b52fa6a3116 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; +import { SERVICE_ENVIRONMENT } from '../../../../../common/es_fields/apm'; + +export const getGroupByTerms = (groupByFields: string[] | undefined = []) => { + return groupByFields.map((groupByField) => { + return { + field: groupByField, + missing: + groupByField === SERVICE_ENVIRONMENT + ? ENVIRONMENT_NOT_DEFINED.value + : undefined, + }; + }); +}; diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts new file mode 100644 index 0000000000000..d511b22b13274 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts @@ -0,0 +1,214 @@ +/* + * 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 { + kqlQuery, + rangeQuery, + termQuery, +} from '@kbn/observability-plugin/server'; +import { keyBy } from 'lodash'; +import { getBucketSize } from '../../../common/utils/get_bucket_size'; +import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { + SERVICE_NAME, + TRANSACTION_DURATION, +} from '../../../common/es_fields/apm'; +import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; +import { Coordinate } from '../../../typings/timeseries'; +import { ApmDocumentType } from '../../../common/document_type'; +import { RollupInterval } from '../../../common/rollup'; + +interface MobileDetailedStatistics { + fieldName: string; + latency: Coordinate[]; + throughput: Coordinate[]; +} + +export interface MobileDetailedStatisticsResponse { + currentPeriod: Record; + previousPeriod: Record; +} + +interface Props { + kuery: string; + apmEventClient: APMEventClient; + serviceName: string; + environment: string; + start: number; + end: number; + field: string; + fieldValues: string[]; + offset?: string; +} + +async function getMobileDetailedStatisticsByField({ + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + offset, +}: Props) { + const { startWithOffset, endWithOffset } = getOffsetInMs({ + start, + end, + offset, + }); + + const { intervalString } = getBucketSize({ + start: startWithOffset, + end: endWithOffset, + minBucketSize: 60, + }); + + const response = await apmEventClient.search( + `get_mobile_detailed_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(startWithOffset, endWithOffset), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + detailed_statistics: { + terms: { + field, + include: fieldValues, + size: fieldValues.length, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: startWithOffset, + max: endWithOffset, + }, + }, + aggs: { + latency: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, + }, + }, + }, + }, + } + ); + + const buckets = response.aggregations?.detailed_statistics.buckets ?? []; + + return buckets.map((bucket) => { + const fieldName = bucket.key as string; + const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: getLatencyValue({ + latencyAggregationType: LatencyAggregationType.avg, + aggregation: timeseriesBucket.latency, + }), + })); + const throughput = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: timeseriesBucket.doc_count, + })); + + return { + fieldName, + latency, + throughput, + }; + }); +} + +export async function getMobileDetailedStatisticsByFieldPeriods({ + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + offset, +}: Props): Promise { + const commonProps = { + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + }; + + const currentPeriodPromise = getMobileDetailedStatisticsByField({ + ...commonProps, + }); + + const previousPeriodPromise = offset + ? getMobileDetailedStatisticsByField({ + ...commonProps, + offset, + }) + : []; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const firstCurrentPeriod = currentPeriod?.[0]; + return { + currentPeriod: keyBy(currentPeriod, 'fieldName'), + previousPeriod: keyBy( + previousPeriod.map((data) => { + return { + ...data, + latency: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod?.latency, + previousPeriodTimeseries: data.latency, + }), + throughput: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod?.throughput, + previousPeriodTimeseries: data.throughput, + }), + }; + }), + 'fieldName' + ), + }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts new file mode 100644 index 0000000000000..a5783997e391b --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + termQuery, + kqlQuery, + rangeQuery, +} from '@kbn/observability-plugin/server'; +import { merge } from 'lodash'; +import { + SERVICE_NAME, + SESSION_ID, + TRANSACTION_DURATION, + ERROR_TYPE, +} from '../../../common/es_fields/apm'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { calculateThroughputWithRange } from '../../lib/helpers/calculate_throughput'; +import { ApmDocumentType } from '../../../common/document_type'; +import { RollupInterval } from '../../../common/rollup'; + +interface Props { + kuery: string; + apmEventClient: APMEventClient; + serviceName: string; + environment: string; + start: number; + end: number; + field: string; +} + +export interface MobileMainStatisticsResponse { + mainStatistics: Array<{ + name: string | number; + latency: number | null; + throughput: number; + crashRate?: number; + }>; +} + +export async function getMobileMainStatisticsByField({ + kuery, + apmEventClient, + serviceName, + environment, + start, + end, + field, +}: Props) { + async function getMobileTransactionEventStatistics() { + const response = await apmEventClient.search( + `get_mobile_main_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + main_statistics: { + terms: { + field, + size: 1000, + }, + aggs: { + latency: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, + }, + }, + } + ); + + return ( + response.aggregations?.main_statistics.buckets.map((bucket) => { + return { + name: bucket.key, + latency: getLatencyValue({ + latencyAggregationType: LatencyAggregationType.avg, + aggregation: bucket.latency, + }), + throughput: calculateThroughputWithRange({ + start, + end, + value: bucket.doc_count, + }), + }; + }) ?? [] + ); + } + + async function getMobileErrorEventStatistics() { + const response = await apmEventClient.search( + `get_mobile_transaction_events_main_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.ErrorEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + main_statistics: { + terms: { + field, + size: 1000, + }, + aggs: { + sessions: { + cardinality: { + field: SESSION_ID, + }, + }, + crashes: { + filter: { + term: { + [ERROR_TYPE]: 'crash', + }, + }, + }, + }, + }, + }, + }, + } + ); + return ( + response.aggregations?.main_statistics.buckets.map((bucket) => { + return { + name: bucket.key, + crashRate: bucket.crashes.doc_count / bucket.sessions.value ?? 0, + }; + }) ?? [] + ); + } + + const [transactioEventStatistics, errorEventStatistics] = await Promise.all([ + getMobileTransactionEventStatistics(), + getMobileErrorEventStatistics(), + ]); + + const mainStatistics = merge(transactioEventStatistics, errorEventStatistics); + + return { mainStatistics }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/route.ts b/x-pack/plugins/apm/server/routes/mobile/route.ts index 3f6de9de1b696..3323172a5e6d5 100644 --- a/x-pack/plugins/apm/server/routes/mobile/route.ts +++ b/x-pack/plugins/apm/server/routes/mobile/route.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { toNumberRt } from '@kbn/io-ts-utils'; +import { jsonRt, toNumberRt } from '@kbn/io-ts-utils'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; @@ -26,6 +26,14 @@ import { getMobileTermsByField, MobileTermsByFieldResponse, } from './get_mobile_terms_by_field'; +import { + getMobileMainStatisticsByField, + MobileMainStatisticsResponse, +} from './get_mobile_main_statistics_by_field'; +import { + getMobileDetailedStatisticsByFieldPeriods, + MobileDetailedStatisticsResponse, +} from './get_mobile_detailed_statistics_by_field'; import { getMobileMostUsedCharts, MobileMostUsedChartResponse, @@ -329,6 +337,84 @@ const mobileTermsByFieldRoute = createApmServerRoute({ }, }); +const mobileMainStatisticsByField = createApmServerRoute({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + environmentRt, + t.type({ + field: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const apmEventClient = await getApmEventClient(resources); + const { params } = resources; + const { serviceName } = params.path; + const { kuery, environment, start, end, field } = params.query; + + return await getMobileMainStatisticsByField({ + kuery, + environment, + start, + end, + serviceName, + apmEventClient, + field, + }); + }, +}); + +const mobileDetailedStatisticsByField = createApmServerRoute({ + endpoint: + 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + offsetRt, + environmentRt, + t.type({ + field: t.string, + fieldValues: jsonRt.pipe(t.array(t.string)), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const apmEventClient = await getApmEventClient(resources); + const { params } = resources; + const { serviceName } = params.path; + const { kuery, environment, start, end, field, offset, fieldValues } = + params.query; + + return await getMobileDetailedStatisticsByFieldPeriods({ + kuery, + environment, + start, + end, + serviceName, + apmEventClient, + field, + fieldValues, + offset, + }); + }, +}); + export const mobileRouteRepository = { ...mobileFiltersRoute, ...mobileChartsRoute, @@ -337,4 +423,6 @@ export const mobileRouteRepository = { ...mobileStatsRoute, ...mobileLocationStatsRoute, ...mobileTermsByFieldRoute, + ...mobileMainStatisticsByField, + ...mobileDetailedStatisticsByField, }; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index f247945c7c700..d33cf6efd4fbf 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -189,7 +189,7 @@ describe('CaseViewPage', () => { expect(result.getAllByText(data.createdBy.fullName!)[0]).toBeInTheDocument(); expect( - within(result.getByTestId('description-action')).getByTestId('user-action-markdown') + within(result.getByTestId('description')).getByTestId('scrollable-markdown') ).toHaveTextContent(data.description); expect(result.getByTestId('case-view-status-action-button')).toHaveTextContent( @@ -604,15 +604,15 @@ describe('CaseViewPage', () => { }); describe('description', () => { - it('renders the descriptions user correctly', async () => { + it('renders the description correctly', async () => { appMockRenderer.render(); - const description = within(screen.getByTestId('description-action')); + const description = within(screen.getByTestId('description')); - expect(await description.findByText('Leslie Knope')).toBeInTheDocument(); + expect(await description.findByText(caseData.description)).toBeInTheDocument(); }); - it('should display description isLoading', async () => { + it('should display description when case is loading', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, isLoading: true, @@ -622,8 +622,7 @@ describe('CaseViewPage', () => { appMockRenderer.render(); await waitFor(() => { - expect(screen.getByTestId('description-loading')).toBeInTheDocument(); - expect(screen.queryByTestId('description-action')).not.toBeInTheDocument(); + expect(screen.getByTestId('description')).toBeInTheDocument(); }); }); @@ -636,11 +635,11 @@ describe('CaseViewPage', () => { userEvent.type(await screen.findByTestId('euiMarkdownEditorTextArea'), newComment); - userEvent.click(await screen.findByTestId('editable-description-edit-icon')); + userEvent.click(await screen.findByTestId('description-edit-icon')); userEvent.type(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], 'Edited!'); - userEvent.click(screen.getByTestId('user-action-save-markdown')); + userEvent.click(screen.getByTestId('editable-save-markdown')); expect(await screen.findByTestId('euiMarkdownEditorTextArea')).toHaveTextContent( newComment diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index aa28340c55308..7386d8bcd7ea1 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -493,17 +493,38 @@ describe.skip('Case View Page activity tab', () => { }); describe('User actions', () => { - it('renders the descriptions user correctly', async () => { + it('renders the description correctly', async () => { appMockRender = createAppMockRenderer(); const result = appMockRender.render(); - const description = within(result.getByTestId('description-action')); + const description = within(result.getByTestId('description')); await waitFor(() => { - expect(description.getByText('Leslie Knope')).toBeInTheDocument(); + expect(description.getByText(caseData.description)).toBeInTheDocument(); }); }); + it('renders edit description user action correctly', async () => { + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { + userActions: [ + getUserAction('description', 'create'), + getUserAction('description', 'update'), + ], + }, + }); + + appMockRender = createAppMockRenderer(); + const result = appMockRender.render(); + + const userActions = within(result.getAllByTestId('user-actions-list')[1]); + + expect( + userActions.getByTestId('description-update-action-description-update') + ).toBeInTheDocument(); + }); + it('renders the unassigned users correctly', async () => { useFindCaseUserActionsMock.mockReturnValue({ ...defaultUseFindCaseUserActions, diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index b178fe37bb450..c4f387c617417 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -37,7 +37,7 @@ import { convertToCaseUserWithProfileInfo } from '../../user_profiles/user_conve import type { UserActivityParams } from '../../user_actions_activity_bar/types'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import { CaseViewTabs } from '../case_view_tabs'; -import { DescriptionWrapper } from '../../description/description_wrapper'; +import { Description } from '../../description'; const buildUserProfilesMap = (users?: CaseUsers): Map => { const userProfiles = new Map(); @@ -210,10 +210,9 @@ export const CaseViewActivity = ({ <> - diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index 8fc80c1a0aba3..d0be88f729a69 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -76,6 +76,14 @@ export const EDIT_DESCRIPTION = i18n.translate('xpack.cases.caseView.edit.descri defaultMessage: 'Edit description', }); +export const COLLAPSE_DESCRIPTION = i18n.translate('xpack.cases.caseView.description.collapse', { + defaultMessage: 'Collapse description', +}); + +export const EXPAND_DESCRIPTION = i18n.translate('xpack.cases.caseView.description.expand', { + defaultMessage: 'Expand description', +}); + export const QUOTE = i18n.translate('xpack.cases.caseView.edit.quote', { defaultMessage: 'Quote', }); diff --git a/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx b/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx deleted file mode 100644 index 40fcf91ffe1fa..0000000000000 --- a/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { waitFor, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -// eslint-disable-next-line @kbn/eslint/module_migration -import routeData from 'react-router'; - -import { useUpdateComment } from '../../containers/use_update_comment'; -import { basicCase } from '../../containers/mock'; -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; -import { DescriptionWrapper } from './description_wrapper'; -import { waitForComponentToUpdate } from '../../common/test_utils'; - -const onUpdateField = jest.fn(); - -const defaultProps = { - data: basicCase, - onUpdateField, - isLoadingDescription: false, - userProfiles: new Map(), -}; - -jest.mock('../../containers/use_update_comment'); -jest.mock('../../common/lib/kibana'); - -const useUpdateCommentMock = useUpdateComment as jest.Mock; -const patchComment = jest.fn(); - -// FLAKY: -describe.skip(`DescriptionWrapper`, () => { - const sampleData = { - content: 'what a great comment update', - }; - let appMockRender: AppMockRenderer; - - beforeEach(() => { - jest.clearAllMocks(); - useUpdateCommentMock.mockReturnValue({ - isLoadingIds: [], - patchComment, - }); - - jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' }); - appMockRender = createAppMockRenderer(); - }); - - it('renders correctly', () => { - appMockRender.render(); - - expect(screen.getByTestId('description-action')).toBeInTheDocument(); - }); - - it('renders loading state', () => { - appMockRender.render(); - - expect(screen.getByTestId('description-loading')).toBeInTheDocument(); - }); - - it('calls update description when description markdown is saved', async () => { - const newData = { - content: 'what a great comment update', - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('editable-description-edit-icon')); - - userEvent.clear(screen.getAllByTestId('euiMarkdownEditorTextArea')[0]); - - userEvent.type(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], newData.content); - - userEvent.click(screen.getByTestId('user-action-save-markdown')); - - await waitForComponentToUpdate(); - - await waitFor(() => { - expect(screen.queryByTestId('user-action-markdown-form')).not.toBeInTheDocument(); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/description/description_wrapper.tsx b/x-pack/plugins/cases/public/components/description/description_wrapper.tsx deleted file mode 100644 index 77b29abfa0c0d..0000000000000 --- a/x-pack/plugins/cases/public/components/description/description_wrapper.tsx +++ /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 type { EuiCommentProps } from '@elastic/eui'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { EuiCommentList, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - -import React, { useMemo } from 'react'; -import styled from 'styled-components'; - -import type { Case } from '../../containers/types'; -import type { OnUpdateFields } from '../case_view/types'; -import { getDescriptionUserAction } from '../user_actions/description'; -import { useUserActionsHandler } from '../user_actions/use_user_actions_handler'; -import { useCasesContext } from '../cases_context/use_cases_context'; - -interface DescriptionWrapperProps { - data: Case; - isLoadingDescription: boolean; - userProfiles: Map; - onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; -} - -const MyEuiCommentList = styled(EuiCommentList)` - & .euiComment > [class*='euiTimelineItemIcon-top'] { - display: none; - } - - & .draftFooter { - & .euiCommentEvent__body { - padding: 0; - } - } - - & .euiComment.isEdit { - & .euiCommentEvent { - border: none; - box-shadow: none; - } - - & .euiCommentEvent__body { - padding: 0; - } - - & .euiCommentEvent__header { - display: none; - } - } -`; - -export const DescriptionWrapper = React.memo( - ({ - data: caseData, - isLoadingDescription, - userProfiles, - onUpdateField, - }: DescriptionWrapperProps) => { - const { appId } = useCasesContext(); - - const { commentRefs, manageMarkdownEditIds, handleManageMarkdownEditId } = - useUserActionsHandler(); - - const descriptionCommentListObj: EuiCommentProps = useMemo( - () => - getDescriptionUserAction({ - appId, - caseData, - commentRefs, - userProfiles, - manageMarkdownEditIds, - isLoadingDescription, - onUpdateField, - handleManageMarkdownEditId, - }), - [ - appId, - caseData, - commentRefs, - manageMarkdownEditIds, - isLoadingDescription, - userProfiles, - onUpdateField, - handleManageMarkdownEditId, - ] - ); - - return isLoadingDescription ? ( - - - - - - ) : ( - - ); - } -); - -DescriptionWrapper.displayName = 'DescriptionWrapper'; diff --git a/x-pack/plugins/cases/public/components/description/index.test.tsx b/x-pack/plugins/cases/public/components/description/index.test.tsx new file mode 100644 index 0000000000000..bf92733b4526a --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/index.test.tsx @@ -0,0 +1,155 @@ +/* + * 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 { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { basicCase } from '../../containers/mock'; + +import { Description } from '.'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, noUpdateCasesPermissions, TestProviders } from '../../common/mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +const defaultProps = { + appId: 'testAppId', + caseData: { + ...basicCase, + }, + isLoadingDescription: false, +}; + +describe('Description', () => { + const onUpdateField = jest.fn(); + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders description correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('description')).toBeInTheDocument(); + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + + it('hides and shows the description correctly when collapse button clicked', async () => { + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-collapse-icon')); + + await waitFor(() => { + expect(screen.queryByText('Security banana Issue')).not.toBeInTheDocument(); + }); + + userEvent.click(res.getByTestId('description-collapse-icon')); + + await waitFor(() => { + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + }); + + it('shows textarea on edit click', async () => { + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + await waitFor(() => { + expect(screen.getByTestId('euiMarkdownEditorTextArea')).toBeInTheDocument(); + }); + }); + + it('edits the description correctly when saved', async () => { + const editedDescription = 'New updated description'; + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), editedDescription); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onUpdateField).toHaveBeenCalledWith({ key: 'description', value: editedDescription }); + }); + }); + + it('keeps the old description correctly when canceled', async () => { + const editedDescription = 'New updated description'; + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), editedDescription); + + userEvent.click(screen.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(onUpdateField).not.toHaveBeenCalled(); + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + }); + + it('should hide the edit button when the user does not have update permissions', () => { + appMockRender.render( + + + + ); + + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + expect(screen.queryByTestId('description-edit-icon')).not.toBeInTheDocument(); + }); + + describe('draft message', () => { + const draftStorageKey = `cases.testAppId.basic-case-id.description.markdownEditor`; + + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should show unsaved draft message correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('description-unsaved-draft')).toBeInTheDocument(); + }); + + it('should not show unsaved draft message when loading', async () => { + appMockRender.render( + + ); + + expect(screen.queryByTestId('description-unsaved-draft')).not.toBeInTheDocument(); + }); + + it('should not show unsaved draft message when description and storage value are same', async () => { + const props = { + ...defaultProps, + caseData: { ...defaultProps.caseData, description: 'value set in storage' }, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('description-unsaved-draft')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/description/index.tsx b/x-pack/plugins/cases/public/components/description/index.tsx new file mode 100644 index 0000000000000..6ebbf8edd6f45 --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/index.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { css } from '@emotion/react'; +import { + EuiButtonIcon, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + useEuiTheme, +} from '@elastic/eui'; + +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import * as i18n from '../user_actions/translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; +import { EditableMarkdown, ScrollableMarkdown } from '../markdown_editor'; +import type { Case } from '../../containers/types'; +import type { OnUpdateFields } from '../case_view/types'; +import { schema } from './schema'; + +const DESCRIPTION_ID = 'description'; +export interface DescriptionProps { + caseData: Case; + isLoadingDescription: boolean; + onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; +} + +const DescriptionFooter = styled(EuiFlexItem)` + ${({ theme }) => ` + border-top: ${theme.eui.euiBorderThin}; + padding: ${theme.eui.euiSizeS}; + `} +`; + +const Panel = styled(EuiPanel)` + padding: 0; +`; + +const Header = styled(EuiFlexGroup)` + ${({ theme }) => ` + display: flex; + padding: ${theme.eui.euiSizeS}; + align-items: center; + `} +`; + +const Body = styled(EuiFlexItem)` + ${({ theme }) => ` + padding: ${theme.eui.euiSize}; + padding-top: 0; + + > div { + padding: 0; + } + `} +`; + +const getDraftDescription = ( + applicationId = '', + caseId: string, + commentId: string +): string | null => { + const draftStorageKey = getMarkdownEditorStorageKey(applicationId, caseId, commentId); + + return sessionStorage.getItem(draftStorageKey); +}; + +export const Description = ({ + caseData, + onUpdateField, + isLoadingDescription, +}: DescriptionProps) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isEditable, setIsEditable] = useState(false); + + const descriptionRef = useRef(null); + const { euiTheme } = useEuiTheme(); + const { appId, permissions } = useCasesContext(); + + const { + clearDraftComment: clearLensDraftComment, + draftComment: lensDraftComment, + hasIncomingLensState, + } = useLensDraftComment(); + + const handleOnChangeEditable = useCallback(() => { + clearLensDraftComment(); + setIsEditable(false); + }, [setIsEditable, clearLensDraftComment]); + + const handleOnSave = useCallback( + (content: string) => { + onUpdateField({ key: DESCRIPTION_ID, value: content }); + setIsEditable(false); + }, + [onUpdateField, setIsEditable] + ); + + const toggleCollapse = () => setIsCollapsed((oldValue: boolean) => !oldValue); + + const draftDescription = getDraftDescription(appId, caseData.id, DESCRIPTION_ID); + + if ( + hasIncomingLensState && + lensDraftComment !== null && + lensDraftComment?.commentId === DESCRIPTION_ID && + !isEditable + ) { + setIsEditable(true); + } + + const hasUnsavedChanges = + draftDescription && draftDescription !== caseData.description && !isLoadingDescription; + + return isEditable ? ( + + ) : ( + + + +
+ + + {i18n.DESCRIPTION} + + + + + {permissions.update ? ( + setIsEditable(true)} + data-test-subj="description-edit-icon" + /> + ) : null} + + + + + +
+
+ {!isCollapsed ? ( + + + + ) : null} + {hasUnsavedChanges ? ( + + + {i18n.UNSAVED_DRAFT_DESCRIPTION} + + + ) : null} +
+
+ ); +}; + +Description.displayName = 'Description'; diff --git a/x-pack/plugins/cases/public/components/description/schema.ts b/x-pack/plugins/cases/public/components/description/schema.ts new file mode 100644 index 0000000000000..8c47b700adeb5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/schema.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from '../../common/translations'; + +const { emptyField } = fieldValidators; +export interface Content { + content: string; +} +export const schema: FormSchema = { + content: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx similarity index 76% rename from x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx rename to x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx index d2c5f692d396b..86772136c18d0 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx @@ -12,12 +12,12 @@ import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib import * as i18n from '../case_view/translations'; -interface UserActionMarkdownFooterProps { +interface EditableMarkdownFooterProps { handleSaveAction: () => Promise; handleCancelAction: () => void; } -const UserActionMarkdownFooterComponent: React.FC = ({ +const EditableMarkdownFooterComponent: React.FC = ({ handleSaveAction, handleCancelAction, }) => { @@ -27,7 +27,7 @@ const UserActionMarkdownFooterComponent: React.FC ); }; -UserActionMarkdownFooterComponent.displayName = 'UserActionMarkdownFooterComponent'; +EditableMarkdownFooterComponent.displayName = 'EditableMarkdownFooterComponent'; -export const UserActionMarkdownFooter = React.memo(UserActionMarkdownFooterComponent); +export const EditableMarkdownFooter = React.memo(EditableMarkdownFooterComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx new file mode 100644 index 0000000000000..59d424fdd82c8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx @@ -0,0 +1,220 @@ +/* + * 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 { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { waitFor, fireEvent, screen, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { EditableMarkdown } from '.'; +import { TestProviders } from '../../common/mock'; +import type { Content } from '../user_actions/schema'; +import { schema } from '../user_actions/schema'; + +jest.mock('../../common/lib/kibana'); + +const onChangeEditable = jest.fn(); +const onSaveContent = jest.fn(); + +const newValue = 'Hello from Tehas'; +const emptyValue = ''; +const hyperlink = `[hyperlink](http://elastic.co)`; +const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; +const content = `A link to a timeline ${hyperlink}`; + +const editorRef: React.MutableRefObject = { current: null }; +const defaultProps = { + content, + id: 'markdown-id', + caseId: 'caseId', + isEditable: true, + draftStorageKey, + onChangeEditable, + onSaveContent, + fieldName: 'content', + formSchema: schema, + editorRef, +}; + +describe('EditableMarkdown', () => { + const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ + children, + testProviderProps = {}, + }) => { + const { form } = useForm({ + defaultValue: { content }, + options: { stripEmptyFields: false }, + schema, + }); + + return ( + +
{children}
+
+ ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + sessionStorage.removeItem(draftStorageKey); + }); + + it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { + render( + + + + ); + + fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + + it('Does not call onSaveContent if no change from current text', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + expect(onSaveContent).not.toHaveBeenCalled(); + }); + + it('Save button disabled if current text is empty', async () => { + render( + + + + ); + + fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { value: emptyValue }); + + await waitFor(() => { + expect(screen.getByTestId('editable-save-markdown')).toHaveProperty('disabled'); + }); + }); + + it('Cancel button click calls only onChangeEditable', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(onSaveContent).not.toHaveBeenCalled(); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + + describe('draft comment ', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + sessionStorage.removeItem(draftStorageKey); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Save button click clears session storage', async () => { + const result = render( + + + + ); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + + fireEvent.click(result.getByTestId(`editable-save-markdown`)); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + it('Cancel button click clears session storage', async () => { + const result = render( + + + + ); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(''); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + }); + + fireEvent.click(result.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + describe('existing storage key', () => { + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should have session storage value same as draft comment', async () => { + const result = render( + + + + ); + + expect(result.getByText('value set in storage')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx new file mode 100644 index 0000000000000..1706e30fcc8f4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; + +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { MarkdownEditorForm } from '.'; +import { removeItemFromSessionStorage } from '../utils'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { getMarkdownEditorStorageKey } from './utils'; +import { EditableMarkdownFooter } from './editable_markdown_footer'; + +export interface EditableMarkdownRefObject { + setComment: (newComment: string) => void; +} +interface EditableMarkdownRendererProps { + content: string; + id: string; + caseId: string; + fieldName: string; + onChangeEditable: (id: string) => void; + onSaveContent: (content: string) => void; + editorRef: React.MutableRefObject; + formSchema: FormSchema<{ content: string }> | undefined; +} + +const EditableMarkDownRenderer = forwardRef< + EditableMarkdownRefObject, + EditableMarkdownRendererProps +>( + ( + { id, content, caseId, fieldName, onChangeEditable, onSaveContent, editorRef, formSchema }, + ref + ) => { + const { appId } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); + const initialState = { content }; + + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema: formSchema, + }); + const { submit, setFieldValue } = form; + + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue, fieldName] + ); + + useImperativeHandle(ref, () => ({ + setComment, + editor: editorRef.current, + })); + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + removeItemFromSessionStorage(draftStorageKey); + }, [id, onChangeEditable, draftStorageKey]); + + const handleSaveAction = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid && data.content !== content) { + onSaveContent(data.content); + } + onChangeEditable(id); + removeItemFromSessionStorage(draftStorageKey); + }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); + + return ( +
+ + ), + initialValue: content, + }} + /> + + ); + } +); + +EditableMarkDownRenderer.displayName = 'EditableMarkDownRenderer'; + +export const EditableMarkdown = React.memo(EditableMarkDownRenderer); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx index e77a36d48f7d9..214779263f773 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx @@ -9,3 +9,5 @@ export * from './types'; export * from './renderer'; export * from './editor'; export * from './eui_form'; +export * from './scrollable_markdown_renderer'; +export * from './editable_markdown_renderer'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx new file mode 100644 index 0000000000000..05ea034e776a3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { ScrollableMarkdown } from '.'; + +const content = 'This is sample content'; + +describe('ScrollableMarkdown', () => { + let appMockRenderer: AppMockRenderer; + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRenderer.render(); + expect(screen.getByTestId('scrollable-markdown')).toBeInTheDocument(); + expect(screen.getByText(content)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx new file mode 100644 index 0000000000000..dd5ab2ec8241a --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { MarkdownRenderer } from './renderer'; + +export const ContentWrapper = styled.div` + padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; + text-overflow: ellipsis; + word-break: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; +`; + +const ScrollableMarkdownRenderer = ({ content }: { content: string }) => { + return ( + + {content} + + ); +}; + +ScrollableMarkdownRenderer.displayName = 'ScrollableMarkdownRenderer'; + +export const ScrollableMarkdown = React.memo(ScrollableMarkdownRenderer); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx index dccf5ae0b91a2..d4086805d7cd9 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx @@ -13,8 +13,7 @@ import type { UserActionBuilder, UserActionBuilderArgs } from '../types'; import { UserActionTimestamp } from '../timestamp'; import type { SnakeToCamelCase } from '../../../../common/types'; import { UserActionCopyLink } from '../copy_link'; -import { MarkdownRenderer } from '../../markdown_editor'; -import { ContentWrapper } from '../markdown_form'; +import { ScrollableMarkdown } from '../../markdown_editor'; import { HostIsolationCommentEvent } from './host_isolation_event'; import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver'; @@ -57,9 +56,7 @@ export const createActionAttachmentUserActionBuilder = ({ timelineAvatarAriaLabel: actionIconName, actions: , children: comment.comment.trim().length > 0 && ( - - {comment.comment} - + ), }, ]; diff --git a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx index 0f0d284130519..d7f393f987b92 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx @@ -7,65 +7,27 @@ import React from 'react'; import { EuiCommentList } from '@elastic/eui'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { Actions } from '../../../common/api'; import { getUserAction } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; -import { createDescriptionUserActionBuilder, getDescriptionUserAction } from './description'; +import { createDescriptionUserActionBuilder } from './description'; import { getMockBuilderArgs } from './mock'; -import userEvent from '@testing-library/user-event'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); describe('createDescriptionUserActionBuilder ', () => { - const onUpdateField = jest.fn(); const builderArgs = getMockBuilderArgs(); beforeEach(() => { jest.clearAllMocks(); }); - it('renders correctly description', async () => { - const descriptionUserAction = getDescriptionUserAction({ - ...builderArgs, - onUpdateField, - isLoadingDescription: false, - }); - - render( - - - - ); - - expect(screen.getByText('added description')).toBeInTheDocument(); - expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); - }); - - it('edits the description correctly', async () => { - const descriptionUserAction = getDescriptionUserAction({ - ...builderArgs, - onUpdateField, - isLoadingDescription: false, - }); - - const res = render( - - - - ); - - userEvent.click(res.getByTestId('editable-description-edit-icon')); - - await waitFor(() => { - expect(builderArgs.handleManageMarkdownEditId).toHaveBeenCalledWith('description'); - }); - }); - - it('renders correctly when editing a description', async () => { + it('renders correctly', async () => { const userAction = getUserAction('description', Actions.update); + // @ts-ignore no need to pass all the arguments const builder = createDescriptionUserActionBuilder({ ...builderArgs, userAction, diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx index 236252927ad6e..42deabb9f1346 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -5,108 +5,12 @@ * 2.0. */ -import React from 'react'; -import classNames from 'classnames'; -import type { EuiCommentProps } from '@elastic/eui'; -import styled from 'styled-components'; -import { EuiText, EuiButtonIcon } from '@elastic/eui'; - -import type { UserActionBuilder, UserActionBuilderArgs, UserActionTreeProps } from './types'; -import { createCommonUpdateUserActionBuilder } from './common'; -import { UserActionTimestamp } from './timestamp'; -import { UserActionMarkdown } from './markdown_form'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import type { UserActionBuilder } from './types'; import * as i18n from './translations'; -import { HoverableUsernameResolver } from '../user_profiles/hoverable_username_resolver'; - -const DESCRIPTION_ID = 'description'; +import { createCommonUpdateUserActionBuilder } from './common'; const getLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; -type GetDescriptionUserActionArgs = Pick< - UserActionBuilderArgs, - | 'caseData' - | 'commentRefs' - | 'userProfiles' - | 'manageMarkdownEditIds' - | 'handleManageMarkdownEditId' - | 'appId' -> & - Pick & { isLoadingDescription: boolean }; - -const MyEuiCommentFooter = styled(EuiText)` - ${({ theme }) => ` - border-top: ${theme.eui.euiBorderThin}; - padding: ${theme.eui.euiSizeS}; - `} -`; - -const hasDraftComment = (appId = '', caseId: string, commentId: string): boolean => { - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, commentId); - - return Boolean(sessionStorage.getItem(draftStorageKey)); -}; - -export const getDescriptionUserAction = ({ - appId, - caseData, - commentRefs, - manageMarkdownEditIds, - isLoadingDescription, - userProfiles, - onUpdateField, - handleManageMarkdownEditId, -}: GetDescriptionUserActionArgs): EuiCommentProps => { - const isEditable = manageMarkdownEditIds.includes(DESCRIPTION_ID); - return { - username: , - event: i18n.ADDED_DESCRIPTION, - 'data-test-subj': 'description-action', - timestamp: , - children: ( - <> - (commentRefs.current[DESCRIPTION_ID] = element)} - caseId={caseData.id} - id={DESCRIPTION_ID} - content={caseData.description} - isEditable={isEditable} - onSaveContent={(content: string) => { - onUpdateField({ key: DESCRIPTION_ID, value: content }); - }} - onChangeEditable={handleManageMarkdownEditId} - /> - {!isEditable && - !isLoadingDescription && - hasDraftComment(appId, caseData.id, DESCRIPTION_ID) ? ( - - - {i18n.UNSAVED_DRAFT_DESCRIPTION} - - - ) : ( - '' - )} - - ), - timelineAvatar: null, - className: classNames({ - isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), - draftFooter: - !isEditable && !isLoadingDescription && hasDraftComment(appId, caseData.id, DESCRIPTION_ID), - }), - actions: ( - handleManageMarkdownEditId(DESCRIPTION_ID)} - data-test-subj="editable-description-edit-icon" - /> - ), - }; -}; - export const createDescriptionUserActionBuilder: UserActionBuilder = ({ userAction, userProfiles, diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 5008fc99c0f01..47f0bd6ab4939 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -171,14 +171,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-cancel-markdown') + ).getByTestId('editable-cancel-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); }); }); @@ -212,14 +212,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-save-markdown') + ).getByTestId('editable-save-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); expect(patchComment).toBeCalledWith({ @@ -306,14 +306,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-save-markdown') + ).getByTestId('editable-save-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index 972a520d2085c..a52d1bf221119 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -6,20 +6,19 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Content } from './schema'; -import { schema } from './schema'; -import { UserActionMarkdown } from './markdown_form'; -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; -import { waitFor, fireEvent, render, act } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { UserActionMarkdown } from './markdown_form'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); -const newValue = 'Hello from Tehas'; -const emptyValue = ''; const hyperlink = `[hyperlink](http://elastic.co)`; const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; const defaultProps = { @@ -33,8 +32,10 @@ const defaultProps = { }; describe('UserActionMarkdown ', () => { + let appMockRenderer: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); }); afterEach(() => { @@ -42,147 +43,26 @@ describe('UserActionMarkdown ', () => { }); it('Renders markdown correctly when not in edit mode', async () => { - const wrapper = mount( - - - - ); + appMockRenderer.render(); - expect(wrapper.find(`[data-test-subj="markdown-link"]`).first().text()).toContain('hyperlink'); + expect(screen.getByTestId('scrollable-markdown')).toBeInTheDocument(); + expect(screen.getByTestId('markdown-link')).toBeInTheDocument(); + expect(screen.queryByTestId('editable-save-markdown')).not.toBeInTheDocument(); + expect(screen.queryByTestId('editable-cancel-markdown')).not.toBeInTheDocument(); }); - it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { - const wrapper = mount( - - - - ); + it('Renders markdown correctly when in edit mode', async () => { + appMockRenderer.render(); - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: newValue }, - }); - - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onSaveContent).toHaveBeenCalledWith(newValue); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - }); - - it('Does not call onSaveContent if no change from current text', async () => { - const wrapper = mount( - - - - ); - - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - expect(onSaveContent).not.toHaveBeenCalled(); - }); - - it('Save button disabled if current text is empty', async () => { - const wrapper = mount( - - - - ); - - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: emptyValue }, - }); - - await waitFor(() => { - expect( - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().prop('disabled') - ).toBeTruthy(); - }); - }); - - it('Cancel button click calls only onChangeEditable', async () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="user-action-cancel-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onSaveContent).not.toHaveBeenCalled(); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); + expect(screen.getByTestId('editable-save-markdown')).toBeInTheDocument(); + expect(screen.getByTestId('editable-cancel-markdown')).toBeInTheDocument(); }); describe('useForm stale state bug', () => { - let appMockRenderer: AppMockRenderer; const oldContent = defaultProps.content; const appendContent = ' appended content'; const newContent = defaultProps.content + appendContent; - beforeEach(() => { - appMockRenderer = createAppMockRenderer(); - }); - - it('creates a stale state if a key is not passed to the component', async () => { - const TestComponent = () => { - const [isEditable, setIsEditable] = React.useState(true); - const [saveContent, setSaveContent] = React.useState(defaultProps.content); - return ( -
- -
- ); - }; - - const result = appMockRenderer.render(); - - expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); - - // append some content and save - userEvent.type(result.container.querySelector('textarea')!, appendContent); - userEvent.click(result.getByTestId('user-action-save-markdown')); - - // wait for the state to update - await waitFor(() => { - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - - // toggle to non-edit state - userEvent.click(result.getByTestId('test-button')); - expect(result.getByTestId('user-action-markdown')).toBeTruthy(); - - // toggle to edit state again - userEvent.click(result.getByTestId('test-button')); - - // the text area holds a stale value - // this is the wrong behaviour. The textarea holds the old content - expect(result.container.querySelector('textarea')!.value).toEqual(oldContent); - expect(result.container.querySelector('textarea')!.value).not.toEqual(newContent); - }); - it("doesn't create a stale state if a key is passed to the component", async () => { const TestComponent = () => { const [isEditable, setIsEditable] = React.useState(true); @@ -208,11 +88,11 @@ describe('UserActionMarkdown ', () => { ); }; const result = appMockRenderer.render(); - expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); + expect(result.getByTestId('editable-markdown-form')).toBeTruthy(); // append content and save userEvent.type(result.container.querySelector('textarea')!, appendContent); - userEvent.click(result.getByTestId('user-action-save-markdown')); + userEvent.click(result.getByTestId('editable-save-markdown')); // wait for the state to update await waitFor(() => { @@ -221,7 +101,7 @@ describe('UserActionMarkdown ', () => { // toggle to non-edit state userEvent.click(result.getByTestId('test-button')); - expect(result.getByTestId('user-action-markdown')).toBeTruthy(); + expect(result.getByTestId('scrollable-markdown')).toBeTruthy(); // toggle to edit state again userEvent.click(result.getByTestId('test-button')); @@ -231,112 +111,4 @@ describe('UserActionMarkdown ', () => { expect(result.container.querySelector('textarea')!.value).not.toEqual(oldContent); }); }); - - describe('draft comment ', () => { - const content = 'test content'; - const initialState = { content }; - const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ - children, - testProviderProps = {}, - }) => { - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - - return ( - -
{children}
-
- ); - }; - - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - sessionStorage.removeItem(draftStorageKey); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('Save button click clears session storage', async () => { - const result = render( - - - - ); - - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { - target: { value: newValue }, - }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - - fireEvent.click(result.getByTestId(`user-action-save-markdown`)); - - await waitFor(() => { - expect(onSaveContent).toHaveBeenCalledWith(newValue); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - expect(sessionStorage.getItem(draftStorageKey)).toBe(null); - }); - }); - - it('Cancel button click clears session storage', async () => { - const result = render( - - - - ); - - expect(sessionStorage.getItem(draftStorageKey)).toBe(''); - - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { - target: { value: newValue }, - }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - await waitFor(() => { - expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - }); - - fireEvent.click(result.getByTestId('user-action-cancel-markdown')); - - await waitFor(() => { - expect(sessionStorage.getItem(draftStorageKey)).toBe(null); - }); - }); - - describe('existing storage key', () => { - beforeEach(() => { - sessionStorage.setItem(draftStorageKey, 'value set in storage'); - }); - - it('should have session storage value same as draft comment', async () => { - const result = render( - - - - ); - - expect(result.getByText('value set in storage')).toBeInTheDocument(); - }); - }); - }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx index 3ddb62eb4fb7a..3866fe774ec14 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx @@ -5,25 +5,10 @@ * 2.0. */ -import React, { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import styled from 'styled-components'; +import React, { forwardRef, useRef } from 'react'; -import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Content } from './schema'; import { schema } from './schema'; -import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; -import { removeItemFromSessionStorage } from '../utils'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; -import { UserActionMarkdownFooter } from './markdown_form_footer'; - -export const ContentWrapper = styled.div` - padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; - text-overflow: ellipsis; - word-break: break-word; - display: -webkit-box; - -webkit-box-orient: vertical; -`; +import { ScrollableMarkdown, EditableMarkdown } from '../markdown_editor'; interface UserActionMarkdownProps { content: string; @@ -43,70 +28,22 @@ const UserActionMarkdownComponent = forwardRef< UserActionMarkdownProps >(({ id, content, caseId, isEditable, onChangeEditable, onSaveContent }, ref) => { const editorRef = useRef(); - const initialState = { content }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const fieldName = 'content'; - const { appId } = useCasesContext(); - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); - const { setFieldValue, submit } = form; - - const handleCancelAction = useCallback(() => { - onChangeEditable(id); - removeItemFromSessionStorage(draftStorageKey); - }, [id, onChangeEditable, draftStorageKey]); - - const handleSaveAction = useCallback(async () => { - const { isValid, data } = await submit(); - - if (isValid && data.content !== content) { - onSaveContent(data.content); - } - onChangeEditable(id); - removeItemFromSessionStorage(draftStorageKey); - }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); - - const setComment = useCallback( - (newComment) => { - setFieldValue(fieldName, newComment); - }, - [setFieldValue] - ); - - useImperativeHandle(ref, () => ({ - setComment, - editor: editorRef.current, - })); return isEditable ? ( -
- - ), - initialValue: content, - }} - /> - + ) : ( - - {content} - + ); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts b/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts index 15bcfdc53512f..85822bae819eb 100644 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @@ -22,6 +22,8 @@ const configSchema = schema.object({ 'Loaded Kibana', // Sent once per page refresh (potentially, once per session) 'Hosts View Query Submitted', // Worst-case scenario 1 every 2 seconds 'Host Entry Clicked', // Worst-case scenario once per second - AT RISK, + 'Host Flyout Filter Removed', // Worst-case scenario once per second - AT RISK, + 'Host Flyout Filter Added', // Worst-case scenario once per second - AT RISK, ], }), }); diff --git a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts new file mode 100644 index 0000000000000..953c49b493b37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export interface ConnectorServerSideDefinition { + iconPath: string; + isBeta: boolean; + isNative: boolean; + keywords: string[]; + name: string; + serviceType: string; +} + +export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ + { + iconPath: 'mongodb.svg', + isBeta: false, + isNative: true, + keywords: ['mongo', 'mongodb', 'database', 'nosql', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mongodb.name', { + defaultMessage: 'MongoDB', + }), + serviceType: 'mongodb', + }, + { + iconPath: 'mysql.svg', + isBeta: false, + isNative: true, + keywords: ['mysql', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mysql.name', { + defaultMessage: 'MySQL', + }), + serviceType: 'mysql', + }, + { + iconPath: 'azure_blob_storage.svg', + isBeta: true, + isNative: false, + keywords: ['cloud', 'azure', 'blob', 's3', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.azureBlob.name', { + defaultMessage: 'Azure Blob Storage', + }), + serviceType: 'azure_blob_storage', + }, + { + iconPath: 'google_cloud_storage.svg', + isBeta: true, + isNative: false, + keywords: ['google', 'cloud', 'blob', 's3', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.googleCloud.name', { + defaultMessage: 'Google Cloud Storage', + }), + serviceType: 'google_cloud_storage', + }, + { + iconPath: 'mssql.svg', + isBeta: true, + isNative: false, + keywords: ['mssql', 'microsoft', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.microsoftSQL.name', { + defaultMessage: 'Microsoft SQL', + }), + serviceType: 'mssql', + }, + { + iconPath: 'network_drive.svg', + isBeta: true, + isNative: false, + keywords: ['network', 'drive', 'file', 'directory', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.networkDrive.name', { + defaultMessage: 'Network drive', + }), + serviceType: 'network_drive', + }, + { + iconPath: 'oracle.svg', + isBeta: true, + isNative: false, + keywords: ['oracle', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.oracle.name', { + defaultMessage: 'Oracle', + }), + serviceType: 'oracle', + }, + { + iconPath: 'postgresql.svg', + isBeta: true, + isNative: false, + keywords: ['postgresql', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.postgresql.name', { + defaultMessage: 'Postgresql', + }), + serviceType: 'postgresql', + }, + { + iconPath: 's3.svg', + isBeta: true, + isNative: false, + keywords: ['s3', 'cloud', 'amazon', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.s3.name', { + defaultMessage: 'S3', + }), + serviceType: 's3', + }, + { + iconPath: 'custom.svg', + isBeta: true, + isNative: false, + keywords: ['custom', 'connector', 'code'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.customConnector.name', { + defaultMessage: 'Customized connector', + }), + serviceType: '', + }, +]; diff --git a/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts index 78a720304d996..bccfe15835a63 100644 --- a/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts +++ b/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts @@ -19,7 +19,7 @@ export const NATIVE_CONNECTOR_DEFINITIONS: Record void + private engineName: string // uncomment and add setLastAPICall to constructor when view this API call is needed // private setLastAPICall?: (apiCallData: APICallData) => void ) {} async performRequest(request: SearchRequest) { @@ -64,7 +86,7 @@ class InternalEngineTransporter implements Transporter { body: JSON.stringify(request), }); - this.setLastAPICall({ request, response }); + // this.setLastAPICall({ request, response }); Uncomment when view this API call is needed const withUniqueIds = { ...response, @@ -87,20 +109,184 @@ class InternalEngineTransporter implements Transporter { } } -const pageTitle = i18n.translate('xpack.enterpriseSearch.content.engine.searchPreview.pageTitle', { - defaultMessage: 'Search Preview', -}); +interface ConfigurationPopOverProps { + engineName: string; + setCloseConfiguration: () => void; + showConfiguration: boolean; +} + +const ConfigurationPopover: React.FC = ({ + engineName, + showConfiguration, + setCloseConfiguration, +}) => { + const { navigateToUrl } = useValues(KibanaLogic); + const { engineData } = useValues(EngineViewLogic); + const { openDeleteEngineModal } = useActions(EngineViewLogic); + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); + return ( + <> + + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', + { + defaultMessage: 'Configuration', + } + )} + + } + > + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.contentTitle', + { + defaultMessage: 'Content', + } + )} +

+
+
+ + + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONTENT_PATH, { + contentTabId: SearchApplicationContentTabs.INDICES, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.content.Indices', + { + defaultMessage: 'Indices', + } + )} + + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONTENT_PATH, { + contentTabId: SearchApplicationContentTabs.SCHEMA, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.content.Schema', + { + defaultMessage: 'Schema', + } + )} + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.connectTitle', + { + defaultMessage: 'Connect', + } + )} +

+
+
+ + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONNECT_PATH, { + connectTabId: SearchApplicationConnectTabs.API, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.connect.Api', + { + defaultMessage: 'API', + } + )} + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.settingsTitle', + { + defaultMessage: 'Settings', + } + )} +

+
+
+ + } + onClick={() => { + if (engineData) { + openDeleteEngineModal(); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-engineView-deleteEngine', + }); + } + }} + > + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.settings.delete', + { + defaultMessage: 'Delete this app', + } + )} +

+
+
+
+
+ + ); +}; export const EngineSearchPreview: React.FC = () => { const { http } = useValues(HttpLogic); - const [showAPICallFlyout, setShowAPICallFlyout] = useState(false); - const [lastAPICall, setLastAPICall] = useState(null); + // const [showAPICallFlyout, setShowAPICallFlyout] = useState(false); Uncomment when view this API call is needed + const [showConfigurationPopover, setShowConfigurationPopover] = useState(false); + // const [lastAPICall, setLastAPICall] = useState(null); Uncomment when view this API call is needed const { engineName, isLoadingEngine } = useValues(EngineViewLogic); const { resultFields, searchableFields, sortableFields } = useValues(EngineSearchPreviewLogic); const { engineData } = useValues(EngineIndicesLogic); const config: SearchDriverOptions = useMemo(() => { - const transporter = new InternalEngineTransporter(http, engineName, setLastAPICall); + const transporter = new InternalEngineTransporter(http, engineName); const connector = new EnginesAPIConnector(transporter); return { @@ -112,27 +298,35 @@ export const EngineSearchPreview: React.FC = () => { search_fields: searchableFields, }, }; - }, [http, engineName, setLastAPICall, resultFields, searchableFields]); + }, [http, engineName, resultFields, searchableFields]); if (!engineData) return null; return ( + ), rightSideItems: [ <> - setShowAPICallFlyout(true)} - isLoading={lastAPICall == null} - > - View this API call - + setShowConfigurationPopover(!showConfigurationPopover)} + /> , ], }} @@ -167,6 +361,9 @@ export const EngineSearchPreview: React.FC = () => {
+ {/* + Uncomment when view this API call needed + {showAPICallFlyout && lastAPICall && ( setShowAPICallFlyout(false)} @@ -174,6 +371,7 @@ export const EngineSearchPreview: React.FC = () => { engineName={engineName} /> )} + */} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts index a1d182434f3f1..e4bdceb5dcbdf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts @@ -7,9 +7,9 @@ import { INGESTION_METHOD_IDS } from '../../../../../common/constants'; -import connectorLogo from '../../../../assets/enterprise_search_features/connector.svg'; +import connectorLogo from '../../../../assets/source_icons/connector.svg'; -import crawlerLogo from '../../../../assets/enterprise_search_features/crawler.svg'; +import crawlerLogo from '../../../../assets/source_icons/crawler.svg'; import { UNIVERSAL_LANGUAGE_VALUE } from './constants'; import { LanguageForOptimization } from './types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts index 9aaea14fde6db..b6269bb91aa87 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts @@ -5,137 +5,83 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -import { CONNECTOR_ICONS } from '../../../../../assets/source_icons/native_connector_icons'; +import { CONNECTOR_DEFINITIONS } from '../../../../../../common/connectors/connectors'; import { docLinks } from '../../../../shared/doc_links'; +import { CONNECTOR_ICONS } from '../../../../shared/icons/connector_icons'; -import { ConnectorDefinition } from './types'; +import { ConnectorClientSideDefinition } from './types'; -export const CONNECTORS: ConnectorDefinition[] = [ - { - docsUrl: docLinks.connectorsMongoDB, - externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', - externalDocsUrl: 'https://www.mongodb.com/docs/', - icon: CONNECTOR_ICONS.mongodb, - isBeta: false, - isNative: true, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mongodb.name', { - defaultMessage: 'MongoDB', - }), - serviceType: 'mongodb', - }, - { - docsUrl: docLinks.connectorsMySQL, - externalDocsUrl: 'https://dev.mysql.com/doc/', - icon: CONNECTOR_ICONS.mysql, - isBeta: false, - isNative: true, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mysql.name', { - defaultMessage: 'MySQL', - }), - serviceType: 'mysql', - }, - { +export const CONNECTORS_DICT: Record = { + azure_blob_storage: { docsUrl: docLinks.connectorsAzureBlobStorage, externalAuthDocsUrl: 'https://learn.microsoft.com/azure/storage/common/authorize-data-access', externalDocsUrl: 'https://learn.microsoft.com/azure/storage/blobs/', icon: CONNECTOR_ICONS.azure_blob_storage, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.azureBlob.name', { - defaultMessage: 'Azure Blob Storage', - }), - serviceType: 'azure_blob_storage', }, - { + custom: { + docsUrl: docLinks.connectors, + externalAuthDocsUrl: '', + externalDocsUrl: '', + icon: CONNECTOR_ICONS.custom, + }, + google_cloud_storage: { docsUrl: docLinks.connectorsGoogleCloudStorage, externalAuthDocsUrl: 'https://cloud.google.com/storage/docs/authentication', externalDocsUrl: 'https://cloud.google.com/storage/docs', icon: CONNECTOR_ICONS.google_cloud_storage, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.googleCloud.name', { - defaultMessage: 'Google Cloud Storage', - }), - serviceType: 'google_cloud_storage', }, - { + mongodb: { + docsUrl: docLinks.connectorsMongoDB, + externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', + externalDocsUrl: 'https://www.mongodb.com/docs/', + icon: CONNECTOR_ICONS.mongodb, + }, + mssql: { docsUrl: docLinks.connectorsMicrosoftSQL, externalAuthDocsUrl: 'https://learn.microsoft.com/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions', externalDocsUrl: 'https://learn.microsoft.com/sql/', icon: CONNECTOR_ICONS.microsoft_sql, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.microsoftSQL.name', { - defaultMessage: 'Microsoft SQL', - }), - serviceType: 'mssql', }, - { + mysql: { + docsUrl: docLinks.connectorsMySQL, + externalDocsUrl: 'https://dev.mysql.com/doc/', + icon: CONNECTOR_ICONS.mysql, + }, + network_drive: { docsUrl: docLinks.connectorsNetworkDrive, externalAuthDocsUrl: '', externalDocsUrl: '', icon: CONNECTOR_ICONS.network_drive, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.networkDrive.name', { - defaultMessage: 'Network drive', - }), - serviceType: 'network_drive', }, - { + oracle: { docsUrl: docLinks.connectorsOracle, externalAuthDocsUrl: 'https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/index.html', externalDocsUrl: 'https://docs.oracle.com/database/oracle/oracle-database/', icon: CONNECTOR_ICONS.oracle, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.oracle.name', { - defaultMessage: 'Oracle', - }), - serviceType: 'oracle', }, - { + postgresql: { docsUrl: docLinks.connectorsPostgreSQL, externalAuthDocsUrl: 'https://www.postgresql.org/docs/15/auth-methods.html', externalDocsUrl: 'https://www.postgresql.org/docs/', icon: CONNECTOR_ICONS.postgresql, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.postgresql.name', { - defaultMessage: 'Postgresql', - }), - serviceType: 'postgresql', }, - { + s3: { docsUrl: docLinks.connectorsS3, externalAuthDocsUrl: 'https://docs.aws.amazon.com/s3/index.html', externalDocsUrl: '', icon: CONNECTOR_ICONS.amazon_s3, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.s3.name', { - defaultMessage: 'S3', - }), - serviceType: 's3', }, - { - docsUrl: docLinks.connectors, - externalAuthDocsUrl: '', - externalDocsUrl: '', - icon: CONNECTOR_ICONS.custom, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.customConnector.name', { - defaultMessage: 'Custom connector', - }), - serviceType: '', - }, -]; +}; + +export const CONNECTORS = CONNECTOR_DEFINITIONS.map((connector) => ({ + ...connector, + ...(connector.serviceType && CONNECTORS_DICT[connector.serviceType] + ? CONNECTORS_DICT[connector.serviceType] + : CONNECTORS_DICT.custom), +})); export const CUSTOM_CONNECTORS = CONNECTORS.filter(({ isNative }) => !isNative); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts index 0b061d1aa3241..68d990650a175 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts @@ -5,13 +5,13 @@ * 2.0. */ -export interface ConnectorDefinition { +import { ConnectorServerSideDefinition } from '../../../../../../common/connectors/connectors'; + +export interface ConnectorClientSideDefinition { docsUrl?: string; externalAuthDocsUrl?: string; externalDocsUrl: string; icon: string; - isBeta: boolean; - isNative: boolean; - name: string; - serviceType: string; } + +export type ConnectorDefinition = ConnectorClientSideDefinition & ConnectorServerSideDefinition; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts b/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts new file mode 100644 index 0000000000000..f9d9eb5df9799 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import azure_blob_storage from '../../../assets/source_icons/azure_blob_storage.svg'; +import custom from '../../../assets/source_icons/custom.svg'; +import google_cloud_storage from '../../../assets/source_icons/google_cloud_storage.svg'; +import mongodb from '../../../assets/source_icons/mongodb.svg'; +import microsoft_sql from '../../../assets/source_icons/mssql.svg'; +import mysql from '../../../assets/source_icons/mysql.svg'; +import network_drive from '../../../assets/source_icons/network_drive.svg'; +import oracle from '../../../assets/source_icons/oracle.svg'; +import postgresql from '../../../assets/source_icons/postgresql.svg'; +import amazon_s3 from '../../../assets/source_icons/s3.svg'; + +export const CONNECTOR_ICONS = { + amazon_s3, + azure_blob_storage, + custom, + google_cloud_storage, + microsoft_sql, + mongodb, + mysql, + network_drive, + oracle, + postgresql, +}; diff --git a/x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/connector.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/connector.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/connector.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/connector.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/crawler.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/crawler.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/crawler.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/crawler.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/microsoft_sql.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/mssql.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/source_icons/microsoft_sql.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/mssql.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts b/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts deleted file mode 100644 index 49767bb497f8b..0000000000000 --- a/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import amazon_s3 from './amazon_s3.svg'; -import azure_blob_storage from './azure_blob_storage.svg'; -import custom from './custom.svg'; -import google_cloud_storage from './google_cloud_storage.svg'; -import microsoft_sql from './microsoft_sql.svg'; -import mongodb from './mongodb.svg'; -import mysql from './mysql.svg'; -import network_drive from './network_drive.svg'; -import oracle from './oracle.svg'; -import postgresql from './postgresql.svg'; - -export const CONNECTOR_ICONS = { - amazon_s3, - azure_blob_storage, - custom, - google_cloud_storage, - microsoft_sql, - mongodb, - mysql, - network_drive, - oracle, - postgresql, -}; diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/amazon_s3.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/s3.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/source_icons/amazon_s3.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/s3.svg diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index ae5eb87aad733..8d68f1c3e1c58 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -588,9 +588,7 @@ export const registerEnterpriseSearchIntegrations = ( icons: [ { type: 'svg', - src: http.basePath.prepend( - '/plugins/enterpriseSearch/assets/source_icons/microsoft_sql.svg' - ), + src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/mssql.svg'), }, ], shipper: 'enterprise_search', @@ -651,7 +649,7 @@ export const registerEnterpriseSearchIntegrations = ( icons: [ { type: 'svg', - src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/amazon_s3.svg'), + src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/s3.svg'), }, ], shipper: 'enterprise_search', diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 729258fe4e97c..77dc6d4c839f5 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -18,6 +18,7 @@ import { import { CustomIntegrationsPluginSetup } from '@kbn/custom-integrations-plugin/server'; import { DataPluginStart } from '@kbn/data-plugin/server/plugin'; import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/server'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import { InfraPluginSetup } from '@kbn/infra-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; @@ -76,31 +77,34 @@ import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/t import { uiSettings as enterpriseSearchUISettings } from './ui_settings'; +import { getSearchResultProvider } from './utils/search_result_provider'; + import { ConfigType } from '.'; interface PluginsSetup { - usageCollection?: UsageCollectionSetup; - security: SecurityPluginSetup; + customIntegrations?: CustomIntegrationsPluginSetup; features: FeaturesPluginSetup; + globalSearch: GlobalSearchPluginSetup; + guidedOnboarding: GuidedOnboardingPluginSetup; infra: InfraPluginSetup; - customIntegrations?: CustomIntegrationsPluginSetup; ml?: MlPluginSetup; - guidedOnboarding: GuidedOnboardingPluginSetup; + security: SecurityPluginSetup; + usageCollection?: UsageCollectionSetup; } interface PluginsStart { - spaces?: SpacesPluginStart; - security: SecurityPluginStart; data: DataPluginStart; + security: SecurityPluginStart; + spaces?: SpacesPluginStart; } export interface RouteDependencies { - router: IRouter; config: ConfigType; - log: Logger; enterpriseSearchRequestHandler: IEnterpriseSearchRequestHandler; getSavedObjectsService?(): SavedObjectsServiceStart; + log: Logger; ml?: MlPluginSetup; + router: IRouter; } export class EnterpriseSearchPlugin implements Plugin { @@ -118,6 +122,7 @@ export class EnterpriseSearchPlugin implements Plugin { usageCollection, security, features, + globalSearch, infra, customIntegrations, ml, @@ -284,6 +289,14 @@ export class EnterpriseSearchPlugin implements Plugin { if (config.hasNativeConnectors) { guidedOnboarding.registerGuideConfig(databaseSearchGuideId, databaseSearchGuideConfig); } + + /** + * Register our integrations in the global search bar + */ + + if (globalSearch) { + globalSearch.registerResultProvider(getSearchResultProvider(http.basePath, config)); + } } public start() {} diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts new file mode 100644 index 0000000000000..d5ecee88f80cf --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { NEVER } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants'; + +import { getSearchResultProvider } from './search_result_provider'; + +const getTestScheduler = () => { + return new TestScheduler((actual, expected) => { + return expect(actual).toEqual(expected); + }); +}; + +describe('Enterprise Search search provider', () => { + const basePathMock = { + prepend: (input: string) => `/kbn${input}`, + } as any; + + const crawlerResult = { + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/crawler.svg', + id: 'elastic-crawler', + score: 75, + title: 'Elastic Web Crawler', + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/crawler`, + prependBasePath: true, + }, + }; + + const mongoResult = { + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/mongodb.svg', + id: 'mongodb', + score: 75, + title: 'MongoDB', + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/connector?service_type=mongodb`, + prependBasePath: true, + }, + }; + + const searchResultProvider = getSearchResultProvider(basePathMock, { + hasConnectors: true, + hasWebCrawler: true, + } as any); + + beforeEach(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('find', () => { + it('returns formatted results', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + + it('returns everything on empty string', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.arrayContaining([ + { ...crawlerResult, score: 80 }, + { ...mongoResult, score: 80 }, + ]), + }); + }); + }); + + it('respect maximum results', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [{ ...crawlerResult, score: 80 }], + }); + }); + }); + + it('omits crawler if config has crawler disabled', () => { + const searchProvider = getSearchResultProvider(basePathMock, { + hasConnectors: true, + hasWebCrawler: false, + } as any); + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.not.arrayContaining([{ ...crawlerResult, score: 80 }]), + }); + }); + }); + + it('omits connectors if config has connectors disabled', () => { + const searchProvider = getSearchResultProvider(basePathMock, { + hasConnectors: false, + hasWebCrawler: true, + } as any); + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.not.arrayContaining([{ mongoResult, score: 80 }]), + }); + }); + }); + + it('returns nothing if tag is specified', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { tags: ['tag'], term: '' }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [], + }); + }); + }); + it('returns nothing if unknown type is specified', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '', types: ['tag'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [], + }); + }); + }); + it('returns results for integrations tag', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler', types: ['integration'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + it('returns results for enterprise search tag', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler', types: ['enterprise search'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts new file mode 100644 index 0000000000000..29f791dfd5ca1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { from, takeUntil } from 'rxjs'; + +import { IBasePath } from '@kbn/core-http-server'; +import { GlobalSearchResultProvider } from '@kbn/global-search-plugin/server'; + +import { ConfigType } from '..'; +import { CONNECTOR_DEFINITIONS } from '../../common/connectors/connectors'; +import { + ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE, + ENTERPRISE_SEARCH_CONTENT_PLUGIN, +} from '../../common/constants'; + +export function toSearchResult({ + basePath, + iconPath, + name, + score, + serviceType, +}: { + basePath: IBasePath; + iconPath: string; + name: string; + score: number; + serviceType: string; +}) { + return { + icon: iconPath + ? basePath.prepend(`/plugins/enterpriseSearch/assets/source_icons/${iconPath}`) + : 'logoEnterpriseSearch', + id: serviceType, + score, + title: name, + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/${ + serviceType === ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE + ? 'crawler' + : `connector?service_type=${serviceType}` + }`, + prependBasePath: true, + }, + }; +} + +export function getSearchResultProvider( + basePath: IBasePath, + config: ConfigType +): GlobalSearchResultProvider { + return { + find: ({ term, types, tags }, { aborted$, maxResults }) => { + if ( + tags || + (types && !(types.includes('integration') || types.includes('enterprise search'))) + ) { + return from([[]]); + } + const result = [ + ...(config.hasWebCrawler + ? [ + { + iconPath: 'crawler.svg', + keywords: ['crawler', 'web', 'website', 'internet', 'google'], + name: 'Elastic Web Crawler', + serviceType: ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE, + }, + ] + : []), + ...(config.hasConnectors ? CONNECTOR_DEFINITIONS : []), + ] + .map(({ iconPath, keywords, name, serviceType }) => { + let score = 0; + const searchTerm = (term || '').toLowerCase(); + const searchName = name.toLowerCase(); + if (!searchTerm) { + score = 80; + } else if (searchName === searchTerm) { + score = 100; + } else if (searchName.startsWith(searchTerm)) { + score = 90; + } else if (searchName.includes(searchTerm)) { + score = 75; + } else if (serviceType === searchTerm) { + score = 65; + } else if (keywords.some((keyword) => keyword.includes(searchTerm))) { + score = 50; + } + return toSearchResult({ basePath, iconPath, name, score, serviceType }); + }) + .filter(({ score }) => score > 0) + .slice(0, maxResults); + return from([result]).pipe(takeUntil(aborted$)); + }, + getSearchableTypes: () => ['enterprise search', 'integration'], + id: 'enterpriseSearch', + }; +} diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json index 4ba8f2b48d814..3bd55202cfe52 100644 --- a/x-pack/plugins/enterprise_search/tsconfig.json +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -59,5 +59,6 @@ "@kbn/field-types", "@kbn/core-elasticsearch-server-mocks", "@kbn/shared-ux-link-redirect-app", + "@kbn/global-search-plugin", ] } diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts index 4b9b904b73fc4..4bd40762a98fb 100644 --- a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts @@ -101,6 +101,7 @@ describe('ObservabilityDataViews', function () { expect(indexP).toEqual({ id: dataViewList.ux }); expect(dataViews?.createAndSave).toHaveBeenCalledWith({ + allowNoIndex: true, fieldFormats, id: 'rum_static_index_pattern_id_trace_apm_', timeFieldName: '@timestamp', diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts index c22a2ae6d1712..ae3a60e7fe65b 100644 --- a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts @@ -140,11 +140,16 @@ export class ObservabilityDataViews { timeFieldName: '@timestamp', fieldFormats: this.getFieldFormats(app), name: DataTypesLabels[app], + allowNoIndex: true, }, false, false ); + if (dataView.matchedIndices.length === 0) { + throw new DataViewMissingIndices('No indices match pattern'); + } + if (runtimeFields !== null) { runtimeFields.forEach(({ name, field }) => { dataView.addRuntimeField(name, field); @@ -170,6 +175,7 @@ export class ObservabilityDataViews { timeFieldName: '@timestamp', fieldFormats: this.getFieldFormats(app), name: DataTypesLabels[app], + allowNoIndex: true, }); } // we want to make sure field formats remain same diff --git a/x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts b/x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts new file mode 100644 index 0000000000000..81a14f6ddec40 --- /dev/null +++ b/x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts @@ -0,0 +1,144 @@ +/* + * 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 Path from 'path'; + +import { + type TestElasticsearchUtils, + type TestKibanaUtils, + createRootWithCorePlugins, + createTestServers, +} from '@kbn/core-test-helpers-kbn-server'; + +import type { OutputSOAttributes } from '../types'; + +import { useDockerRegistry, waitForFleetSetup } from './helpers'; + +const logFilePath = Path.join(__dirname, 'logs.log'); + +describe('Fleet preconfigured outputs', () => { + let esServer: TestElasticsearchUtils; + let kbnServer: TestKibanaUtils; + + const registryUrl = useDockerRegistry(); + + const startServers = async (outputs: any) => { + const { startES } = createTestServers({ + adjustTimeout: (t) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + }, + kbn: {}, + }, + }); + + esServer = await startES(); + if (kbnServer) { + await kbnServer.stop(); + } + + const root = createRootWithCorePlugins( + { + xpack: { + fleet: { + outputs, + registryUrl, + }, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + { + name: 'plugins.fleet', + level: 'all', + }, + ], + }, + }, + { oss: false } + ); + + await root.preboot(); + const coreSetup = await root.setup(); + const coreStart = await root.start(); + + kbnServer = { + root, + coreSetup, + coreStart, + stop: async () => await root.shutdown(), + }; + await waitForFleetSetup(kbnServer.root); + }; + + const stopServers = async () => { + if (kbnServer) { + await kbnServer.stop(); + } + + if (esServer) { + await esServer.stop(); + } + + await new Promise((res) => setTimeout(res, 10000)); + }; + + describe('Preconfigured outputs', () => { + describe('With a preconfigured monitoring output', () => { + beforeAll(async () => { + await startServers([ + { + name: 'Test output', + is_default_monitoring: true, + type: 'elasticsearch', + id: 'output-default-monitoring', + hosts: ['http://elasticsearch-alternative-url:9200'], + }, + ]); + }); + + afterAll(async () => { + await stopServers(); + }); + + it('Should create a default output and the default preconfigured output', async () => { + const outputs = await kbnServer.coreStart.savedObjects + .createInternalRepository() + .find({ + type: 'ingest-outputs', + perPage: 10000, + }); + + expect(outputs.total).toBe(2); + expect(outputs.saved_objects.filter((so) => so.attributes.is_default)).toHaveLength(1); + expect( + outputs.saved_objects.filter((so) => so.attributes.is_default_monitoring) + ).toHaveLength(1); + + const defaultDataOutput = outputs.saved_objects.find((so) => so.attributes.is_default); + const defaultMonitoringOutput = outputs.saved_objects.find( + (so) => so.attributes.is_default_monitoring + ); + expect(defaultDataOutput!.id).not.toBe(defaultMonitoringOutput!.id); + expect(defaultDataOutput!.attributes.is_default_monitoring).toBeFalsy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/grokdebugger/kibana.jsonc b/x-pack/plugins/grokdebugger/kibana.jsonc index 340088efc772f..aa0bdc864142f 100644 --- a/x-pack/plugins/grokdebugger/kibana.jsonc +++ b/x-pack/plugins/grokdebugger/kibana.jsonc @@ -16,8 +16,7 @@ "devTools" ], "requiredBundles": [ - "kibanaReact", - "esUiShared" + "kibanaReact" ] } } diff --git a/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js b/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js index cac942fa44694..def839c3f9b09 100644 --- a/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js +++ b/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js @@ -6,11 +6,11 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiCallOut, EuiCodeBlock, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EDITOR } from '../../../common/constants'; -import { EuiCodeEditor } from '../../shared_imports'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; export function CustomPatternsInput({ value, onChange }) { const sampleCustomPatterns = `POSTFIX_QUEUEID [0-9A-F]{10,11} @@ -43,18 +43,18 @@ MSG message-id=<%{GREEDYDATA}>`; - diff --git a/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js b/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js index 35b3be399fdce..2bfdce4f0a893 100644 --- a/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js +++ b/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js @@ -6,11 +6,10 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - -import { EDITOR } from '../../../common/constants'; -import { EuiCodeEditor } from '../../shared_imports'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; export function EventInput({ value, onChange }) { return ( @@ -19,20 +18,20 @@ export function EventInput({ value, onChange }) { } fullWidth - data-test-subj="aceEventInput" + data-test-subj="eventInput" > - ); diff --git a/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js b/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js index a2a02259c3fdf..e26672e467c3e 100644 --- a/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js +++ b/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js @@ -6,11 +6,9 @@ */ import React from 'react'; -import { EuiFormRow } from '@elastic/eui'; +import { EuiFormRow, EuiCodeBlock } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiCodeEditor } from '../../shared_imports'; - export function EventOutput({ value }) { return ( } fullWidth - data-test-subj="aceEventOutput" > - + + {JSON.stringify(value, null, 2)} + ); } diff --git a/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js b/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js index f40948730872b..7eb4e9412edaa 100644 --- a/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js +++ b/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js @@ -11,7 +11,6 @@ import { i18n } from '@kbn/i18n'; // eslint-disable-next-line no-restricted-imports import isEmpty from 'lodash/isEmpty'; -import './brace_imports'; import { EuiForm, EuiButton, diff --git a/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js b/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js index 75af1453c6b40..096537bd8a6bf 100644 --- a/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js +++ b/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js @@ -6,12 +6,10 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - -import { EDITOR } from '../../../common/constants'; -import { EuiCodeEditor } from '../../shared_imports'; -import { GrokMode } from '../../lib/ace'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; export function PatternInput({ value, onChange }) { return ( @@ -20,20 +18,20 @@ export function PatternInput({ value, onChange }) { } fullWidth - data-test-subj="acePatternInput" + data-test-subj="patternInput" > - ); diff --git a/x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js b/x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js deleted file mode 100644 index 5987cd672ec05..0000000000000 --- a/x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js +++ /dev/null @@ -1,45 +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 ace from 'brace'; - -const { TextHighlightRules } = ace.acequire('ace/mode/text_highlight_rules'); - -export class GrokHighlightRules extends TextHighlightRules { - constructor() { - super(); - this.$rules = { - start: [ - { - token: ['grokStart', 'grokPatternName', 'grokSeparator', 'grokFieldName', 'grokEnd'], - regex: '(%{)([^:]+)(:)([^:]+)(})', - }, - { - token: [ - 'grokStart', - 'grokPatternName', - 'grokSeparator', - 'grokFieldName', - 'grokSeparator', - 'grokFieldType', - 'grokEnd', - ], - regex: '(%{)([^:]+)(:)([^:]+)(:)([^:]+)(})', - }, - { - token: (escapeToken /* regexToken */) => { - if (escapeToken) { - return ['grokEscape', 'grokEscaped']; - } - return 'grokRegex'; - }, - regex: '(\\\\)?([\\[\\]\\(\\)\\?\\:\\|])', - }, - ], - }; - } -} diff --git a/x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js b/x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js deleted file mode 100644 index a3f97de6dd934..0000000000000 --- a/x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js +++ /dev/null @@ -1,18 +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 ace from 'brace'; -import { GrokHighlightRules } from './grok_highlight_rules'; - -const TextMode = ace.acequire('ace/mode/text').Mode; - -export class GrokMode extends TextMode { - constructor() { - super(); - this.HighlightRules = GrokHighlightRules; - } -} diff --git a/x-pack/plugins/grokdebugger/public/shared_imports.ts b/x-pack/plugins/grokdebugger/public/shared_imports.ts index b2e82bce637f1..0bfbb3e05f933 100644 --- a/x-pack/plugins/grokdebugger/public/shared_imports.ts +++ b/x-pack/plugins/grokdebugger/public/shared_imports.ts @@ -5,6 +5,4 @@ * 2.0. */ -export { EuiCodeEditor } from '@kbn/es-ui-shared-plugin/public'; - export { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 1727caa9eaff0..2eb7403177257 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -21,6 +21,23 @@ import { import { setup } from './template_create.helpers'; import { TemplateFormTestBed } from './template_form.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index 99565222ffa7b..6d1224abf3529 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -28,6 +28,23 @@ const MAPPING = { }, }; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const origial = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 64ce73ee8161b..bf16e8e5e803d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -219,18 +219,13 @@ export const formSetup = async (initTestBed: SetupFunc) => { const completeStepThree = async (settings?: string) => { const { find, component } = testBed; - await act(async () => { - if (settings) { - find('settingsEditor').simulate('change', { - jsonString: settings, - }); // Using mocked EuiCodeEditor - jest.advanceTimersByTime(0); - } - }); + if (settings) { + find('settingsEditor').getDOMNode().setAttribute('data-currentvalue', settings); + find('settingsEditor').simulate('change'); + } await act(async () => { clickNextButton(); - jest.advanceTimersByTime(0); }); component.update(); @@ -258,13 +253,8 @@ export const formSetup = async (initTestBed: SetupFunc) => { const { find, component } = testBed; if (aliases) { - await act(async () => { - find('aliasesEditor').simulate('change', { - jsonString: aliases, - }); // Using mocked EuiCodeEditor - jest.advanceTimersByTime(0); // advance timers to allow the form to validate - }); - component.update(); + find('aliasesEditor').getDOMNode().setAttribute('data-currentvalue', aliases); + find('aliasesEditor').simulate('change'); } await act(async () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index 5e915c526dc44..8309db0699fc6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -14,6 +14,23 @@ import { setupEnvironment } from './helpers'; import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 94beecf441b07..1da8027c59497 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -13,6 +13,23 @@ import { setupEnvironment } from './helpers'; import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts index 2aaadf3c06f11..0db29dffff510 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts @@ -66,14 +66,14 @@ export const getFormActions = (testBed: TestBed) => { const completeStepSettings = async (settings?: { [key: string]: any }) => { const { find, component } = testBed; + const settingsValue = JSON.stringify(settings); - await act(async () => { - if (settings) { - find('settingsEditor').simulate('change', { - jsonString: JSON.stringify(settings), - }); // Using mocked EuiCodeEditor - } + if (settingsValue) { + find('settingsEditor').getDOMNode().setAttribute('data-currentvalue', settingsValue); + find('settingsEditor').simulate('change'); + } + await act(async () => { clickNextButton(); }); @@ -119,14 +119,14 @@ export const getFormActions = (testBed: TestBed) => { const completeStepAliases = async (aliases?: { [key: string]: any }) => { const { find, component } = testBed; + const aliasesValue = JSON.stringify(aliases); - await act(async () => { - if (aliases) { - find('aliasesEditor').simulate('change', { - jsonString: JSON.stringify(aliases), - }); // Using mocked EuiCodeEditor - } + if (aliasesValue) { + find('aliasesEditor').getDOMNode().setAttribute('data-currentvalue', aliasesValue); + find('aliasesEditor').simulate('change'); + } + await act(async () => { clickNextButton(); }); diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx index b22030be2d30b..a948b9a999fa8 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx @@ -18,8 +18,9 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; -import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { @@ -105,29 +106,23 @@ export const StepAliases: React.FunctionComponent = React.memo( error={error} fullWidth > - diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx index f370ea3642491..0fd889de03921 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx @@ -18,8 +18,9 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; -import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { @@ -99,29 +100,23 @@ export const StepSettings: React.FunctionComponent = React.memo( error={error} fullWidth > - diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx index 51277427b6352..acefcbea5e304 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx @@ -37,6 +37,7 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) query: { filterManager: filterManagerService }, }, notifications: { toasts: toastsService }, + telemetry, }, } = useKibanaContextForPlugin(); @@ -53,6 +54,9 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) negate: false, }); if (newFilter) { + telemetry.reportHostFlyoutFilterAdded({ + field_name: item.name, + }); filterManagerService.addFilters(newFilter); toastsService.addSuccess({ title: filterAddedToastTitle, @@ -84,7 +88,12 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) defaultMessage: 'Filter', } )} - onClick={() => filterManagerService.removeFilter(existingFilter)} + onClick={() => { + telemetry.reportHostFlyoutFilterRemoved({ + field_name: existingFilter.meta.key!, + }); + filterManagerService.removeFilter(existingFilter); + }} /> diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts index 83e8fc2420440..298424f5c9db6 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts @@ -10,4 +10,6 @@ import { ITelemetryClient } from './types'; export const createTelemetryClientMock = (): jest.Mocked => ({ reportHostEntryClicked: jest.fn(), reportHostsViewQuerySubmitted: jest.fn(), + reportHostFlyoutFilterRemoved: jest.fn(), + reportHostFlyoutFilterAdded: jest.fn(), }); diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts index 66ee3220c2935..f53a6b298de1d 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts @@ -8,6 +8,7 @@ import { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; import { HostEntryClickedParams, + HostFlyoutFilterActionParams, HostsViewQuerySubmittedParams, InfraTelemetryEventTypes, ITelemetryClient, @@ -30,6 +31,22 @@ export class TelemetryClient implements ITelemetryClient { }); }; + public reportHostFlyoutFilterRemoved = ({ + field_name: fieldName, + }: HostFlyoutFilterActionParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED, { + field_name: fieldName, + }); + }; + + public reportHostFlyoutFilterAdded = ({ + field_name: fieldName, + }: HostFlyoutFilterActionParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED, { + field_name: fieldName, + }); + }; + public reportHostsViewQuerySubmitted = (params: HostsViewQuerySubmittedParams) => { this.analytics.reportEvent(InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED, params); }; diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts index ee7545022d9ee..597c9c56eacfd 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts @@ -66,4 +66,34 @@ const hostsEntryClickedEvent: InfraTelemetryEvent = { }, }; -export const infraTelemetryEvents = [hostsViewQuerySubmittedEvent, hostsEntryClickedEvent]; +const hostFlyoutRemoveFilter: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED, + schema: { + field_name: { + type: 'keyword', + _meta: { + description: 'Removed filter field name for the selected host.', + optional: false, + }, + }, + }, +}; +const hostFlyoutAddFilter: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED, + schema: { + field_name: { + type: 'keyword', + _meta: { + description: 'Added filter field name for the selected host.', + optional: false, + }, + }, + }, +}; + +export const infraTelemetryEvents = [ + hostsViewQuerySubmittedEvent, + hostsEntryClickedEvent, + hostFlyoutRemoveFilter, + hostFlyoutAddFilter, +]; diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts index d3516fc84600b..6fdd2105ec2df 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts @@ -49,6 +49,8 @@ describe('TelemetryService', () => { const telemetry = service.start(); expect(telemetry).toHaveProperty('reportHostEntryClicked'); + expect(telemetry).toHaveProperty('reportHostFlyoutFilterRemoved'); + expect(telemetry).toHaveProperty('reportHostFlyoutFilterAdded'); expect(telemetry).toHaveProperty('reportHostsViewQuerySubmitted'); }); }); @@ -74,7 +76,7 @@ describe('TelemetryService', () => { ); }); - it('should report hosts entry click with cloud provider equal to "unknow" if not exist', async () => { + it('should report hosts entry click with cloud provider equal to "unknown" if not exist', async () => { const setupParams = getSetupParams(); service.setup(setupParams); const telemetry = service.start(); @@ -119,4 +121,44 @@ describe('TelemetryService', () => { ); }); }); + + describe('#reportHostFlyoutFilterRemoved', () => { + it('should report Host Flyout Filter Removed click with field name', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostFlyoutFilterRemoved({ + field_name: 'agent.version', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED, + { + field_name: 'agent.version', + } + ); + }); + }); + + describe('#reportHostFlyoutFilterAdded', () => { + it('should report Host Flyout Filter Added click with field name', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostFlyoutFilterAdded({ + field_name: 'agent.version', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED, + { + field_name: 'agent.version', + } + ); + }); + }); }); diff --git a/x-pack/plugins/infra/public/services/telemetry/types.ts b/x-pack/plugins/infra/public/services/telemetry/types.ts index a24f64e4c5f4a..f0f64ff00c918 100644 --- a/x-pack/plugins/infra/public/services/telemetry/types.ts +++ b/x-pack/plugins/infra/public/services/telemetry/types.ts @@ -15,6 +15,8 @@ export interface TelemetryServiceSetupParams { export enum InfraTelemetryEventTypes { HOSTS_VIEW_QUERY_SUBMITTED = 'Hosts View Query Submitted', HOSTS_ENTRY_CLICKED = 'Host Entry Clicked', + HOST_FLYOUT_FILTER_REMOVED = 'Host Flyout Filter Removed', + HOST_FLYOUT_FILTER_ADDED = 'Host Flyout Filter Added', } export interface HostsViewQuerySubmittedParams { @@ -29,10 +31,19 @@ export interface HostEntryClickedParams { cloud_provider?: string | null; } -export type InfraTelemetryEventParams = HostsViewQuerySubmittedParams | HostEntryClickedParams; +export interface HostFlyoutFilterActionParams { + field_name: string; +} + +export type InfraTelemetryEventParams = + | HostsViewQuerySubmittedParams + | HostEntryClickedParams + | HostFlyoutFilterActionParams; export interface ITelemetryClient { reportHostEntryClicked(params: HostEntryClickedParams): void; + reportHostFlyoutFilterRemoved(params: HostFlyoutFilterActionParams): void; + reportHostFlyoutFilterAdded(params: HostFlyoutFilterActionParams): void; reportHostsViewQuerySubmitted(params: HostsViewQuerySubmittedParams): void; } @@ -41,6 +52,14 @@ export type InfraTelemetryEvent = eventType: InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED; schema: RootSchema; } + | { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED; + schema: RootSchema; + } + | { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED; + schema: RootSchema; + } | { eventType: InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED; schema: RootSchema; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 60f66dbb415f3..6d232ba70557d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -496,7 +496,6 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }, inference: { FieldsComponent: Inference, - forLicenseAtLeast: 'platinum', docLinkPath: '/inference-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.inference', { defaultMessage: 'Inference', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js deleted file mode 100644 index f63511d857277..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js +++ /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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { EuiSpacer, EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; - -import { ml } from '../../../../services/ml_api_service'; -import { checkPermission } from '../../../../capabilities/check_capabilities'; -import { ML_DATA_PREVIEW_COUNT } from '../../../../../../common/util/job_utils'; -import { MLJobEditor } from '../ml_job_editor'; -import { FormattedMessage } from '@kbn/i18n-react'; - -export class DatafeedPreviewPane extends Component { - constructor(props) { - super(props); - - this.state = { - previewJson: '', - loading: true, - canPreviewDatafeed: true, - }; - } - - renderContent() { - const { previewJson, loading, canPreviewDatafeed } = this.state; - - if (canPreviewDatafeed === false) { - return ( - - } - color="warning" - iconType="warning" - > -

- -

-
- ); - } else if (loading === true) { - return ; - } else { - return ; - } - } - - componentDidMount() { - const canPreviewDatafeed = - checkPermission('canPreviewDatafeed') && this.props.job.datafeed_config !== undefined; - this.setState({ canPreviewDatafeed }); - - updateDatafeedPreview(this.props.job, canPreviewDatafeed) - .then((previewJson) => { - this.setState({ previewJson, loading: false }); - }) - .catch((error) => { - console.log('Datafeed preview could not be loaded', error); - this.setState({ loading: false }); - }); - } - - render() { - return ( - - - {this.renderContent()} - - ); - } -} -DatafeedPreviewPane.propTypes = { - job: PropTypes.object.isRequired, -}; - -function updateDatafeedPreview(job, canPreviewDatafeed) { - return new Promise((resolve, reject) => { - if (canPreviewDatafeed) { - ml.jobs - .datafeedPreview(job.datafeed_config.datafeed_id) - .then((resp) => { - if (Array.isArray(resp)) { - resolve(JSON.stringify(resp.slice(0, ML_DATA_PREVIEW_COUNT), null, 2)); - } else { - resolve(''); - console.log('Datafeed preview could not be loaded', resp); - } - }) - .catch((error) => { - reject(error); - }); - } - }); -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx new file mode 100644 index 0000000000000..1ad46ce7bfce5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ML_DATA_PREVIEW_COUNT } from '../../../../../../common/util/job_utils'; +import { useMlApiContext } from '../../../../contexts/kibana'; +import { usePermissionCheck } from '../../../../capabilities/check_capabilities'; +import { CombinedJob } from '../../../../../shared'; +import { MLJobEditor } from '../ml_job_editor'; + +interface Props { + job: CombinedJob; +} + +export const DatafeedPreviewPane: FC = ({ job }) => { + const { + jobs: { datafeedPreview }, + } = useMlApiContext(); + + const canPreviewDatafeed = usePermissionCheck('canPreviewDatafeed'); + const [loading, setLoading] = useState(false); + const [previewJson, setPreviewJson] = useState(''); + + useEffect(() => { + setLoading(true); + datafeedPreview(job.datafeed_config.datafeed_id).then((resp) => { + if (Array.isArray(resp)) { + if (resp.length === 0) { + setPreviewJson(null); + } else { + setPreviewJson(JSON.stringify(resp.slice(0, ML_DATA_PREVIEW_COUNT), null, 2)); + } + } else { + setPreviewJson(''); + } + + setLoading(false); + }); + }, [datafeedPreview, job]); + + if (canPreviewDatafeed === false) { + return ; + } + + return loading ? ( + + ) : ( + <> + {previewJson === null ? ( + + ) : ( + + )} + + ); +}; + +const InsufficientPermissions: FC = () => ( + + } + color="warning" + iconType="warning" + > +

+ +

+
+); + +const EmptyResults: FC = () => ( + + } + color="warning" + iconType="warning" + > +

+ +

+
+); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index f445afe634b90..2d28256336757 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -20,7 +20,7 @@ import { mlOnlyAggregations, } from '../../../../../../common/constants/aggregation_types'; import { getQueryFromSavedSearchObject } from '../../../../util/index_utils'; -import { +import type { Job, Datafeed, Detector, @@ -29,11 +29,11 @@ import { BucketSpan, CustomSettings, } from '../../../../../../common/types/anomaly_detection_jobs'; -import { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; +import type { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; import { combineFieldsAndAggs } from '../../../../../../common/util/fields_utils'; import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; import { mlJobService } from '../../../../services/job_service'; -import { JobRunner, ProgressSubscriber } from '../job_runner'; +import { JobRunner, type ProgressSubscriber } from '../job_runner'; import { JOB_TYPE, CREATED_BY_LABEL, @@ -42,7 +42,7 @@ import { import { collectAggs } from './util/general'; import { filterRuntimeMappings } from './util/filter_runtime_mappings'; import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { Calendar } from '../../../../../../common/types/calendars'; +import type { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; @@ -542,6 +542,28 @@ export class JobCreator { this._datafeed_config.indices = indics; } + public get ignoreUnavailable(): boolean { + return !!this._datafeed_config.indices_options?.ignore_unavailable; + } + + public set ignoreUnavailable(ignore: boolean) { + if (ignore === true) { + if (this._datafeed_config.indices_options === undefined) { + this._datafeed_config.indices_options = {}; + } + this._datafeed_config.indices_options.ignore_unavailable = true; + } else { + if (this._datafeed_config.indices_options !== undefined) { + delete this._datafeed_config.indices_options.ignore_unavailable; + + // if no other properties are set, remove indices_options + if (Object.keys(this._datafeed_config.indices_options).length === 0) { + delete this._datafeed_config.indices_options; + } + } + } + } + public get scriptFields(): Field[] { return this._scriptFields; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx index 6774db9225fd4..e0ad13e1d1c00 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx @@ -20,6 +20,7 @@ import { DedicatedIndexSwitch } from './components/dedicated_index'; import { ModelMemoryLimitInput } from '../../../common/model_memory_limit'; import { JobCreatorContext } from '../../../job_creator_context'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; +import { IgnoreUnavailableSwitch } from './components/ignore_unavailable'; const buttonContent = i18n.translate( 'xpack.ml.newJob.wizard.jobDetailsStep.advancedSectionButton', @@ -43,12 +44,22 @@ export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpand - + + + + + + + + + + + ); } @@ -71,13 +82,39 @@ export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpand > - - + + + + + + + + + + + + + + + + + + + + ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx index 12294a2e3fed4..154d0785e244e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiCallOut, EuiSwitch } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; @@ -62,7 +62,6 @@ export const AnnotationsSwitch: FC = () => { iconType="help" /> )} - ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx new file mode 100644 index 0000000000000..5e40856084246 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCode, EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +export const Description: FC = memo(({ children }) => { + const title = i18n.translate( + 'xpack.ml.newJob.wizard.jobDetailsStep.advancedSection.ignoreUnavailable.title', + { + defaultMessage: 'Ignore unavailable indices', + } + ); + return ( + {title}} + description={ + + ignore_unavailable + + ), + }} + /> + } + > + + <>{children} + + + ); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx new file mode 100644 index 0000000000000..aa6be80329c76 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const IgnoreUnavailableSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [ignoreUnavailable, setIgnoreUnavailable] = useState(jobCreator.ignoreUnavailable); + + useEffect(() => { + jobCreator.ignoreUnavailable = ignoreUnavailable; + jobCreatorUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ignoreUnavailable]); + + function toggleIgnoreUnavailable() { + setIgnoreUnavailable(!ignoreUnavailable); + } + + return ( + + + + ); +}; diff --git a/x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts similarity index 75% rename from x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts rename to x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts index 4f81fd1744795..997ee4f81a175 100644 --- a/x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts @@ -5,6 +5,4 @@ * 2.0. */ -import 'brace/mode/json'; -import 'brace/mode/text'; -import 'brace/theme/textmate'; +export { IgnoreUnavailableSwitch } from './ignore_unavailable_switch'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx index 631922a388faa..167335bacab77 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiSwitch } from '@elastic/eui'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; import { MMLCallout } from '../mml_callout'; @@ -58,7 +58,6 @@ export const ModelPlotSwitch: FC = () => { />
- ); }; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 0e209c01ddb6a..1ab3a659442b1 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -368,8 +368,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ ) { const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents }); return httpService.http<{ - total: number; - categories: Array<{ count?: number; category: Category }>; + success: boolean; }>({ path: `${ML_BASE_PATH}/jobs/revert_model_snapshot`, method: 'POST', @@ -379,10 +378,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ datafeedPreview(datafeedId?: string, job?: Job, datafeed?: Datafeed) { const body = JSON.stringify({ datafeedId, job, datafeed }); - return httpService.http<{ - total: number; - categories: Array<{ count?: number; category: Category }>; - }>({ + return httpService.http({ path: `${ML_BASE_PATH}/jobs/datafeed_preview`, method: 'POST', body, diff --git a/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx b/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx index dce255a6d244b..f8d1b4b43af74 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx @@ -6,11 +6,12 @@ */ import React, { Fragment } from 'react'; -import * as useUiSettingHook from '@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting'; import { useParams } from 'react-router-dom'; import { Chance } from 'chance'; import { waitFor } from '@testing-library/react'; import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import * as useUiSettingHook from '@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting'; import { Subset } from '../../typings'; import { render } from '../../utils/test_helper'; @@ -120,10 +121,18 @@ describe('Alert details', () => { mockKibana(); }); + const renderComponent = () => + render( + + + , + config + ); + it('should show the alert detail page with all necessary components', async () => { useFetchAlertDetailMock.mockReturnValue([false, alert]); - const alertDetails = render(, config); + const alertDetails = renderComponent(); await waitFor(() => expect(alertDetails.queryByTestId('centerJustifiedSpinner')).toBeFalsy()); @@ -136,7 +145,7 @@ describe('Alert details', () => { it('should show error loading the alert details', async () => { useFetchAlertDetailMock.mockReturnValue([false, alertWithNoData]); - const alertDetails = render(, config); + const alertDetails = renderComponent(); expect(alertDetails.queryByTestId('alertDetailsError')).toBeTruthy(); expect(alertDetails.queryByTestId('centerJustifiedSpinner')).toBeFalsy(); @@ -146,7 +155,7 @@ describe('Alert details', () => { it('should show loading spinner', async () => { useFetchAlertDetailMock.mockReturnValue([true, alertWithNoData]); - const alertDetails = render(, config); + const alertDetails = renderComponent(); expect(alertDetails.queryByTestId('centerJustifiedSpinner')).toBeTruthy(); expect(alertDetails.queryByTestId('alertDetailsError')).toBeFalsy(); diff --git a/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx b/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx index 9572c287257de..7bdb4d1054640 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { EuiEmptyPrompt, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { ALERT_RULE_TYPE_ID, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import { ALERT_RULE_CATEGORY, ALERT_RULE_TYPE_ID, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; import { RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '../../utils/kibana_react'; @@ -17,7 +17,7 @@ import { useFetchRule } from '../../hooks/use_fetch_rule'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { useFetchAlertDetail } from '../../hooks/use_fetch_alert_detail'; -import { PageTitle } from './components/page_title'; +import { PageTitle, pageTitleContent } from './components/page_title'; import { HeaderActions } from './components/header_actions'; import { AlertSummary, AlertSummaryField } from './components/alert_summary'; import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; @@ -33,6 +33,9 @@ interface AlertDetailsPathParams { } export const ALERT_DETAILS_PAGE_ID = 'alert-details-o11y'; +const defaultBreadcrumb = i18n.translate('xpack.observability.breadcrumbs.alertDetails', { + defaultMessage: 'Alert details', +}); export function AlertDetails() { const { @@ -69,6 +72,9 @@ export function AlertDetails() { defaultMessage: 'Alerts', }), }, + { + text: alert ? pageTitleContent(alert.fields[ALERT_RULE_CATEGORY]) : defaultBreadcrumb, + }, ]); if (isLoading) { diff --git a/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx index 41172644b9cf7..201e19a5c94a6 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx @@ -34,6 +34,18 @@ export interface PageTitleProps { alert: TopAlert | null; } +export function pageTitleContent(ruleCategory: string) { + return ( + + ); +} + export function PageTitle({ alert }: PageTitleProps) { const { euiTheme } = useEuiTheme(); @@ -41,13 +53,7 @@ export function PageTitle({ alert }: PageTitleProps) { return (
- + {pageTitleContent(alert.fields[ALERT_RULE_CATEGORY])} diff --git a/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts b/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts index 91c0b9f8c8ffd..6f61c5f6277f2 100644 --- a/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts +++ b/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts @@ -65,7 +65,7 @@ export function sloBurnRateRuleType( alerts: { context: SLO_RULE_REGISTRATION_CONTEXT, mappings: { fieldMap: { ...legacyExperimentalFieldMap, ...sloRuleFieldMap } }, - useEcs: true, + useEcs: false, useLegacyAlerts: true, }, }; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js index adfb9aafc444e..daba32a3b9520 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js @@ -6,22 +6,14 @@ */ import React from 'react'; - -import { EuiCodeEditor } from '../../../../../shared_imports'; +import { EuiCodeBlock } from '@elastic/eui'; export const TabJson = ({ json }) => { const jsonString = JSON.stringify(json, null, 2); return ( - + + {jsonString} + ); }; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js index fa63639ef4d06..dc38bd3af9afd 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js @@ -322,9 +322,8 @@ describe('', () => { const tabContent = find('rollupJobDetailTabContent'); it('should render the "EuiCodeEditor" with the job "json" data', () => { - const euiCodeEditor = tabContent.find('EuiCodeEditor'); - expect(euiCodeEditor.length).toBeTruthy(); - expect(JSON.parse(euiCodeEditor.props().value)).toEqual(defaultJob.json); + const euiCodeEditor = tabContent.find('[data-test-subj="jsonCodeBlock"]').at(0); + expect(JSON.parse(euiCodeEditor.text())).toEqual(defaultJob.json); }); }); }); diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index cee1fe39bc16a..ed0d444ccb160 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -5,12 +5,7 @@ * 2.0. */ -export { - extractQueryParams, - indices, - SectionLoading, - EuiCodeEditor, -} from '@kbn/es-ui-shared-plugin/public'; +export { extractQueryParams, indices, SectionLoading } from '@kbn/es-ui-shared-plugin/public'; export { KibanaContextProvider, diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2551bc34f7fa4..7da50bd569a47 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -156,6 +156,7 @@ export const DATA_QUALITY_PATH = '/data_quality' as const; export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; +export const ALERT_DETAILS_REDIRECT_PATH = `${ALERTS_PATH}/redirect` as const; export const RULES_PATH = '/rules' as const; export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const; export const EXCEPTIONS_PATH = '/exceptions' as const; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts new file mode 100644 index 0000000000000..66c06b8406d3c --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; +import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0'; +import type { + Ancestor840, + BaseFields840, + EqlBuildingBlockFields840, + EqlShellFields840, + NewTermsFields840, +} from '../8.4.0'; + +/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.8.0. +Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.8.0. +If you are adding new fields for a new release of Kibana, create a new sibling folder to this one +for the version to be released and add the field(s) to the schema in that folder. +Then, update `../index.ts` to import from the new folder that has the latest schemas, add the +new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas. +*/ + +export type { Ancestor840 as Ancestor880 }; +export interface BaseFields880 extends BaseFields840 { + [ALERT_URL]: string | undefined; + [ALERT_UUID]: string; +} + +export interface WrappedFields880 { + _id: string; + _index: string; + _source: T; +} + +export type GenericAlert880 = AlertWithCommonFields800; + +export type EqlShellFields880 = EqlShellFields840 & BaseFields880; + +export type EqlBuildingBlockFields880 = EqlBuildingBlockFields840 & BaseFields880; + +export type NewTermsFields880 = NewTermsFields840 & BaseFields880; + +export type NewTermsAlert880 = NewTermsFields840 & BaseFields880; + +export type EqlBuildingBlockAlert880 = AlertWithCommonFields800; + +export type EqlShellAlert880 = AlertWithCommonFields800; + +export type DetectionAlert880 = + | GenericAlert880 + | EqlShellAlert880 + | EqlBuildingBlockAlert880 + | NewTermsAlert880; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts index 2fdf426f0aea0..1d3e3f0d35f4f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts @@ -7,18 +7,18 @@ import type { DetectionAlert800 } from './8.0.0'; -import type { - Ancestor840, - BaseFields840, - DetectionAlert840, - WrappedFields840, - EqlBuildingBlockFields840, - EqlShellFields840, - NewTermsFields840, -} from './8.4.0'; - +import type { DetectionAlert840 } from './8.4.0'; import type { DetectionAlert860 } from './8.6.0'; import type { DetectionAlert870 } from './8.7.0'; +import type { + Ancestor880, + BaseFields880, + DetectionAlert880, + EqlBuildingBlockFields880, + EqlShellFields880, + NewTermsFields880, + WrappedFields880, +} from './8.8.0'; // When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version // here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0 @@ -26,14 +26,15 @@ export type DetectionAlert = | DetectionAlert800 | DetectionAlert840 | DetectionAlert860 - | DetectionAlert870; + | DetectionAlert870 + | DetectionAlert880; export type { - Ancestor840 as AncestorLatest, - BaseFields840 as BaseFieldsLatest, - DetectionAlert860 as DetectionAlertLatest, - WrappedFields840 as WrappedFieldsLatest, - EqlBuildingBlockFields840 as EqlBuildingBlockFieldsLatest, - EqlShellFields840 as EqlShellFieldsLatest, - NewTermsFields840 as NewTermsFieldsLatest, + Ancestor880 as AncestorLatest, + BaseFields880 as BaseFieldsLatest, + DetectionAlert880 as DetectionAlertLatest, + WrappedFields880 as WrappedFieldsLatest, + EqlBuildingBlockFields880 as EqlBuildingBlockFieldsLatest, + EqlShellFields880 as EqlShellFieldsLatest, + NewTermsFields880 as NewTermsFieldsLatest, }; diff --git a/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts b/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts new file mode 100644 index 0000000000000..be827e082db14 --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildAlertDetailPath, getAlertDetailsUrl } from './alert_detail_path'; + +describe('alert_detail_path', () => { + const defaultArguments = { + alertId: 'testId', + index: 'testIndex', + timestamp: '2023-04-18T00:00:00.000Z', + }; + describe('buildAlertDetailPath', () => { + it('builds the alert detail path as expected', () => { + expect(buildAlertDetailPath(defaultArguments)).toMatchInlineSnapshot( + `"/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + }); + describe('getAlertDetailsUrl', () => { + it('builds the alert detail path without a space id', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + basePath: 'http://somebasepath.com', + }) + ).toMatchInlineSnapshot( + `"http://somebasepath.com/app/security/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + + it('builds the alert detail path with a space id', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + basePath: 'http://somebasepath.com', + spaceId: 'test-space', + }) + ).toMatchInlineSnapshot( + `"http://somebasepath.com/s/test-space/app/security/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + + it('does not build the alert detail path without a basePath', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + spaceId: 'test-space', + }) + ).toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts b/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts new file mode 100644 index 0000000000000..2fcc1b6687b7d --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; +import { ALERT_DETAILS_REDIRECT_PATH, APP_PATH } from '../constants'; + +export const buildAlertDetailPath = ({ + alertId, + index, + timestamp, +}: { + alertId: string; + index: string; + timestamp: string; +}) => `${ALERT_DETAILS_REDIRECT_PATH}/${alertId}?index=${index}×tamp=${timestamp}`; + +export const getAlertDetailsUrl = ({ + alertId, + index, + timestamp, + basePath, + spaceId, +}: { + alertId: string; + index: string; + timestamp: string; + basePath?: string; + spaceId?: string | null; +}) => { + const alertDetailPath = buildAlertDetailPath({ alertId, index, timestamp }); + const alertDetailPathWithAppPath = `${APP_PATH}${alertDetailPath}`; + return basePath + ? addSpaceIdToPath(basePath, spaceId ?? undefined, alertDetailPathWithAppPath) + : undefined; +}; diff --git a/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts index 46bfd1f388ea6..260ef5a393b3e 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts @@ -26,7 +26,6 @@ import { CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, @@ -99,8 +98,7 @@ describe('Cases', () => { const expectedTags = this.mycase.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', this.mycase.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'Open'); - cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', this.mycase.reporter); - cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'Description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${this.mycase.description} ${this.mycase.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts index 947f92d3ec4aa..a42f81481d576 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts @@ -134,7 +134,7 @@ describe('Alert details flyout', () => { cy.get('[data-test-subj="formatted-field-_id"]') .invoke('text') .then((alertId) => { - cy.visit(`http://localhost:5620/app/security/alerts/${alertId}`); + cy.visit(`http://localhost:5620/app/security/alerts/redirect/${alertId}`); cy.get('[data-test-subj="unifiedQueryInput"]').should('have.text', `_id: ${alertId}`); cy.get(ALERTS_COUNT).should('have.text', '1 alert'); cy.get(OVERVIEW_RULE).should('be.visible'); diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index fcd8b60557fc1..271ef54922d5d 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -12,7 +12,7 @@ export const CASE_CONNECTOR = '[data-test-subj="connector-fields"] .euiCard__tit export const CASE_DELETE = '[data-test-subj="property-actions-trash"]'; export const CASE_DETAILS_DESCRIPTION = - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -21,13 +21,10 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status-dropdown"] export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"] button'; export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = - '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; - -export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = - '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; + '[data-test-subj="description"] [data-test-subj="description-title"]'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="user-profile-username"]'; @@ -41,7 +38,7 @@ export const CASES_TAGS = (tagName: string) => { return `[data-test-subj="tag-${tagName}"]`; }; -export const CASE_USER_ACTION = '[data-test-subj="user-action-markdown"]'; +export const CASE_USER_ACTION = '[data-test-subj="scrollable-markdown"]'; export const CONNECTOR_CARD_DETAILS = '[data-test-subj="connector-card-details"]'; diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index 9401b69d11657..a929c15b48641 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -32,6 +32,7 @@ "maps", "ruleRegistry", "sessionView", + "spaces", "taskManager", "threatIntelligence", "timelines", @@ -50,7 +51,6 @@ "ml", "newsfeed", "security", - "spaces", "usageCollection", "lists", "home", diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx index bf6f85712b6c7..7fa169b32d348 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx @@ -344,7 +344,7 @@ describe('When showing Endpoint Agent Status', () => { }); it('should keep agent status up to date when autoRefresh is true', async () => { - renderProps.autoFresh = true; + renderProps.autoRefresh = true; apiMocks.responseProvider.metadataDetails.mockReturnValueOnce(endpointDetails); const { getByTestId } = render(); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx index e74cdcf41fd57..1b2d021634a2f 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx @@ -138,7 +138,7 @@ export interface EndpointAgentStatusByIdProps { * If set to `true` (Default), then the endpoint status and isolation/action counts will * be kept up to date by querying the API periodically */ - autoFresh?: boolean; + autoRefresh?: boolean; 'data-test-subj'?: string; } @@ -150,9 +150,9 @@ export interface EndpointAgentStatusByIdProps { * instead in order to avoid duplicate API calls. */ export const EndpointAgentStatusById = memo( - ({ endpointAgentId, autoFresh, 'data-test-subj': dataTestSubj }) => { + ({ endpointAgentId, autoRefresh, 'data-test-subj': dataTestSubj }) => { const { data } = useGetEndpointDetails(endpointAgentId, { - refetchInterval: autoFresh ? DEFAULT_POLL_INTERVAL : false, + refetchInterval: autoRefresh ? DEFAULT_POLL_INTERVAL : false, }); const emptyValue = ( @@ -169,7 +169,7 @@ export const EndpointAgentStatusById = memo( ); } diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts index 2afd961c7a9b7..4b0e5cdd2a0e3 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts @@ -21,6 +21,7 @@ const { applyDeltaToColumnWidth, changeViewMode, removeColumn, + toggleDetailPanel, updateColumnOrder, updateColumns, updateColumnWidth, @@ -46,6 +47,7 @@ const tableActionTypes = [ updateShowBuildingBlockAlertsFilter.type, updateTotalCount.type, updateIsLoading.type, + toggleDetailPanel.type, ]; export const createDataTableLocalStorageEpic = diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index 829a6688f4b60..e07848c145d08 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -303,6 +303,7 @@ const EditRulePageComponent: FC = () => { {actionsStep.data != null && ( { }, ], [ + rule?.id, rule?.immutable, rule?.type, loading, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx deleted file mode 100644 index e610715d676ce..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useFetchRulesSnoozeSettings } from '../../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'; -import { RuleSnoozeBadge } from '../../../../../components/rule_snooze_badge'; -import * as i18n from './translations'; - -interface RuleDetailsSnoozeBadge { - /** - * Rule's SO id (not ruleId) - */ - id: string; -} - -export function RuleDetailsSnoozeSettings({ id }: RuleDetailsSnoozeBadge): JSX.Element { - const { data: rulesSnoozeSettings, isFetching, isError } = useFetchRulesSnoozeSettings([id]); - const snoozeSettings = rulesSnoozeSettings?.[0]; - - return ( - - ); -} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx index 07cbd4294cb22..67a156e31edf2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx @@ -87,8 +87,8 @@ jest.mock('react-router-dom', () => { }); // RuleDetailsSnoozeSettings is an isolated component and not essential for existing tests -jest.mock('./components/rule_details_snooze_settings', () => ({ - RuleDetailsSnoozeSettings: () => <>, +jest.mock('../../../rule_management/components/rule_snooze_badge', () => ({ + RuleSnoozeBadge: () => <>, })); const mockRedirectLegacyUrl = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 90f1d38f69774..211321618068f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -140,7 +140,7 @@ import { EditRuleSettingButtonLink } from '../../../../detections/pages/detectio import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs'; import { useBulkDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation'; import { BulkActionDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation'; -import { RuleDetailsSnoozeSettings } from './components/rule_details_snooze_settings'; +import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze_badge'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -559,7 +559,7 @@ const RuleDetailsPageComponent: React.FC = ({ )} - + ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index b1a2d0f95417a..5e3248818bf4e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -791,6 +791,9 @@ describe('Detections Rules API', () => { describe('fetchRulesSnoozeSettings', () => { beforeEach(() => { fetchMock.mockClear(); + fetchMock.mockResolvedValue({ + data: [], + }); }); test('requests snooze settings of multiple rules by their IDs', () => { @@ -836,5 +839,38 @@ describe('Detections Rules API', () => { }) ); }); + + test('returns mapped data', async () => { + fetchMock.mockResolvedValue({ + data: [ + { + id: '1', + mute_all: false, + }, + { + id: '1', + mute_all: false, + active_snoozes: [], + is_snoozed_until: '2023-04-24T19:31:46.765Z', + }, + ], + }); + + const result = await fetchRulesSnoozeSettings({ ids: ['id1'] }); + + expect(result).toEqual([ + { + id: '1', + muteAll: false, + activeSnoozes: [], + }, + { + id: '1', + muteAll: false, + activeSnoozes: [], + isSnoozedUntil: new Date('2023-04-24T19:31:46.765Z'), + }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index b8078421ce683..24b66cada346c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -57,7 +57,8 @@ import type { PrePackagedRulesStatusResponse, PreviewRulesProps, Rule, - RulesSnoozeSettingsResponse, + RuleSnoozeSettings, + RulesSnoozeSettingsBatchResponse, UpdateRulesProps, } from '../logic/types'; import { convertRulesFilterToKQL } from '../logic/utils'; @@ -197,8 +198,8 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => - KibanaServices.get().http.fetch( +}: FetchRuleSnoozingProps): Promise => { + const response = await KibanaServices.get().http.fetch( INTERNAL_ALERTING_API_FIND_RULES_PATH, { method: 'GET', @@ -211,6 +212,17 @@ export const fetchRulesSnoozeSettings = async ({ } ); + return response.data?.map((snoozeSettings) => ({ + id: snoozeSettings?.id ?? '', + muteAll: snoozeSettings?.mute_all ?? false, + activeSnoozes: snoozeSettings?.active_snoozes ?? [], + isSnoozedUntil: snoozeSettings?.is_snoozed_until + ? new Date(snoozeSettings.is_snoozed_until) + : undefined, + snoozeSchedule: snoozeSettings?.snooze_schedule, + })); +}; + export interface BulkActionSummary { failed: number; skipped: number; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts index bdc101fe18644..8e0ef31871826 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts @@ -29,11 +29,7 @@ export const useFetchRulesSnoozeSettings = ( ) => { return useQuery( [...FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY, ...ids], - async ({ signal }) => { - const response = await fetchRulesSnoozeSettings({ ids, signal }); - - return response.data; - }, + ({ signal }) => fetchRulesSnoozeSettings({ ids, signal }), { ...DEFAULT_QUERY_OPTIONS, ...queryOptions, diff --git a/x-pack/plugins/grokdebugger/public/lib/ace/index.js b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts similarity index 86% rename from x-pack/plugins/grokdebugger/public/lib/ace/index.js rename to x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts index 3152baa94f0ec..8e231398688f0 100644 --- a/x-pack/plugins/grokdebugger/public/lib/ace/index.js +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { GrokMode } from './grok_mode'; +export * from './rule_snooze_badge'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/components/rule_snooze_badge.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/rule_snooze_badge.tsx similarity index 55% rename from x-pack/plugins/security_solution/public/detection_engine/components/rule_snooze_badge.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/rule_snooze_badge.tsx index 7fa16826eec60..e488127c25691 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/components/rule_snooze_badge.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/rule_snooze_badge.tsx @@ -7,46 +7,44 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { useUserData } from '../../detections/components/user_info'; -import { hasUserCRUDPermission } from '../../common/utils/privileges'; -import { useKibana } from '../../common/lib/kibana'; -import type { RuleSnoozeSettings } from '../rule_management/logic'; -import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../rule_management/api/hooks/use_fetch_rules_snooze_settings'; +import type { RuleObjectId } from '../../../../../common/detection_engine/rule_schema'; +import { useUserData } from '../../../../detections/components/user_info'; +import { hasUserCRUDPermission } from '../../../../common/utils/privileges'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../../api/hooks/use_fetch_rules_snooze_settings'; +import { useRuleSnoozeSettings } from './use_rule_snooze_settings'; interface RuleSnoozeBadgeProps { /** - * Rule's snooze settings, when set to `undefined` considered as a loading state + * Rule's SO id (not ruleId) */ - snoozeSettings: RuleSnoozeSettings | undefined; - /** - * It should represent a user readable error message happened during data snooze settings fetching - */ - error?: string; + ruleId: RuleObjectId; showTooltipInline?: boolean; } export function RuleSnoozeBadge({ - snoozeSettings, - error, + ruleId, showTooltipInline = false, }: RuleSnoozeBadgeProps): JSX.Element { const RulesListNotifyBadge = useKibana().services.triggersActionsUi.getRulesListNotifyBadge; + const { snoozeSettings, error } = useRuleSnoozeSettings(ruleId); const [{ canUserCRUD }] = useUserData(); const hasCRUDPermissions = hasUserCRUDPermission(canUserCRUD); const invalidateFetchRuleSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery(); const isLoading = !snoozeSettings; - const rule = useMemo(() => { - return { + const rule = useMemo( + () => ({ id: snoozeSettings?.id ?? '', - muteAll: snoozeSettings?.mute_all ?? false, - activeSnoozes: snoozeSettings?.active_snoozes ?? [], - isSnoozedUntil: snoozeSettings?.is_snoozed_until - ? new Date(snoozeSettings.is_snoozed_until) + muteAll: snoozeSettings?.muteAll ?? false, + activeSnoozes: snoozeSettings?.activeSnoozes ?? [], + isSnoozedUntil: snoozeSettings?.isSnoozedUntil + ? new Date(snoozeSettings.isSnoozedUntil) : undefined, - snoozeSchedule: snoozeSettings?.snooze_schedule, + snoozeSchedule: snoozeSettings?.snoozeSchedule, isEditable: hasCRUDPermissions, - }; - }, [snoozeSettings, hasCRUDPermissions]); + }), + [snoozeSettings, hasCRUDPermissions] + ); if (error) { return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/translations.ts similarity index 68% rename from x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/translations.ts rename to x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/translations.ts index 37b3b6c75ba6e..2c67bdab2744f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/translations.ts @@ -7,8 +7,8 @@ import { i18n } from '@kbn/i18n'; -export const UNABLE_TO_FETCH_RULE_SNOOZE_SETTINGS = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDetails.rulesSnoozeSettings.error.unableToFetch', +export const UNABLE_TO_FETCH_RULES_SNOOZE_SETTINGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rulesSnoozeBadge.error.unableToFetch', { defaultMessage: 'Unable to fetch snooze settings', } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts new file mode 100644 index 0000000000000..94a857b1e9842 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleSnoozeSettings } from '../../logic'; +import { useFetchRulesSnoozeSettings } from '../../api/hooks/use_fetch_rules_snooze_settings'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import * as i18n from './translations'; + +interface UseRuleSnoozeSettingsResult { + snoozeSettings?: RuleSnoozeSettings; + error?: string; +} + +export function useRuleSnoozeSettings(id: string): UseRuleSnoozeSettingsResult { + const { + state: { rulesSnoozeSettings: rulesTableSnoozeSettings }, + } = useRulesTableContextOptional() ?? { state: {} }; + const { + data: rulesSnoozeSettings, + isFetching: isSingleSnoozeSettingsFetching, + isError: isSingleSnoozeSettingsError, + } = useFetchRulesSnoozeSettings([id], { + enabled: !rulesTableSnoozeSettings?.data[id] && !rulesTableSnoozeSettings?.isFetching, + }); + const snoozeSettings = rulesTableSnoozeSettings?.data[id] ?? rulesSnoozeSettings?.[0]; + const isFetching = rulesTableSnoozeSettings?.isFetching || isSingleSnoozeSettingsFetching; + const isError = rulesTableSnoozeSettings?.isError || isSingleSnoozeSettingsError; + + return { + snoozeSettings, + error: + isError || (!snoozeSettings && !isFetching) + ? i18n.UNABLE_TO_FETCH_RULES_SNOOZE_SETTINGS + : undefined, + }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index ca71fa2680f17..e22be9467c6a1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -219,6 +219,14 @@ export interface FetchRulesProps { } export interface RuleSnoozeSettings { + id: string; + muteAll: boolean; + snoozeSchedule?: RuleSnooze; + activeSnoozes?: string[]; + isSnoozedUntil?: Date; +} + +interface RuleSnoozeSettingsResponse { id: string; mute_all: boolean; snooze_schedule?: RuleSnooze; @@ -226,8 +234,8 @@ export interface RuleSnoozeSettings { is_snoozed_until?: string; } -export interface RulesSnoozeSettingsResponse { - data: RuleSnoozeSettings[]; +export interface RulesSnoozeSettingsBatchResponse { + data: RuleSnoozeSettingsResponse[]; } export type SortingOptions = t.TypeOf; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx index 22a3af8ff0814..abc384cea3bfb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx @@ -190,8 +190,8 @@ describe('RulesTableContextProvider', () => { { id: '2', name: 'rule 2' }, ] as Rule[], rulesSnoozeSettings: [ - { id: '1', mute_all: true, snooze_schedule: [] }, - { id: '2', mute_all: false, snooze_schedule: [] }, + { id: '1', muteAll: true, snoozeSchedule: [] }, + { id: '2', muteAll: false, snoozeSchedule: [] }, ], }); @@ -216,21 +216,21 @@ describe('RulesTableContextProvider', () => { { id: '2', name: 'rule 2' }, ] as Rule[], rulesSnoozeSettings: [ - { id: '1', mute_all: true, snooze_schedule: [] }, - { id: '2', mute_all: false, snooze_schedule: [] }, + { id: '1', muteAll: true, snoozeSchedule: [] }, + { id: '2', muteAll: false, snoozeSchedule: [] }, ], }); expect(state.rulesSnoozeSettings.data).toEqual({ '1': { id: '1', - mute_all: true, - snooze_schedule: [], + muteAll: true, + snoozeSchedule: [], }, '2': { id: '2', - mute_all: false, - snooze_schedule: [], + muteAll: false, + snoozeSchedule: [], }, }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts index ad3cd89604030..52b4a5d4ba622 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts @@ -21,10 +21,3 @@ export const ML_RULE_JOBS_WARNING_BUTTON_LABEL = i18n.translate( defaultMessage: 'Visit rule details page to investigate', } ); - -export const UNABLE_TO_FETCH_RULES_SNOOZE_SETTINGS = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleManagement.rulesSnoozeSettings.error.unableToFetch', - { - defaultMessage: 'Unable to fetch snooze settings', - } -); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index 0ffb0ac7574a6..cccd9f394d65e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -22,7 +22,7 @@ import type { } from '../../../../../common/detection_engine/rule_monitoring'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; -import { RuleSnoozeBadge } from '../../../components/rule_snooze_badge'; +import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze_badge'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; @@ -46,7 +46,6 @@ import { useHasActionsPrivileges } from './use_has_actions_privileges'; import { useHasMlPermissions } from './use_has_ml_permissions'; import { useRulesTableActions } from './use_rules_table_actions'; import { MlRuleWarningPopover } from './ml_rule_warning_popover'; -import * as rulesTableI18n from './translations'; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; @@ -109,33 +108,15 @@ const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): Ta }; const useRuleSnoozeColumn = (): TableColumn => { - const { - state: { rulesSnoozeSettings }, - } = useRulesTableContext(); - return useMemo( () => ({ field: 'snooze', name: i18n.COLUMN_SNOOZE, - render: (_, rule: Rule) => { - const snoozeSettings = rulesSnoozeSettings.data[rule.id]; - const { isFetching, isError } = rulesSnoozeSettings; - - return ( - - ); - }, + render: (_, rule: Rule) => , width: '100px', sortable: false, }), - [rulesSnoozeSettings] + [] ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 86bbb7604add2..47c33f9282572 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -22,6 +22,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public'; import { UseArray } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { RuleObjectId } from '../../../../../common/detection_engine/rule_schema'; import { isQueryRule } from '../../../../../common/detection_engine/utils'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { ResponseActionsForm } from '../../../../detection_engine/rule_response_actions/response_actions_form'; @@ -35,8 +36,10 @@ import { useKibana } from '../../../../common/lib/kibana'; import { getSchema } from './get_schema'; import * as I18n from './translations'; import { APP_UI_ID } from '../../../../../common/constants'; +import { RuleSnoozeSection } from './rule_snooze_section'; interface StepRuleActionsProps extends RuleStepProps { + ruleId?: RuleObjectId; // Rule SO's id (not ruleId) defaultValues?: ActionsStepRule | null; actionMessageParams: ActionVariables; ruleType?: Type; @@ -68,6 +71,7 @@ const DisplayActionsHeader = () => { }; const StepRuleActionsComponent: FC = ({ + ruleId, addPadding = false, defaultValues, isReadOnlyView, @@ -166,9 +170,9 @@ const StepRuleActionsComponent: FC = ({ return application.capabilities.actions.show ? ( <> + {ruleId && } {displayActionsOptions} {responseActionsEnabled && displayResponseActionsOptions} - @@ -178,6 +182,7 @@ const StepRuleActionsComponent: FC = ({ ); }, [ + ruleId, application.capabilities.actions.show, displayActionsOptions, displayResponseActionsOptions, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx new file mode 100644 index 0000000000000..d9586f80f3e93 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import type { RuleObjectId } from '../../../../../common/detection_engine/rule_schema'; +import { RuleSnoozeBadge } from '../../../../detection_engine/rule_management/components/rule_snooze_badge'; +import * as i18n from './translations'; + +interface RuleSnoozeSectionProps { + ruleId: RuleObjectId; // Rule SO's id (not ruleId) +} + +export function RuleSnoozeSection({ ruleId }: RuleSnoozeSectionProps): JSX.Element { + const { euiTheme } = useEuiTheme(); + + return ( +
+ {i18n.RULE_SNOOZE_DESCRIPTION} + + + + + + + {i18n.SNOOZED_ACTIONS_WARNING} + + + +
+ ); +} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx index d467c3af05f8f..06368eadc30df 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx @@ -28,3 +28,18 @@ export const NO_ACTIONS_READ_PERMISSIONS = i18n.translate( 'Cannot create rule actions. You do not have "Read" permissions for the "Actions" plugin.', } ); + +export const RULE_SNOOZE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepRuleActions.snoozeDescription', + { + defaultMessage: + 'Select when automated actions should be performed if a rule evaluates as true.', + } +); + +export const SNOOZED_ACTIONS_WARNING = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepRuleActions.snoozedActionsWarning', + { + defaultMessage: 'Actions will not be preformed until it is unsnoozed.', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index 2d3a55c6b0cd4..6a7bdc526e750 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -10,7 +10,11 @@ import { Switch } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; +import { + ALERTS_PATH, + ALERT_DETAILS_REDIRECT_PATH, + SecurityPageName, +} from '../../../../common/constants'; import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { DetectionEnginePage } from '../detection_engine/detection_engine'; @@ -31,7 +35,7 @@ const AlertsContainerComponent: React.FC = () => { {/* Redirect to the alerts page filtered for the given alert id */} - + ); diff --git a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts index c461f712b75b5..51cd74de62f67 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts @@ -10,6 +10,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; +import type { DeleteAllEndpointDataResponse } from '../../../scripts/endpoint/common/delete_all_endpoint_data'; import type { IndexedEndpointPolicyResponse } from '../../../common/endpoint/data_loaders/index_endpoint_policy_response'; import type { HostPolicyResponse, @@ -56,6 +57,20 @@ declare global { ...args: Parameters['find']> ): Chainable>; + /** + * Continuously call provided callback function until it either return `true` + * or fail if `timeout` is reached. + * @param fn + * @param options + */ + waitUntil( + fn: (subject?: any) => boolean | Promise | Chainable, + options?: Partial<{ + interval: number; + timeout: number; + }> + ): Chainable; + task( name: 'indexFleetEndpointPolicy', arg: { @@ -124,6 +139,12 @@ declare global { arg: HostActionResponse, options?: Partial ): Chainable; + + task( + name: 'deleteAllEndpointData', + arg: { endpointAgentIds: string[] }, + options?: Partial + ): Chainable; } } } diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts new file mode 100644 index 0000000000000..8163e74db17b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { deleteAllLoadedEndpointData } from '../../tasks/delete_all_endpoint_data'; +import { getAlertsTableRows, navigateToAlertsList } from '../../screens/alerts'; +import { waitForEndpointAlerts } from '../../tasks/alerts'; +import { request } from '../../tasks/common'; +import { getEndpointIntegrationVersion } from '../../tasks/fleet'; +import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; +import { enableAllPolicyProtections } from '../../tasks/endpoint_policy'; +import type { PolicyData, ResponseActionApiResponse } from '../../../../../common/endpoint/types'; +import type { CreateAndEnrollEndpointHostResponse } from '../../../../../scripts/endpoint/common/endpoint_host_services'; +import { login } from '../../tasks/login'; +import { EXECUTE_ROUTE } from '../../../../../common/endpoint/constants'; +import { waitForActionToComplete } from '../../tasks/response_actions'; + +describe('Endpoint generated alerts', () => { + let indexedPolicy: IndexedFleetEndpointPolicyResponse; + let policy: PolicyData; + let createdHost: CreateAndEnrollEndpointHostResponse; + + before(() => { + getEndpointIntegrationVersion().then((version) => { + const policyName = `alerts test ${Math.random().toString(36).substring(2, 7)}`; + + cy.task('indexFleetEndpointPolicy', { + policyName, + endpointPackageVersion: version, + agentPolicyName: policyName, + }).then((data) => { + indexedPolicy = data; + policy = indexedPolicy.integrationPolicies[0]; + + return enableAllPolicyProtections(policy.id).then(() => { + // Create and enroll a new Endpoint host + return cy + .task( + 'createEndpointHost', + { + agentPolicyId: policy.policy_id, + }, + { timeout: 180000 } + ) + .then((host) => { + createdHost = host as CreateAndEnrollEndpointHostResponse; + }); + }); + }); + }); + }); + + after(() => { + if (createdHost) { + cy.task('destroyEndpointHost', createdHost).then(() => {}); + } + + if (indexedPolicy) { + cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy); + } + + if (createdHost) { + deleteAllLoadedEndpointData({ endpointAgentIds: [createdHost.agentId] }); + } + }); + + beforeEach(() => { + login(); + }); + + it('should create a Detection Engine alert from an endpoint alert', () => { + // Triggers a Malicious Behaviour alert on Linux system (`grep *` was added only to identify this specific alert) + const executeMaliciousCommand = `bash -c cat /dev/tcp/foo | grep ${Math.random() + .toString(16) + .substring(2)}`; + + // Send `execute` command that triggers malicious behaviour using the `execute` response action + request({ + method: 'POST', + url: EXECUTE_ROUTE, + body: { + endpoint_ids: [createdHost.agentId], + parameters: { + command: executeMaliciousCommand, + }, + }, + }) + .then((response) => waitForActionToComplete(response.body.data.id)) + .then(() => { + return waitForEndpointAlerts(createdHost.agentId, [ + { + term: { 'process.group_leader.args': executeMaliciousCommand }, + }, + ]); + }) + .then(() => { + return navigateToAlertsList( + `query=(language:kuery,query:'agent.id: "${createdHost.agentId}" ')` + ); + }); + + getAlertsTableRows().should('have.length.greaterThan', 0); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/alerts.ts new file mode 100644 index 0000000000000..48f0747464bf8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/alerts.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 { APP_ALERTS_PATH } from '../../../../common/constants'; + +export const navigateToAlertsList = (urlQueryParams: string = '') => { + cy.visit(`${APP_ALERTS_PATH}${urlQueryParams ? `?${urlQueryParams}` : ''}`); +}; + +export const clickAlertListRefreshButton = (): Cypress.Chainable => { + return cy.getByTestSubj('querySubmitButton').click().should('be.enabled'); +}; + +/** + * Waits until the Alerts list has alerts data and return the number of rows that are currently displayed + * @param timeout + */ +export const getAlertsTableRows = (timeout?: number): Cypress.Chainable> => { + let $rows: JQuery = Cypress.$(); + + return cy + .waitUntil( + () => { + clickAlertListRefreshButton(); + + return cy + .getByTestSubj('alertsTable') + .find('.euiDataGridRow') + .then(($rowsFound) => { + $rows = $rowsFound; + return Boolean($rows); + }); + }, + { timeout } + ) + .then(() => $rows); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts index 32a12168aadb0..da2c278b8e30d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts @@ -6,7 +6,7 @@ */ import { APP_PATH } from '../../../../common/constants'; -import { getEndpointDetailsPath } from '../../common/routing'; +import { getEndpointDetailsPath, getEndpointListPath } from '../../common/routing'; export const AGENT_HOSTNAME_CELL = 'hostnameCellLink'; export const AGENT_POLICY_CELL = 'policyNameCellLink'; @@ -21,3 +21,7 @@ export const navigateToEndpointPolicyResponse = ( getEndpointDetailsPath({ name: 'endpointPolicyResponse', selected_endpoint: endpointAgentId }) ); }; + +export const navigateToEndpointList = (): Cypress.Chainable => { + return cy.visit(APP_PATH + getEndpointListPath({ name: 'endpointList' })); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 6dd4bedaa8937..ffcca01a6f1e9 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -9,6 +9,13 @@ import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; import { sendEndpointActionResponse } from '../../../../scripts/endpoint/agent_emulator/services/endpoint_response_actions'; +import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data'; +import { deleteAllEndpointData } from '../../../../scripts/endpoint/common/delete_all_endpoint_data'; +import { waitForEndpointToStreamData } from '../../../../scripts/endpoint/common/endpoint_metadata_services'; +import type { + CreateAndEnrollEndpointHostOptions, + CreateAndEnrollEndpointHostResponse, +} from '../../../../scripts/endpoint/common/endpoint_host_services'; import type { IndexedEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; import { deleteIndexedEndpointPolicyResponse, @@ -39,6 +46,10 @@ import { deleteIndexedEndpointRuleAlerts, indexEndpointRuleAlerts, } from '../../../../common/endpoint/data_loaders/index_endpoint_rule_alerts'; +import { + createAndEnrollEndpointHost, + destroyEndpointHost, +} from '../../../../scripts/endpoint/common/endpoint_host_services'; /** * Cypress plugin for adding data loading related `task`s @@ -155,5 +166,47 @@ export const dataLoaders = ( const { esClient } = await stackServicesPromise; return sendEndpointActionResponse(esClient, data.action, { state: data.state.state }); }, + + deleteAllEndpointData: async ({ + endpointAgentIds, + }: { + endpointAgentIds: string[]; + }): Promise => { + const { esClient } = await stackServicesPromise; + return deleteAllEndpointData(esClient, endpointAgentIds); + }, + }); +}; + +export const dataLoadersForRealEndpoints = ( + on: Cypress.PluginEvents, + config: Cypress.PluginConfigOptions +): void => { + const stackServicesPromise = createRuntimeServices({ + kibanaUrl: config.env.KIBANA_URL, + elasticsearchUrl: config.env.ELASTICSEARCH_URL, + username: config.env.ELASTICSEARCH_USERNAME, + password: config.env.ELASTICSEARCH_PASSWORD, + asSuperuser: true, + }); + + on('task', { + createEndpointHost: async ( + options: Omit + ): Promise => { + const { kbnClient, log } = await stackServicesPromise; + return createAndEnrollEndpointHost({ ...options, log, kbnClient }).then((newHost) => { + return waitForEndpointToStreamData(kbnClient, newHost.agentId, 120000).then(() => { + return newHost; + }); + }); + }, + + destroyEndpointHost: async ( + createdHost: CreateAndEnrollEndpointHostResponse + ): Promise => { + const { kbnClient } = await stackServicesPromise; + return destroyEndpointHost(kbnClient, createdHost).then(() => null); + }, }); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts b/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts index 0ccb00e8d5e63..12c236f481791 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts @@ -50,4 +50,42 @@ Cypress.Commands.addQuery<'findByTestSubj'>( } ); +Cypress.Commands.add( + 'waitUntil', + { prevSubject: 'optional' }, + (subject, fn, { interval = 500, timeout = 30000 } = {}) => { + let attempts = Math.floor(timeout / interval); + + const completeOrRetry = (result: boolean) => { + if (result) { + return result; + } + if (attempts < 1) { + throw new Error(`Timed out while retrying, last result was: {${result}}`); + } + cy.wait(interval, { log: false }).then(() => { + attempts--; + return evaluate(); + }); + }; + + const evaluate = () => { + const result = fn(subject); + + if (typeof result === 'boolean') { + return completeOrRetry(result); + } else if ('then' in result) { + // @ts-expect-error + return result.then(completeOrRetry); + } else { + throw new Error( + `Unknown return type from callback: ${Object.prototype.toString.call(result)}` + ); + } + }; + + return evaluate(); + } +); + Cypress.on('uncaught:exception', () => false); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts new file mode 100644 index 0000000000000..a78b1c6742afa --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import type { Rule } from '../../../detection_engine/rule_management/logic'; +import { + DETECTION_ENGINE_QUERY_SIGNALS_URL, + DETECTION_ENGINE_RULES_BULK_ACTION, + DETECTION_ENGINE_RULES_URL, +} from '../../../../common/constants'; +import { ELASTIC_SECURITY_RULE_ID } from '../../../../common'; +import { request } from './common'; +import { ENDPOINT_ALERTS_INDEX } from '../../../../scripts/endpoint/common/constants'; +const ES_URL = Cypress.env('ELASTICSEARCH_URL'); + +/** + * Continuously check for any alert to have been received by the given endpoint. + * + * NOTE: This is tno the same as the alerts that populate the Alerts list. To check for + * those types of alerts, use `waitForDetectionAlerts()` + */ +export const waitForEndpointAlerts = ( + endpointAgentId: string, + additionalFilters?: object[], + timeout = 120000 +): Cypress.Chainable => { + return cy + .waitUntil( + () => { + return request({ + method: 'GET', + url: `${ES_URL}/${ENDPOINT_ALERTS_INDEX}/_search`, + body: { + query: { + match: { + 'agent.id': endpointAgentId, + }, + }, + size: 1, + _source: false, + }, + }).then(({ body: streamedAlerts }) => { + return (streamedAlerts.hits.total as estypes.SearchTotalHits).value > 0; + }); + }, + { timeout } + ) + .then(() => { + // Stop/start Endpoint rule so that it can pickup and create Detection alerts + cy.log( + `Received endpoint alerts for agent [${endpointAgentId}] in index [${ENDPOINT_ALERTS_INDEX}]` + ); + + return stopStartEndpointDetectionsRule(); + }) + .then(() => { + // wait until the Detection alert shows up in the API + return waitForDetectionAlerts(getEndpointDetectionAlertsQueryForAgentId(endpointAgentId)); + }); +}; + +export const fetchEndpointSecurityDetectionRule = (): Cypress.Chainable => { + return request({ + method: 'GET', + url: DETECTION_ENGINE_RULES_URL, + qs: { + rule_id: ELASTIC_SECURITY_RULE_ID, + }, + }).then(({ body }) => { + return body; + }); +}; + +export const stopStartEndpointDetectionsRule = (): Cypress.Chainable => { + return fetchEndpointSecurityDetectionRule() + .then((endpointRule) => { + // Disabled it + return request({ + method: 'POST', + url: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + action: 'disable', + ids: [endpointRule.id], + }, + }).then(() => { + return endpointRule; + }); + }) + .then((endpointRule) => { + cy.log(`Endpoint rule id [${endpointRule.id}] has been disabled`); + + // Re-enable it + return request({ + method: 'POST', + url: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + action: 'enable', + ids: [endpointRule.id], + }, + }).then(() => endpointRule); + }) + .then((endpointRule) => { + cy.log(`Endpoint rule id [${endpointRule.id}] has been re-enabled`); + return cy.wrap(endpointRule); + }); +}; + +/** + * Waits for alerts to have been loaded by continuously calling the detections engine alerts + * api until data shows up + * @param query + * @param timeout + */ +export const waitForDetectionAlerts = ( + /** The ES query. Defaults to `{ match_all: {} }` */ + query: object = { match_all: {} }, + timeout?: number +): Cypress.Chainable => { + return cy.waitUntil( + () => { + return request({ + method: 'POST', + url: DETECTION_ENGINE_QUERY_SIGNALS_URL, + body: { + query, + size: 1, + }, + }).then(({ body: alertsResponse }) => { + return Boolean((alertsResponse.hits.total as estypes.SearchTotalHits)?.value ?? 0); + }); + }, + { timeout } + ); +}; + +/** + * Builds and returns the ES `query` object for use in querying for Endpoint Detection Engine + * alerts. Can be used in ES searches or with the Detection Engine query signals (alerts) url. + * @param endpointAgentId + */ +export const getEndpointDetectionAlertsQueryForAgentId = (endpointAgentId: string) => { + return { + bool: { + filter: [ + { + bool: { + should: [{ match_phrase: { 'agent.type': 'endpoint' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'agent.id': endpointAgentId } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ exists: { field: 'kibana.alert.rule.uuid' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts new file mode 100644 index 0000000000000..761cde513ad52 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data'; + +export const deleteAllLoadedEndpointData = (options: { + endpointAgentIds: string[]; +}): Cypress.Chainable => { + return cy.task('deleteAllEndpointData', options); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts new file mode 100644 index 0000000000000..134fc470b412b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + GetOnePackagePolicyResponse, + UpdatePackagePolicy, + UpdatePackagePolicyResponse, +} from '@kbn/fleet-plugin/common'; +import { packagePolicyRouteService } from '@kbn/fleet-plugin/common'; +import { request } from './common'; +import { ProtectionModes } from '../../../../common/endpoint/types'; + +/** + * Updates the given Endpoint policy and enables all of the policy protections + * @param endpointPolicyId + */ +export const enableAllPolicyProtections = ( + endpointPolicyId: string +): Cypress.Chainable> => { + return request({ + method: 'GET', + url: packagePolicyRouteService.getInfoPath(endpointPolicyId), + }).then(({ body: { item: endpointPolicy } }) => { + const { + created_by: _createdBy, + created_at: _createdAt, + updated_at: _updatedAt, + updated_by: _updatedBy, + id, + version, + revision, + ...restOfPolicy + } = endpointPolicy; + + const updatedEndpointPolicy: UpdatePackagePolicy = restOfPolicy; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const policy = updatedEndpointPolicy!.inputs[0]!.config!.policy.value; + + policy.mac.malware.mode = ProtectionModes.prevent; + policy.windows.malware.mode = ProtectionModes.prevent; + policy.linux.malware.mode = ProtectionModes.prevent; + + policy.mac.memory_protection.mode = ProtectionModes.prevent; + policy.windows.memory_protection.mode = ProtectionModes.prevent; + policy.linux.memory_protection.mode = ProtectionModes.prevent; + + policy.mac.behavior_protection.mode = ProtectionModes.prevent; + policy.windows.behavior_protection.mode = ProtectionModes.prevent; + policy.linux.behavior_protection.mode = ProtectionModes.prevent; + + policy.windows.ransomware.mode = ProtectionModes.prevent; + + return request({ + method: 'PUT', + url: packagePolicyRouteService.getUpdatePath(endpointPolicyId), + body: updatedEndpointPolicy, + }); + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts index c888e7dce1254..13829f8d3378c 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { request } from './common'; +import { resolvePathVariables } from '../../../common/utils/resolve_path_variables'; +import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants'; +import type { ActionDetails, ActionDetailsApiResponse } from '../../../../common/endpoint/types'; import { ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS } from '../../../../common/endpoint/service/response_actions/constants'; export const validateAvailableCommands = () => { @@ -59,3 +63,40 @@ export const tryAddingDisabledResponseAction = (itemNumber = 0) => { }); cy.getByTestSubj(`response-actions-list-item-${itemNumber}`).should('not.exist'); }; + +/** + * Continuously checks an Response Action until it completes (or timeout is reached) + * @param actionId + * @param timeout + */ +export const waitForActionToComplete = ( + actionId: string, + timeout = 60000 +): Cypress.Chainable => { + let action: ActionDetails | undefined; + + return cy + .waitUntil( + () => { + return request({ + method: 'GET', + url: resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: actionId || 'undefined' }), + }).then((response) => { + if (response.body.data.isCompleted) { + action = response.body.data; + return true; + } + + return false; + }); + }, + { timeout } + ) + .then(() => { + if (!action) { + throw new Error(`Failed to retrieve completed action`); + } + + return action; + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts b/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts index 8975599350fe2..50a9d8f1f5356 100644 --- a/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts +++ b/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts @@ -7,7 +7,7 @@ import { defineCypressConfig } from '@kbn/cypress-config'; // eslint-disable-next-line @kbn/imports/no_boundary_crossing -import { dataLoaders } from './cypress/support/data_loaders'; +import { dataLoaders, dataLoadersForRealEndpoints } from './cypress/support/data_loaders'; // eslint-disable-next-line import/no-default-export export default defineCypressConfig({ @@ -40,7 +40,9 @@ export default defineCypressConfig({ specPattern: 'public/management/cypress/e2e/endpoint/*.cy.{js,jsx,ts,tsx}', experimentalRunAllSpecs: true, setupNodeEvents: (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => { - return dataLoaders(on, config); + dataLoaders(on, config); + // Data loaders specific to "real" Endpoint testing + dataLoadersForRealEndpoints(on, config); }, }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 9717daac79baf..c5bc7821278b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -49,18 +49,26 @@ Array [
- +
+ +
+
,
+
+
+
( )} - {handleOnEventClosed && ( - - - - )} - {isAlert && ( - - {(copy) => ( - - {i18n.SHARE_ALERT} - + + + {handleOnEventClosed && ( + + + + )} + {isAlert && alertDetailsLink && ( + + {(copy) => ( + + {i18n.SHARE_ALERT} + + )} + )} - - )} + + ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts index 1d2d1b5ea6213..9a074e16dc0b3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts @@ -6,9 +6,10 @@ */ import { useMemo } from 'react'; +import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants'; +import { buildAlertDetailPath } from '../../../../../common/utils/alert_detail_path'; import { useAppUrl } from '../../../../common/lib/kibana/hooks'; -import { ALERTS_PATH } from '../../../../../common/constants'; export const useGetAlertDetailsFlyoutLink = ({ _id, @@ -20,13 +21,16 @@ export const useGetAlertDetailsFlyoutLink = ({ timestamp: string; }) => { const { getAppUrl } = useAppUrl(); + const alertDetailPath = buildAlertDetailPath({ alertId: _id, index: _index, timestamp }); + const isPreviewAlert = _index.includes(DEFAULT_PREVIEW_INDEX); + // getAppUrl accounts for the users selected space const alertDetailsLink = useMemo(() => { - const url = getAppUrl({ - path: `${ALERTS_PATH}/${_id}?index=${_index}×tamp=${timestamp}`, - }); + if (isPreviewAlert) return null; + const url = getAppUrl({ path: alertDetailPath }); + // We use window.location.origin instead of http.basePath as the http.basePath has to be configured in config dev yml return `${window.location.origin}${url}`; - }, [_id, _index, getAppUrl, timestamp]); + }, [isPreviewAlert, getAppUrl, alertDetailPath]); return alertDetailsLink; }; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts new file mode 100644 index 0000000000000..6382964fda643 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Client, estypes } from '@elastic/elasticsearch'; +import assert from 'assert'; +import { createEsClient } from './stack_services'; +import { createSecuritySuperuser } from './security_user_services'; + +export interface DeleteAllEndpointDataResponse { + count: number; + query: string; + response: estypes.DeleteByQueryResponse; +} + +/** + * Attempts to delete all data associated with the provided endpoint agent IDs. + * + * **NOTE:** This utility will create a new role and user that has elevated privileges and access to system indexes. + * + * @param esClient + * @param endpointAgentIds + */ +export const deleteAllEndpointData = async ( + esClient: Client, + endpointAgentIds: string[] +): Promise => { + assert(endpointAgentIds.length > 0, 'At least one endpoint agent id must be defined'); + + const unrestrictedUser = await createSecuritySuperuser(esClient, 'super_superuser'); + const esUrl = getEsUrlFromClient(esClient); + const esClientUnrestricted = createEsClient({ + url: esUrl, + username: unrestrictedUser.username, + password: unrestrictedUser.password, + }); + + const queryString = endpointAgentIds.map((id) => `(${id})`).join(' OR '); + + const deleteResponse = await esClientUnrestricted.deleteByQuery({ + index: '*,.*', + body: { + query: { + query_string: { + query: queryString, + }, + }, + }, + ignore_unavailable: true, + conflicts: 'proceed', + }); + + return { + count: deleteResponse.deleted ?? 0, + query: queryString, + response: deleteResponse, + }; +}; + +const getEsUrlFromClient = (esClient: Client) => { + const connection = esClient.connectionPool.connections.find((entry) => entry.status === 'alive'); + + if (!connection) { + throw new Error( + 'Unable to get esClient connection information. No connection found with status `alive`' + ); + } + + const url = new URL(connection.url.href); + url.username = ''; + url.password = ''; + + return url.href; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts new file mode 100644 index 0000000000000..4bb03324f172e --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts @@ -0,0 +1,205 @@ +/* + * 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 { kibanaPackageJson } from '@kbn/repo-info'; +import type { KbnClient } from '@kbn/test'; +import type { ToolingLog } from '@kbn/tooling-log'; +import execa from 'execa'; +import assert from 'assert'; +import { + fetchAgentPolicyEnrollmentKey, + fetchFleetServerUrl, + getAgentDownloadUrl, + unEnrollFleetAgent, + waitForHostToEnroll, +} from './fleet_services'; + +export interface CreateAndEnrollEndpointHostOptions + extends Pick { + kbnClient: KbnClient; + log: ToolingLog; + /** The fleet Agent Policy ID to use for enrolling the agent */ + agentPolicyId: string; + /** version of the Agent to install. Defaults to stack version */ + version?: string; + /** The name for the host. Will also be the name of the VM */ + hostname?: string; +} + +export interface CreateAndEnrollEndpointHostResponse { + hostname: string; + agentId: string; +} + +/** + * Creates a new virtual machine (host) and enrolls that with Fleet + */ +export const createAndEnrollEndpointHost = async ({ + kbnClient, + log, + agentPolicyId, + cpus, + disk, + memory, + hostname, + version = kibanaPackageJson.version, +}: CreateAndEnrollEndpointHostOptions): Promise => { + const [vm, agentDownloadUrl, fleetServerUrl, enrollmentToken] = await Promise.all([ + createMultipassVm({ + vmName: hostname ?? `test-host-${Math.random().toString().substring(2, 6)}`, + disk, + cpus, + memory, + }), + + getAgentDownloadUrl(version, true, log), + + fetchFleetServerUrl(kbnClient), + + fetchAgentPolicyEnrollmentKey(kbnClient, agentPolicyId), + ]); + + // Some validations before we proceed + assert(agentDownloadUrl, 'Missing agent download URL'); + assert(fleetServerUrl, 'Fleet server URL not set'); + assert(enrollmentToken, `No enrollment token for agent policy id [${agentPolicyId}]`); + + log.verbose(`Enrolling host [${vm.vmName}] + with fleet-server [${fleetServerUrl}] + using enrollment token [${enrollmentToken}]`); + + const { agentId } = await enrollHostWithFleet({ + kbnClient, + log, + fleetServerUrl, + agentDownloadUrl, + enrollmentToken, + vmName: vm.vmName, + }); + + return { + hostname: vm.vmName, + agentId, + }; +}; + +/** + * Destroys the Endpoint Host VM and un-enrolls the Fleet agent + * @param kbnClient + * @param createdHost + */ +export const destroyEndpointHost = async ( + kbnClient: KbnClient, + createdHost: CreateAndEnrollEndpointHostResponse +): Promise => { + await Promise.all([ + deleteMultipassVm(createdHost.hostname), + unEnrollFleetAgent(kbnClient, createdHost.agentId, true), + ]); +}; + +interface CreateMultipassVmOptions { + vmName: string; + /** Number of CPUs */ + cpus?: number; + /** Disk size */ + disk?: string; + /** Amount of memory */ + memory?: string; +} + +interface CreateMultipassVmResponse { + vmName: string; +} + +/** + * Creates a new VM using `multipass` + */ +const createMultipassVm = async ({ + vmName, + disk = '8G', + cpus = 1, + memory = '1G', +}: CreateMultipassVmOptions): Promise => { + await execa.command( + `multipass launch --name ${vmName} --disk ${disk} --cpus ${cpus} --memory ${memory}` + ); + + return { + vmName, + }; +}; + +const deleteMultipassVm = async (vmName: string): Promise => { + await execa.command(`multipass delete -p ${vmName}`); +}; + +interface EnrollHostWithFleetOptions { + kbnClient: KbnClient; + log: ToolingLog; + vmName: string; + agentDownloadUrl: string; + fleetServerUrl: string; + enrollmentToken: string; +} + +const enrollHostWithFleet = async ({ + kbnClient, + log, + vmName, + fleetServerUrl, + agentDownloadUrl, + enrollmentToken, +}: EnrollHostWithFleetOptions): Promise<{ agentId: string }> => { + const agentDownloadedFile = agentDownloadUrl.substring(agentDownloadUrl.lastIndexOf('/') + 1); + const vmDirName = agentDownloadedFile.replace(/\.tar\.gz$/, ''); + + await execa.command( + `multipass exec ${vmName} -- curl -L ${agentDownloadUrl} -o ${agentDownloadedFile}` + ); + await execa.command(`multipass exec ${vmName} -- tar -zxf ${agentDownloadedFile}`); + await execa.command(`multipass exec ${vmName} -- rm -f ${agentDownloadedFile}`); + + const agentInstallArguments = [ + 'exec', + + vmName, + + '--working-directory', + `/home/ubuntu/${vmDirName}`, + + '--', + + 'sudo', + + './elastic-agent', + + 'install', + + '--insecure', + + '--force', + + '--url', + fleetServerUrl, + + '--enrollment-token', + enrollmentToken, + ]; + + log.info(`Enrolling elastic agent with Fleet`); + log.verbose(`Command: multipass ${agentInstallArguments.join(' ')}`); + + await execa(`multipass`, agentInstallArguments); + + log.info(`Waiting for Agent to check-in with Fleet`); + const agent = await waitForHostToEnroll(kbnClient, vmName, 120000); + + return { + agentId: agent.id, + }; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts index a1f21b80567d6..7823309aa0059 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts @@ -131,3 +131,46 @@ const fetchLastStreamedEndpointUpdate = async ( return queryResult.hits?.hits[0]?._source; }; + +/** + * Waits for an endpoint to have streamed data to ES and for that data to have made it to the + * Endpoint Details API (transform destination index) + * @param kbnClient + * @param endpointAgentId + * @param timeoutMs + */ +export const waitForEndpointToStreamData = async ( + kbnClient: KbnClient, + endpointAgentId: string, + timeoutMs: number = 60000 +): Promise => { + const started = new Date(); + const hasTimedOut = (): boolean => { + const elapsedTime = Date.now() - started.getTime(); + return elapsedTime > timeoutMs; + }; + let found: HostInfo | undefined; + + while (!found && !hasTimedOut()) { + found = await fetchEndpointMetadata(kbnClient, 'invalid-id-test').catch((error) => { + // Ignore `not found` (404) responses. Endpoint could be new and thus documents might not have + // been streamed yet. + if (error?.response?.status === 404) { + return undefined; + } + + throw error; + }); + + if (!found) { + // sleep and check again + await new Promise((r) => setTimeout(r, 2000)); + } + } + + if (!found) { + throw new Error(`Timed out waiting for Endpoint id [${endpointAgentId}] to stream data to ES`); + } + + return found; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts index 1f4d28cecc569..fca93d1848f4a 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts @@ -14,7 +14,12 @@ import type { GetAgentPoliciesResponse, GetAgentsResponse, } from '@kbn/fleet-plugin/common'; -import { AGENT_API_ROUTES, agentPolicyRouteService, AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import { + AGENT_API_ROUTES, + agentPolicyRouteService, + agentRouteService, + AGENTS_INDEX, +} from '@kbn/fleet-plugin/common'; import { ToolingLog } from '@kbn/tooling-log'; import type { KbnClient } from '@kbn/test'; import type { GetFleetServerHostsResponse } from '@kbn/fleet-plugin/common/types/rest_spec/fleet_server_hosts'; @@ -26,7 +31,10 @@ import type { EnrollmentAPIKey, GetAgentsRequest, GetEnrollmentAPIKeysResponse, + PostAgentUnenrollResponse, } from '@kbn/fleet-plugin/common/types'; +import nodeFetch from 'node-fetch'; +import semver from 'semver'; import { FleetAgentGenerator } from '../../../common/endpoint/data_generators/fleet_agent_generator'; const fleetGenerator = new FleetAgentGenerator(); @@ -236,3 +244,135 @@ export const getAgentVersionMatchingCurrentStack = async ( return version; }; + +interface ElasticArtifactSearchResponse { + manifest: { + 'last-update-time': string; + 'seconds-since-last-update': number; + }; + packages: { + [packageFileName: string]: { + architecture: string; + os: string[]; + type: string; + asc_url: string; + sha_url: string; + url: string; + }; + }; +} + +/** + * Retrieves the download URL to the Linux installation package for a given version of the Elastic Agent + * @param version + * @param closestMatch + * @param log + */ +export const getAgentDownloadUrl = async ( + version: string, + /** + * When set to true a check will be done to determine the latest version of the agent that + * is less than or equal to the `version` provided + */ + closestMatch: boolean = false, + log?: ToolingLog +): Promise => { + const agentVersion = closestMatch ? await getLatestAgentDownloadVersion(version, log) : version; + const downloadArch = + { arm64: 'arm64', x64: 'x86_64' }[process.arch] ?? `UNSUPPORTED_ARCHITECTURE_${process.arch}`; + const agentFile = `elastic-agent-${agentVersion}-linux-${downloadArch}.tar.gz`; + const artifactSearchUrl = `https://artifacts-api.elastic.co/v1/search/${agentVersion}/${agentFile}`; + + log?.verbose(`Retrieving elastic agent download URL from:\n ${artifactSearchUrl}`); + + const searchResult: ElasticArtifactSearchResponse = await nodeFetch(artifactSearchUrl).then( + (response) => { + if (!response.ok) { + throw new Error( + `Failed to search elastic's artifact repository: ${response.statusText} (HTTP ${response.status}) {URL: ${artifactSearchUrl})` + ); + } + + return response.json(); + } + ); + + log?.verbose(searchResult); + + if (!searchResult.packages[agentFile]) { + throw new Error(`Unable to find an Agent download URL for version [${agentVersion}]`); + } + + return searchResult.packages[agentFile].url; +}; + +/** + * Given a stack version number, function will return the closest Agent download version available + * for download. THis could be the actual version passed in or lower. + * @param version + */ +export const getLatestAgentDownloadVersion = async ( + version: string, + log?: ToolingLog +): Promise => { + const artifactsUrl = 'https://artifacts-api.elastic.co/v1/versions'; + const semverMatch = `<=${version}`; + const artifactVersionsResponse: { versions: string[] } = await nodeFetch(artifactsUrl).then( + (response) => { + if (!response.ok) { + throw new Error( + `Failed to retrieve list of versions from elastic's artifact repository: ${response.statusText} (HTTP ${response.status}) {URL: ${artifactsUrl})` + ); + } + + return response.json(); + } + ); + + const stackVersionToArtifactVersion: Record = + artifactVersionsResponse.versions.reduce((acc, artifactVersion) => { + const stackVersion = artifactVersion.split('-SNAPSHOT')[0]; + acc[stackVersion] = artifactVersion; + return acc; + }, {} as Record); + + log?.verbose( + `Versions found from [${artifactsUrl}]:\n${JSON.stringify( + stackVersionToArtifactVersion, + null, + 2 + )}` + ); + + const matchedVersion = semver.maxSatisfying( + Object.keys(stackVersionToArtifactVersion), + semverMatch + ); + + if (!matchedVersion) { + throw new Error(`Unable to find a semver version that meets ${semverMatch}`); + } + + return stackVersionToArtifactVersion[matchedVersion]; +}; + +/** + * Un-enrolls a Fleet agent + * + * @param kbnClient + * @param agentId + * @param force + */ +export const unEnrollFleetAgent = async ( + kbnClient: KbnClient, + agentId: string, + force = false +): Promise => { + const { data } = await kbnClient.request({ + method: 'POST', + path: agentRouteService.getUnenrollPath(agentId), + body: { revoke: force }, + }); + + return data; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts index dab9e2b6abd27..f17bf7b514f21 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts @@ -17,12 +17,41 @@ export const createSecuritySuperuser = async ( throw new Error(`username and password require values.`); } + // Create a role which has full access to restricted indexes + await esClient.transport.request({ + method: 'POST', + path: '_security/role/superuser_restricted_indices', + body: { + cluster: ['all'], + indices: [ + { + names: ['*'], + privileges: ['all'], + allow_restricted_indices: true, + }, + { + names: ['*'], + privileges: ['monitor', 'read', 'view_index_metadata', 'read_cross_cluster'], + allow_restricted_indices: true, + }, + ], + applications: [ + { + application: '*', + privileges: ['*'], + resources: ['*'], + }, + ], + run_as: ['*'], + }, + }); + const addedUser = await esClient.transport.request>({ method: 'POST', path: `_security/user/${username}`, body: { password, - roles: ['superuser', 'kibana_system'], + roles: ['superuser', 'kibana_system', 'superuser_restricted_indices'], full_name: username, }, }); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts index 424f451c3fdc6..f7ba4c1a5b514 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts @@ -99,7 +99,11 @@ export const createRuntimeServices = async ({ }; }; -const buildUrlWithCredentials = (url: string, username: string, password: string): string => { +export const buildUrlWithCredentials = ( + url: string, + username: string, + password: string +): string => { const newUrl = new URL(url); newUrl.username = username; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts deleted file mode 100644 index 277772253b92e..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts +++ /dev/null @@ -1,144 +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 { - loggingSystemMock, - savedObjectsClientMock, - elasticsearchServiceMock, -} from '@kbn/core/server/mocks'; -import type { - SavedObjectsClient, - Logger, - SavedObjectsFindResponse, - SavedObjectsFindResult, -} from '@kbn/core/server'; -import { migrateArtifactsToFleet } from './migrate_artifacts_to_fleet'; -import { createEndpointArtifactClientMock } from '../../services/artifacts/mocks'; -import type { InternalArtifactCompleteSchema } from '../../schemas'; -import { generateArtifactEsGetSingleHitMock } from '@kbn/fleet-plugin/server/services/artifacts/mocks'; -import type { NewArtifact } from '@kbn/fleet-plugin/server/services'; -import type { CreateRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -describe('When migrating artifacts to fleet', () => { - let soClient: jest.Mocked; - let logger: jest.Mocked; - let artifactClient: ReturnType; - /** An artifact that was created prior to 7.14 */ - let soArtifactEntry: InternalArtifactCompleteSchema; - - const createSoFindResult = ( - soHits: SavedObjectsFindResult[] = [], - total: number = 15, - page: number = 1 - ): SavedObjectsFindResponse => { - return { - total, - page, - per_page: 10, - saved_objects: soHits, - }; - }; - - beforeEach(async () => { - soClient = savedObjectsClientMock.create() as unknown as jest.Mocked; - logger = loggingSystemMock.create().get() as jest.Mocked; - artifactClient = createEndpointArtifactClientMock(); - // pre-v7.14 artifact, which is compressed - soArtifactEntry = { - identifier: 'endpoint-exceptionlist-macos-v1', - compressionAlgorithm: 'zlib', - encryptionAlgorithm: 'none', - decodedSha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encodedSha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decodedSize: 14, - encodedSize: 22, - body: 'eJyrVkrNKynKTC1WsoqOrQUAJxkFKQ==', - }; - - // Mock the esClient create response to include the artifact properties that were provide - // to it by fleet artifact client - artifactClient._esClient.create.mockImplementation((props: CreateRequest) => { - return elasticsearchServiceMock.createSuccessTransportRequestPromise({ - ...generateArtifactEsGetSingleHitMock({ - ...((props?.body ?? {}) as NewArtifact), - }), - _index: '.fleet-artifacts-7', - _id: `endpoint:endpoint-exceptionlist-macos-v1-${ - // @ts-expect-error TS2339 - props?.body?.decodedSha256 ?? 'UNKNOWN?' - }`, - _version: 1, - result: 'created', - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - _seq_no: 0, - _primary_term: 1, - }); - }); - - soClient.find.mockResolvedValue(createSoFindResult([], 0)).mockResolvedValueOnce( - createSoFindResult([ - { - score: 1, - type: '', - id: 'abc123', - references: [], - attributes: soArtifactEntry, - }, - ]) - ); - }); - - it('should do nothing if there are no artifacts', async () => { - soClient.find.mockReset(); - soClient.find.mockResolvedValue(createSoFindResult([], 0)); - await migrateArtifactsToFleet(soClient, artifactClient, logger); - expect(soClient.find).toHaveBeenCalled(); - expect(artifactClient.createArtifact).not.toHaveBeenCalled(); - expect(soClient.delete).not.toHaveBeenCalled(); - }); - - it('should create new artifact via fleet client and delete prior SO one', async () => { - await migrateArtifactsToFleet(soClient, artifactClient, logger); - expect(artifactClient.createArtifact).toHaveBeenCalled(); - expect(soClient.delete).toHaveBeenCalled(); - }); - - it('should create artifact in fleet with attributes that match the SO version', async () => { - await migrateArtifactsToFleet(soClient, artifactClient, logger); - - await expect(artifactClient.createArtifact.mock.results[0].value).resolves.toEqual( - expect.objectContaining({ - ...soArtifactEntry, - compressionAlgorithm: 'zlib', - }) - ); - }); - - it('should ignore 404 responses for SO delete (multi-node kibana setup)', async () => { - const notFoundError: Error & { output?: { statusCode: number } } = new Error('not found'); - notFoundError.output = { statusCode: 404 }; - soClient.delete.mockRejectedValue(notFoundError); - await expect(migrateArtifactsToFleet(soClient, artifactClient, logger)).resolves.toEqual( - undefined - ); - expect(logger.debug).toHaveBeenCalledWith( - 'Artifact Migration: Attempt to delete Artifact SO [abc123] returned 404' - ); - }); - - it('should Throw() and log error if migration fails', async () => { - const error = new Error('test: delete failed'); - soClient.delete.mockRejectedValue(error); - await expect(migrateArtifactsToFleet(soClient, artifactClient, logger)).rejects.toThrow( - 'Artifact SO migration failed' - ); - }); -}); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts deleted file mode 100644 index e015019fa8a5d..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { inflate as _inflate } from 'zlib'; -import { promisify } from 'util'; -import type { SavedObjectsClient, Logger } from '@kbn/core/server'; -import type { EndpointArtifactClientInterface } from '../../services'; -import type { InternalArtifactCompleteSchema, InternalArtifactSchema } from '../../schemas'; -import { ArtifactConstants } from './common'; - -class ArtifactMigrationError extends Error { - constructor(message: string, public readonly meta?: unknown) { - super(message); - } -} - -const inflateAsync = promisify(_inflate); - -function isCompressed(artifact: InternalArtifactSchema) { - return artifact.compressionAlgorithm === 'zlib'; -} - -/** - * With v7.13, artifact storage was moved from a security_solution saved object to a fleet index - * in order to support Fleet Server. - */ -export const migrateArtifactsToFleet = async ( - soClient: SavedObjectsClient, - endpointArtifactClient: EndpointArtifactClientInterface, - logger: Logger -): Promise => { - let totalArtifactsMigrated = -1; - let hasMore = true; - - try { - while (hasMore) { - // Retrieve list of artifact records - const { saved_objects: artifactList, total } = - await soClient.find({ - type: ArtifactConstants.SAVED_OBJECT_TYPE, - page: 1, - perPage: 10, - }); - - if (totalArtifactsMigrated === -1) { - totalArtifactsMigrated = total; - if (total > 0) { - logger.info(`Migrating artifacts from SavedObject`); - } - } - - // If nothing else to process, then exit out - if (total === 0) { - hasMore = false; - if (totalArtifactsMigrated > 0) { - logger.info(`Total Artifacts migrated: ${totalArtifactsMigrated}`); - } - return; - } - - for (const artifact of artifactList) { - if (isCompressed(artifact.attributes)) { - artifact.attributes = { - ...artifact.attributes, - body: (await inflateAsync(Buffer.from(artifact.attributes.body, 'base64'))).toString( - 'base64' - ), - }; - } - - // Create new artifact in fleet index - await endpointArtifactClient.createArtifact(artifact.attributes); - // Delete old artifact from SO and if there are errors here, then ignore 404's - // since multiple kibana instances could be going at this - try { - await soClient.delete(ArtifactConstants.SAVED_OBJECT_TYPE, artifact.id); - } catch (e) { - if (e?.output?.statusCode !== 404) { - throw e; - } - logger.debug( - `Artifact Migration: Attempt to delete Artifact SO [${artifact.id}] returned 404` - ); - } - } - } - } catch (e) { - const error = new ArtifactMigrationError('Artifact SO migration failed', e); - logger.error(error); - throw error; - } -}; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index 826002f5970cd..dba96c2c084de 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -14,81 +14,18 @@ import { migrations } from './migrations'; export const exceptionsArtifactSavedObjectType = ArtifactConstants.SAVED_OBJECT_TYPE; export const manifestSavedObjectType = ManifestConstants.SAVED_OBJECT_TYPE; -export const exceptionsArtifactSavedObjectMappings: SavedObjectsType['mappings'] = { - properties: { - identifier: { - type: 'keyword', - }, - compressionAlgorithm: { - type: 'keyword', - index: false, - }, - encryptionAlgorithm: { - type: 'keyword', - index: false, - }, - encodedSha256: { - type: 'keyword', - }, - encodedSize: { - type: 'long', - index: false, - }, - decodedSha256: { - type: 'keyword', - index: false, - }, - decodedSize: { - type: 'long', - index: false, - }, - created: { - type: 'date', - index: false, - }, - body: { - type: 'binary', - }, - }, -}; - export const manifestSavedObjectMappings: SavedObjectsType['mappings'] = { + dynamic: false, properties: { - created: { - type: 'date', - index: false, - }, schemaVersion: { type: 'keyword', }, - semanticVersion: { - type: 'keyword', - index: false, - }, artifacts: { type: 'nested', - properties: { - policyId: { - type: 'keyword', - index: false, - }, - artifactId: { - type: 'keyword', - index: false, - }, - }, }, }, }; -export const exceptionsArtifactType: SavedObjectsType = { - name: exceptionsArtifactSavedObjectType, - indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, - hidden: false, - namespaceType: 'agnostic', - mappings: exceptionsArtifactSavedObjectMappings, -}; - export const manifestType: SavedObjectsType = { name: manifestSavedObjectType, indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts index 9593dd5624c22..f852bfff48873 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts @@ -50,6 +50,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, @@ -318,6 +319,7 @@ export const sampleAlertDocAADNoSortId = ( }, ], }, + [ALERT_URL]: 'http://example.com/docID', }, fields: { someKey: ['someValue'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts index 15f82e84155cb..399a80f4b9101 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts @@ -13,6 +13,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_WORKFLOW_STATUS, ALERT_RULE_NAMESPACE, + ALERT_URL, ALERT_UUID, ALERT_RULE_TYPE_ID, ALERT_RULE_PRODUCER, @@ -125,6 +126,7 @@ export const sampleThresholdAlert = { interval: '5m', exceptions_list: getListArrayMock(), }) as TypeOfFieldMap), + [ALERT_URL]: 'http://example.com/docID', 'kibana.alert.depth': 1, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index e2650f0a142f2..990523d6ad5f9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -65,7 +65,16 @@ export const securityRuleTypeFieldMap = { /* eslint-disable complexity */ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = - ({ lists, logger, config, ruleDataClient, ruleExecutionLoggerFactory, version, isPreview }) => + ({ + lists, + logger, + config, + publicBaseUrl, + ruleDataClient, + ruleExecutionLoggerFactory, + version, + isPreview, + }) => (type) => { const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; const persistenceRuleType = createPersistenceRuleTypeWrapper({ ruleDataClient, logger }); @@ -319,6 +328,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = spaceId, indicesToQuery: inputIndex, alertTimestampOverride, + publicBaseUrl, ruleExecutionLogger, }); @@ -328,6 +338,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = mergeStrategy, completeRule, spaceId, + publicBaseUrl, indicesToQuery: inputIndex, alertTimestampOverride, }); @@ -371,6 +382,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = alertTimestampOverride, alertWithSuppression, refreshOnIndexingAlerts: refresh, + publicBaseUrl, }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts index 98c5637b59d7a..b3cf8f3ed1675 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; +import { ALERT_RULE_CONSUMER, ALERT_URL } from '@kbn/rule-data-utils'; import { sampleDocNoSortId, sampleRuleGuid } from '../__mocks__/es_results'; import { @@ -25,6 +25,7 @@ import { } from '../../../../../common/field_maps/field_names'; const SPACE_ID = 'space'; +const PUBLIC_BASE_URL = 'http://testkibanabaseurl.com'; const ruleExecutionLoggerMock = ruleExecutionLogMock.forExecutors.create(); @@ -54,7 +55,8 @@ describe('buildAlert', () => { SPACE_ID, jest.fn(), completeRule.ruleParams.index as string[], - undefined + undefined, + PUBLIC_BASE_URL ); expect(alertGroup.length).toEqual(3); expect(alertGroup[0]).toEqual( @@ -74,6 +76,9 @@ describe('buildAlert', () => { }), }) ); + expect(alertGroup[0]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/f2db3574eaf8450e3f4d1cf4f416d70b110b035ae0a7a00026242df07f0a6c90?index=.alerts-security.alerts-space' + ); expect(alertGroup[1]).toEqual( expect.objectContaining({ _source: expect.objectContaining({ @@ -91,6 +96,9 @@ describe('buildAlert', () => { }), }) ); + expect(alertGroup[1]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/1dbc416333244efbda833832eb83f13ea5d980a33c2f981ca8d2b35d82a045da?index=.alerts-security.alerts-space' + ); expect(alertGroup[2]).toEqual( expect.objectContaining({ _source: expect.objectContaining({ @@ -128,7 +136,9 @@ describe('buildAlert', () => { }), }) ); - + expect(alertGroup[2]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/1b7d06954e74257140f3bf73f139078483f9658fe829fd806cc307fc0388fb23?index=.alerts-security.alerts-space' + ); const groupIds = alertGroup.map((alert) => alert._source[ALERT_GROUP_ID]); for (const groupId of groupIds) { expect(groupId).toEqual(groupIds[0]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts index f03d185c19996..92c8e4d749a7d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; +import { getAlertDetailsUrl } from '../../../../../common/utils/alert_detail_path'; +import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants'; import type { ConfigType } from '../../../../config'; import type { Ancestor, SignalSource, SignalSourceHit } from '../types'; import { buildAlert, buildAncestors, generateAlertId } from '../factories/utils/build_alert'; @@ -43,7 +45,8 @@ export const buildAlertGroupFromSequence = ( spaceId: string | null | undefined, buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], - alertTimestampOverride: Date | undefined + alertTimestampOverride: Date | undefined, + publicBaseUrl?: string ): Array> => { const ancestors: Ancestor[] = sequence.events.flatMap((event) => buildAncestors(event)); if (ancestors.some((ancestor) => ancestor?.rule === completeRule.alertId)) { @@ -65,7 +68,9 @@ export const buildAlertGroupFromSequence = ( buildReasonMessage, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + 'placeholder-alert-uuid', // This is overriden below + publicBaseUrl ) ); } catch (error) { @@ -96,7 +101,8 @@ export const buildAlertGroupFromSequence = ( spaceId, buildReasonMessage, indicesToQuery, - alertTimestampOverride + alertTimestampOverride, + publicBaseUrl ); const sequenceAlert: WrappedFieldsLatest = { _id: shellAlert[ALERT_UUID], @@ -106,15 +112,26 @@ export const buildAlertGroupFromSequence = ( // Finally, we have the group id from the shell alert so we can convert the BaseFields into EqlBuildingBlocks const wrappedBuildingBlocks = wrappedBaseFields.map( - (block, i): WrappedFieldsLatest => ({ - ...block, - _source: { - ...block._source, - [ALERT_BUILDING_BLOCK_TYPE]: 'default', - [ALERT_GROUP_ID]: shellAlert[ALERT_GROUP_ID], - [ALERT_GROUP_INDEX]: i, - }, - }) + (block, i): WrappedFieldsLatest => { + const alertUrl = getAlertDetailsUrl({ + alertId: block._id, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp: block._source['@timestamp'], + basePath: publicBaseUrl, + spaceId, + }); + + return { + ...block, + _source: { + ...block._source, + [ALERT_BUILDING_BLOCK_TYPE]: 'default', + [ALERT_GROUP_ID]: shellAlert[ALERT_GROUP_ID], + [ALERT_GROUP_INDEX]: i, + [ALERT_URL]: alertUrl, + }, + }; + } ); return [...wrappedBuildingBlocks, sequenceAlert]; @@ -126,7 +143,8 @@ export const buildAlertRoot = ( spaceId: string | null | undefined, buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], - alertTimestampOverride: Date | undefined + alertTimestampOverride: Date | undefined, + publicBaseUrl?: string ): EqlShellFieldsLatest => { const mergedAlerts = objectArrayIntersection(wrappedBuildingBlocks.map((alert) => alert._source)); const reason = buildReasonMessage({ @@ -140,14 +158,25 @@ export const buildAlertRoot = ( spaceId, reason, indicesToQuery, + 'placeholder-uuid', // These will be overriden below + publicBaseUrl, // Not necessary now, but when the ID is created ahead of time this can be passed alertTimestampOverride ); const alertId = generateAlertId(doc); + const alertUrl = getAlertDetailsUrl({ + alertId, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp: doc['@timestamp'], + basePath: publicBaseUrl, + spaceId, + }); + return { ...mergedAlerts, ...doc, [ALERT_UUID]: alertId, [ALERT_GROUP_ID]: alertId, + [ALERT_URL]: alertUrl, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts index 650cfb21a71c9..6c608da5cb5cb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts @@ -21,6 +21,7 @@ export const wrapSequencesFactory = completeRule, ignoreFields, mergeStrategy, + publicBaseUrl, spaceId, indicesToQuery, alertTimestampOverride, @@ -32,6 +33,7 @@ export const wrapSequencesFactory = spaceId: string | null | undefined; indicesToQuery: string[]; alertTimestampOverride: Date | undefined; + publicBaseUrl: string | undefined; }): WrapSequences => (sequences, buildReasonMessage) => sequences.reduce( @@ -45,7 +47,8 @@ export const wrapSequencesFactory = spaceId, buildReasonMessage, indicesToQuery, - alertTimestampOverride + alertTimestampOverride, + publicBaseUrl ), ], [] diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 2b6702f591ab4..8525c63ce8c87 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -17,6 +17,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_ACTION, @@ -31,7 +32,7 @@ import { sampleDocNoSortIdWithTimestamp } from '../../__mocks__/es_results'; import { buildAlert, buildParent, buildAncestors, additionalAlertFields } from './build_alert'; import type { Ancestor, SignalSourceHit } from '../../types'; import { getListArrayMock } from '../../../../../../common/detection_engine/schemas/types/lists.mock'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; +import { DEFAULT_ALERTS_INDEX, SERVER_APP_ID } from '../../../../../../common/constants'; import { EVENT_DATASET } from '../../../../../../common/cti/constants'; import { ALERT_ANCESTORS, @@ -48,6 +49,9 @@ type SignalDoc = SignalSourceHit & { }; const SPACE_ID = 'space'; +const reason = 'alert reasonable reason'; +const publicBaseUrl = 'testKibanaBasePath.com'; +const alertUuid = 'test-uuid'; describe('buildAlert', () => { beforeEach(() => { @@ -58,7 +62,6 @@ describe('buildAlert', () => { const doc = sampleDocNoSortIdWithTimestamp('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const completeRule = getCompleteRuleMock(getQueryRuleParams()); - const reason = 'alert reasonable reason'; const alert = { ...buildAlert( [doc], @@ -66,11 +69,14 @@ describe('buildAlert', () => { SPACE_ID, reason, completeRule.ruleParams.index as string[], + alertUuid, + publicBaseUrl, undefined ), ...additionalAlertFields(doc), }; const timestamp = alert[TIMESTAMP]; + const expectedAlertUrl = `${publicBaseUrl}/s/${SPACE_ID}/app/security/alerts/redirect/${alertUuid}?index=${DEFAULT_ALERTS_INDEX}-${SPACE_ID}×tamp=${timestamp}`; const expected = { [TIMESTAMP]: timestamp, [EVENT_KIND]: 'signal', @@ -222,6 +228,8 @@ describe('buildAlert', () => { timeline_title: 'some-timeline-title', }), [ALERT_DEPTH]: 1, + [ALERT_URL]: expectedAlertUrl, + [ALERT_UUID]: alertUuid, }; expect(alert).toEqual(expected); }); @@ -239,7 +247,6 @@ describe('buildAlert', () => { }, }; const completeRule = getCompleteRuleMock(getQueryRuleParams()); - const reason = 'alert reasonable reason'; const alert = { ...buildAlert( [doc], @@ -247,12 +254,14 @@ describe('buildAlert', () => { SPACE_ID, reason, completeRule.ruleParams.index as string[], + alertUuid, + publicBaseUrl, undefined ), ...additionalAlertFields(doc), }; const timestamp = alert[TIMESTAMP]; - + const expectedAlertUrl = `${publicBaseUrl}/s/${SPACE_ID}/app/security/alerts/redirect/${alertUuid}?index=${DEFAULT_ALERTS_INDEX}-${SPACE_ID}×tamp=${timestamp}`; const expected = { [TIMESTAMP]: timestamp, [EVENT_KIND]: 'signal', @@ -410,6 +419,8 @@ describe('buildAlert', () => { timeline_title: 'some-timeline-title', }), [ALERT_DEPTH]: 1, + [ALERT_URL]: expectedAlertUrl, + [ALERT_UUID]: alertUuid, }; expect(alert).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index d206fa06704f0..5d53380a736f5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -34,6 +34,8 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, + ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, SPACE_IDS, @@ -43,6 +45,7 @@ import { flattenWithPrefix } from '@kbn/securitysolution-rules'; import { createHash } from 'crypto'; +import { getAlertDetailsUrl } from '../../../../../../common/utils/alert_detail_path'; import type { BaseSignalHit, SimpleHit } from '../../types'; import type { ThresholdResult } from '../../threshold/types'; import { @@ -51,7 +54,7 @@ import { isWrappedDetectionAlert, isWrappedSignalHit, } from '../../utils/utils'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; +import { DEFAULT_ALERTS_INDEX, SERVER_APP_ID } from '../../../../../../common/constants'; import type { SearchTypes } from '../../../../telemetry/types'; import { ALERT_ANCESTORS, @@ -137,6 +140,8 @@ export const buildAlert = ( spaceId: string | null | undefined, reason: string, indicesToQuery: string[], + alertUuid: string, + publicBaseUrl: string | undefined, alertTimestampOverride: Date | undefined, overrides?: { nameOverride: string; @@ -180,8 +185,18 @@ export const buildAlert = ( primaryTimestamp: TIMESTAMP, }); + const timestamp = alertTimestampOverride?.toISOString() ?? new Date().toISOString(); + + const alertUrl = getAlertDetailsUrl({ + alertId: alertUuid, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp, + basePath: publicBaseUrl, + spaceId, + }); + return { - [TIMESTAMP]: alertTimestampOverride?.toISOString() ?? new Date().toISOString(), + [TIMESTAMP]: timestamp, [SPACE_IDS]: spaceId != null ? [spaceId] : [], [EVENT_KIND]: 'signal', [ALERT_ORIGINAL_TIME]: originalTime?.toISOString(), @@ -229,6 +244,8 @@ export const buildAlert = ( [ALERT_RULE_UPDATED_BY]: updatedBy ?? '', [ALERT_RULE_UUID]: completeRule.alertId, [ALERT_RULE_VERSION]: params.version, + [ALERT_URL]: alertUrl, + [ALERT_UUID]: alertUuid, ...flattenWithPrefix(ALERT_RULE_META, params.meta), // These fields don't exist in the mappings, but leaving here for now to limit changes to the alert building logic 'kibana.alert.rule.risk_score': params.riskScore, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts index cd7351362db0b..1963837d64bc7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts @@ -55,7 +55,9 @@ export const buildBulkBody = ( buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], alertTimestampOverride: Date | undefined, - ruleExecutionLogger: IRuleExecutionLogForExecutors + ruleExecutionLogger: IRuleExecutionLogForExecutors, + alertUuid: string, + publicBaseUrl?: string ): BaseFieldsLatest => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc, ignoreFields }); @@ -111,6 +113,8 @@ export const buildBulkBody = ( spaceId, reason, indicesToQuery, + alertUuid, + publicBaseUrl, alertTimestampOverride, overrides ), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts index aae7501bf3798..a5b56303c603d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts @@ -6,8 +6,6 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ALERT_UUID } from '@kbn/rule-data-utils'; - import type { ConfigType } from '../../../../config'; import type { SignalSource, SimpleHit } from '../types'; import type { CompleteRule, RuleParams } from '../../rule_schema'; @@ -28,6 +26,7 @@ export const wrapHitsFactory = spaceId, indicesToQuery, alertTimestampOverride, + publicBaseUrl, ruleExecutionLogger, }: { completeRule: CompleteRule; @@ -36,6 +35,7 @@ export const wrapHitsFactory = spaceId: string | null | undefined; indicesToQuery: string[]; alertTimestampOverride: Date | undefined; + publicBaseUrl: string | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; }) => ( @@ -49,23 +49,27 @@ export const wrapHitsFactory = String(event._version), `${spaceId}:${completeRule.alertId}` ); + + const baseAlert = buildBulkBody( + spaceId, + completeRule, + event as SimpleHit, + mergeStrategy, + ignoreFields, + true, + buildReasonMessage, + indicesToQuery, + alertTimestampOverride, + ruleExecutionLogger, + id, + publicBaseUrl + ); + return { _id: id, _index: '', _source: { - ...buildBulkBody( - spaceId, - completeRule, - event as SimpleHit, - mergeStrategy, - ignoreFields, - true, - buildReasonMessage, - indicesToQuery, - alertTimestampOverride, - ruleExecutionLogger - ), - [ALERT_UUID]: id, + ...baseAlert, }, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index c4525de1b00b3..41c5420c748e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -107,6 +107,7 @@ export const createNewTermsAlertType = ( exceptionFilter, unprocessedExceptions, alertTimestampOverride, + publicBaseUrl, }, services, params, @@ -300,6 +301,7 @@ export const createNewTermsAlertType = ( indicesToQuery: inputIndex, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }); const bulkCreateResult = await bulkCreate( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts index 69d0b90b45c29..67a3c69af9850 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; import { ALERT_NEW_TERMS } from '../../../../../common/field_maps/field_names'; import { getCompleteRuleMock, getNewTermsRuleParams } from '../../rule_schema/mocks'; import { ruleExecutionLogMock } from '../../rule_monitoring/mocks'; @@ -15,6 +15,7 @@ import { wrapNewTermsAlerts } from './wrap_new_terms_alerts'; const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create(); const docId = 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71'; +const publicBaseUrl = 'http://somekibanabaseurl.com'; describe('wrapNewTermsAlerts', () => { test('should create an alert with the correct _id from a document', () => { const doc = sampleDocNoSortIdWithTimestamp(docId); @@ -27,11 +28,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('a36d9fe6fe4b2f65058fb1a487733275f811af58'); expect(alerts[0]._source[ALERT_UUID]).toEqual('a36d9fe6fe4b2f65058fb1a487733275f811af58'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/app/security/alerts/redirect/a36d9fe6fe4b2f65058fb1a487733275f811af58?index=.alerts-security.alerts-default' + ); }); test('should create an alert with a different _id if the space is different', () => { @@ -45,11 +50,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('f7877a31b1cc83373dbc9ba5939ebfab1db66545'); expect(alerts[0]._source[ALERT_UUID]).toEqual('f7877a31b1cc83373dbc9ba5939ebfab1db66545'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/f7877a31b1cc83373dbc9ba5939ebfab1db66545?index=.alerts-security.alerts-otherSpace' + ); }); test('should create an alert with a different _id if the newTerms array is different', () => { @@ -63,11 +72,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea'); expect(alerts[0]._source[ALERT_UUID]).toEqual('75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.2']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea?index=.alerts-security.alerts-otherSpace' + ); }); test('should create an alert with a different _id if the newTerms array contains multiple terms', () => { @@ -81,10 +94,14 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('86a216cfa4884767d9bb26d2b8db911cb4aa85ce'); expect(alerts[0]._source[ALERT_UUID]).toEqual('86a216cfa4884767d9bb26d2b8db911cb4aa85ce'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1', '127.0.0.2']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/86a216cfa4884767d9bb26d2b8db911cb4aa85ce?index=.alerts-security.alerts-otherSpace' + ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts index 424c52273c30a..2a373edf7de6e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts @@ -7,7 +7,6 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import objectHash from 'object-hash'; -import { ALERT_UUID } from '@kbn/rule-data-utils'; import type { BaseFieldsLatest, NewTermsFieldsLatest, @@ -34,6 +33,7 @@ export const wrapNewTermsAlerts = ({ indicesToQuery, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }: { eventsAndTerms: EventsAndTerms[]; spaceId: string | null | undefined; @@ -42,6 +42,7 @@ export const wrapNewTermsAlerts = ({ indicesToQuery: string[]; alertTimestampOverride: Date | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; + publicBaseUrl: string | undefined; }): Array> => { return eventsAndTerms.map((eventAndTerms) => { const id = objectHash([ @@ -61,15 +62,17 @@ export const wrapNewTermsAlerts = ({ buildReasonMessageForNewTermsAlert, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + id, + publicBaseUrl ); + return { _id: id, _index: '', _source: { ...baseAlert, [ALERT_NEW_TERMS]: eventAndTerms.newTerms, - [ALERT_UUID]: id, }, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts index 99f1901aad9f8..6338917eecbb9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts @@ -224,6 +224,7 @@ export const groupAndBulkCreate = async ({ completeRule: runOpts.completeRule, mergeStrategy: runOpts.mergeStrategy, indicesToQuery: runOpts.inputIndex, + publicBaseUrl: runOpts.publicBaseUrl, buildReasonMessage, alertTimestampOverride: runOpts.alertTimestampOverride, ruleExecutionLogger: runOpts.ruleExecutionLogger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts index 42fe0954a1847..c793d24f9fa6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts @@ -8,7 +8,6 @@ import objectHash from 'object-hash'; import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import { - ALERT_UUID, ALERT_SUPPRESSION_TERMS, ALERT_SUPPRESSION_DOCS_COUNT, ALERT_SUPPRESSION_END, @@ -56,6 +55,7 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }: { suppressionBuckets: SuppressionBuckets[]; spaceId: string; @@ -65,6 +65,7 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage: BuildReasonMessage; alertTimestampOverride: Date | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; + publicBaseUrl: string | undefined; }): Array> => { return suppressionBuckets.map((bucket) => { const id = objectHash([ @@ -91,8 +92,11 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + id, + publicBaseUrl ); + return { _id: id, _index: '', @@ -102,7 +106,6 @@ export const wrapSuppressedAlerts = ({ [ALERT_SUPPRESSION_START]: bucket.start, [ALERT_SUPPRESSION_END]: bucket.end, [ALERT_SUPPRESSION_DOCS_COUNT]: bucket.count - 1, - [ALERT_UUID]: id, [ALERT_INSTANCE_ID]: instanceId, }, }; 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 d3f8e8b42b44e..13c7e9e53df54 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 @@ -34,6 +34,7 @@ jest.mock('../utils/get_list_client', () => ({ describe('Custom Query Alerts', () => { const mocks = createRuleTypeMocks(); const licensing = licensingMock.createSetup(); + const publicBaseUrl = 'http://somekibanabaseurl.com'; const { dependencies, executor, services } = mocks; const { alerting, lists, logger, ruleDataClient } = dependencies; @@ -44,6 +45,7 @@ describe('Custom Query Alerts', () => { ruleDataClient, ruleExecutionLoggerFactory: () => Promise.resolve(ruleExecutionLogMock.forExecutors.create()), version: '8.3', + publicBaseUrl, }); const eventsTelemetry = createMockTelemetryEventsSender(true); 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 0dee5eba79cc4..c58613344f724 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 @@ -100,6 +100,7 @@ export interface RunOpts { alertTimestampOverride: Date | undefined; alertWithSuppression: SuppressedAlertService; refreshOnIndexingAlerts: RefreshTypes; + publicBaseUrl: string | undefined; } export type SecurityAlertType< @@ -129,6 +130,7 @@ export interface CreateSecurityRuleTypeWrapperProps { lists: SetupPlugins['lists']; logger: Logger; config: ConfigType; + publicBaseUrl: string | undefined; ruleDataClient: IRuleDataClient; ruleExecutionLoggerFactory: IRuleExecutionLogService['createClientForExecutors']; version: string; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts index 43cd37aca396a..3d3dc8872f261 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts @@ -38,6 +38,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, @@ -158,6 +159,7 @@ export const createAlert = ( [ALERT_RULE_UUID]: '2e051244-b3c6-4779-a241-e1b4f0beceb9', [ALERT_RULE_VERSION]: 1, [ALERT_UUID]: someUuid, + [ALERT_URL]: `http://kibanaurl.com/app/security/alerts/redirect/${someUuid}?index=myFakeSignalIndex×tamp=2020-04-20T21:27:45`, 'kibana.alert.rule.risk_score': 50, 'kibana.alert.rule.severity': 'high', 'kibana.alert.rule.building_block_type': undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts index 8e71a4dce49aa..31c1e38b08f91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts @@ -112,6 +112,7 @@ describe('searchAfterAndBulkCreate', () => { indicesToQuery: inputIndexPattern, alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl: 'http://testkibanabaseurl.com', }); }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index e74138f61e195..164969377c6d7 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -69,7 +69,6 @@ import type { ITelemetryReceiver } from './lib/telemetry/receiver'; import { TelemetryReceiver } from './lib/telemetry/receiver'; import { licenseService } from './lib/license'; import { PolicyWatcher } from './endpoint/lib/policy/license_watch'; -import { migrateArtifactsToFleet } from './endpoint/lib/artifacts/migrate_artifacts_to_fleet'; import previewPolicy from './lib/detection_engine/routes/index/preview_policy.json'; import { createRuleExecutionLogService } from './lib/detection_engine/rule_monitoring'; import { getKibanaPrivilegesFeaturePrivileges, getCasesKibanaFeature } from './features'; @@ -239,6 +238,7 @@ export class Plugin implements ISecuritySolutionPlugin { lists: plugins.lists, logger: this.logger, config: this.config, + publicBaseUrl: core.http.basePath.publicBaseUrl, ruleDataClient, ruleExecutionLoggerFactory: ruleExecutionLogService.createClientForExecutors, version: pluginContext.env.packageInfo.version, @@ -450,17 +450,15 @@ export class Plugin implements ISecuritySolutionPlugin { // Migrate artifacts to fleet and then start the minifest task after that is done plugins.fleet.fleetSetupCompleted().then(() => { - migrateArtifactsToFleet(savedObjectsClient, artifactClient, logger).finally(() => { - logger.info('Dependent plugin setup complete - Starting ManifestTask'); - - if (this.manifestTask) { - this.manifestTask.start({ - taskManager, - }); - } else { - logger.error(new Error('User artifacts task not available.')); - } - }); + logger.info('Dependent plugin setup complete - Starting ManifestTask'); + + if (this.manifestTask) { + this.manifestTask.start({ + taskManager, + }); + } else { + logger.error(new Error('User artifacts task not available.')); + } }); // License related start diff --git a/x-pack/plugins/security_solution/server/saved_objects.ts b/x-pack/plugins/security_solution/server/saved_objects.ts index 394cab7f52455..bd6c21a4d489a 100644 --- a/x-pack/plugins/security_solution/server/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/saved_objects.ts @@ -12,10 +12,7 @@ import { noteType, pinnedEventType, timelineType } from './lib/timeline/saved_ob import { legacyType as legacyRuleActionsType } from './lib/detection_engine/rule_actions_legacy'; import { prebuiltRuleAssetType } from './lib/detection_engine/prebuilt_rules'; import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects'; -import { - exceptionsArtifactType, - manifestType, -} from './endpoint/lib/artifacts/saved_object_mappings'; +import { manifestType } from './endpoint/lib/artifacts/saved_object_mappings'; const types = [ noteType, @@ -23,7 +20,6 @@ const types = [ legacyRuleActionsType, prebuiltRuleAssetType, timelineType, - exceptionsArtifactType, manifestType, signalsMigrationType, ]; diff --git a/x-pack/plugins/stack_connectors/common/slack_api/constants.ts b/x-pack/plugins/stack_connectors/common/slack_api/constants.ts new file mode 100644 index 0000000000000..3c107e1c05342 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SLACK_API_CONNECTOR_ID = '.slack_api'; +export const SLACK_URL = 'https://slack.com/api/'; diff --git a/x-pack/plugins/stack_connectors/common/slack_api/lib.ts b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts new file mode 100644 index 0000000000000..449b1aef56b14 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { i18n } from '@kbn/i18n'; + +export function successResult( + actionId: string, + data: unknown +): ConnectorTypeExecutorResult { + return { status: 'ok', data, actionId }; +} + +export function errorResult(actionId: string, message: string): ConnectorTypeExecutorResult { + return { + status: 'error', + message, + actionId, + }; +} +export function serviceErrorResult( + actionId: string, + serviceMessage?: string +): ConnectorTypeExecutorResult { + const errMessage = i18n.translate('xpack.stackConnectors.slack.errorPostingErrorMessage', { + defaultMessage: 'error posting slack message', + }); + return { + status: 'error', + message: errMessage, + actionId, + serviceMessage, + }; +} + +export function retryResult(actionId: string, message: string): ConnectorTypeExecutorResult { + const errMessage = i18n.translate( + 'xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage', + { + defaultMessage: 'error posting a slack message, retry later', + } + ); + return { + status: 'error', + message: errMessage, + retry: true, + actionId, + }; +} + +export function retryResultSeconds( + actionId: string, + message: string, + retryAfter: number +): ConnectorTypeExecutorResult { + const retryEpoch = Date.now() + retryAfter * 1000; + const retry = new Date(retryEpoch); + const retryString = retry.toISOString(); + const errMessage = i18n.translate( + 'xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage', + { + defaultMessage: 'error posting a slack message, retry at {retryString}', + values: { + retryString, + }, + } + ); + return { + status: 'error', + message: errMessage, + retry, + actionId, + serviceMessage: message, + }; +} diff --git a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts new file mode 100644 index 0000000000000..a1060f3290b28 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const SlackApiSecretsSchema = schema.object({ + token: schema.string({ minLength: 1 }), +}); + +export const GetChannelsParamsSchema = schema.object({ + subAction: schema.literal('getChannels'), +}); + +export const PostMessageSubActionParamsSchema = schema.object({ + channels: schema.arrayOf(schema.string()), + text: schema.string(), +}); +export const PostMessageParamsSchema = schema.object({ + subAction: schema.literal('postMessage'), + subActionParams: PostMessageSubActionParamsSchema, +}); + +export const SlackApiParamsSchema = schema.oneOf([ + GetChannelsParamsSchema, + PostMessageParamsSchema, +]); diff --git a/x-pack/plugins/stack_connectors/common/slack_api/types.ts b/x-pack/plugins/stack_connectors/common/slack_api/types.ts new file mode 100644 index 0000000000000..1098d40eded19 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionType as ConnectorType } from '@kbn/actions-plugin/server/types'; +import { TypeOf } from '@kbn/config-schema'; +import type { ActionTypeExecutorOptions as ConnectorTypeExecutorOptions } from '@kbn/actions-plugin/server/types'; +import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { + PostMessageParamsSchema, + PostMessageSubActionParamsSchema, + SlackApiSecretsSchema, + SlackApiParamsSchema, +} from './schema'; + +export type SlackApiSecrets = TypeOf; + +export type PostMessageParams = TypeOf; +export type PostMessageSubActionParams = TypeOf; +export type SlackApiParams = TypeOf; +export type SlackApiConnectorType = ConnectorType<{}, SlackApiSecrets, SlackApiParams, unknown>; + +export type SlackApiExecutorOptions = ConnectorTypeExecutorOptions< + {}, + SlackApiSecrets, + SlackApiParams +>; + +export type SlackExecutorOptions = ConnectorTypeExecutorOptions< + {}, + SlackApiSecrets, + SlackApiParams +>; + +export type SlackApiActionParams = TypeOf; + +export interface GetChannelsResponse { + ok: true; + error?: string; + channels?: Array<{ + id: string; + name: string; + is_channel: boolean; + is_archived: boolean; + is_private: boolean; + }>; +} + +export interface PostMessageResponse { + ok: boolean; + channel?: string; + error?: string; + message?: { + text: string; + }; +} + +export interface SlackApiService { + getChannels: () => Promise>; + postMessage: ({ + channels, + text, + }: PostMessageSubActionParams) => Promise>; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index 55b2a31d2ca80..2cedad5996a8b 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -18,7 +18,8 @@ import { getServerLogConnectorType } from './server_log'; import { getServiceNowITOMConnectorType } from './servicenow_itom'; import { getServiceNowITSMConnectorType } from './servicenow_itsm'; import { getServiceNowSIRConnectorType } from './servicenow_sir'; -import { getSlackConnectorType } from './slack'; +import { getSlackWebhookConnectorType } from './slack'; +import { getSlackApiConnectorType } from './slack_api'; import { getSwimlaneConnectorType } from './swimlane'; import { getTeamsConnectorType } from './teams'; import { getTinesConnectorType } from './tines'; @@ -41,7 +42,8 @@ export function registerConnectorTypes({ services: RegistrationServices; }) { connectorTypeRegistry.register(getServerLogConnectorType()); - connectorTypeRegistry.register(getSlackConnectorType()); + connectorTypeRegistry.register(getSlackWebhookConnectorType()); + connectorTypeRegistry.register(getSlackApiConnectorType()); connectorTypeRegistry.register(getEmailConnectorType(services)); connectorTypeRegistry.register(getIndexConnectorType()); connectorTypeRegistry.register(getPagerDutyConnectorType()); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts index 05d27afff76fb..74a96853ab149 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { getConnectorType as getSlackConnectorType } from './slack'; +export { getConnectorType as getSlackWebhookConnectorType } from './slack'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx index fabfe46a4db12..c1b4b72182fa9 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx @@ -12,10 +12,28 @@ import type { GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public/types'; import { SlackActionParams, SlackSecrets } from '../types'; +import { PostMessageParams } from '../../../common/slack_api/types'; + +export const subtype = [ + { + id: '.slack', + name: i18n.translate('xpack.stackConnectors.components.slack.webhook', { + defaultMessage: 'Webhook', + }), + }, + { + id: '.slack_api', + name: i18n.translate('xpack.stackConnectors.components.slack.webApi', { + defaultMessage: 'Web API', + }), + }, +]; export function getConnectorType(): ConnectorTypeModel { return { id: '.slack', + subtype, + modalWidth: 675, iconClass: 'logoSlack', selectMessage: i18n.translate('xpack.stackConnectors.components.slack.selectMessageText', { defaultMessage: 'Send a message to a Slack channel or user.', @@ -38,5 +56,17 @@ export function getConnectorType(): ConnectorTypeModel import('./slack_connectors')), actionParamsFields: lazy(() => import('./slack_params')), + convertParamsBetweenGroups: ( + params: PostMessageParams | SlackActionParams + ): PostMessageParams | SlackActionParams | {} => { + if ('message' in params) { + return params; + } else if ('subAction' in params) { + return { + message: (params as PostMessageParams).subActionParams.text, + }; + } + return {}; + }, }; } diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx index 0910565276216..e81dec2d662c2 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx @@ -91,7 +91,7 @@ describe('SlackActionFields renders', () => { }); }); - it('validates teh web hook url field correctly', async () => { + it('validates the web hook url field correctly', async () => { const actionConnector = { secrets: { webhookUrl: 'http://test.com', diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx new file mode 100644 index 0000000000000..258473accdd68 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getConnectorType as getSlackApiConnectorType } from './slack_api'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx new file mode 100644 index 0000000000000..17ed4f9380e09 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registerConnectorTypes } from '..'; +import { registrationServicesMock } from '../../mocks'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; + +let connectorTypeModel: ConnectorTypeModel; + +beforeAll(async () => { + const connectorTypeRegistry = new TypeRegistry(); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); + const getResult = connectorTypeRegistry.get(SLACK_API_CONNECTOR_ID); + if (getResult !== null) { + connectorTypeModel = getResult; + } +}); + +describe('connectorTypeRegistry.get works', () => { + test('connector type static data is as expected', () => { + expect(connectorTypeModel.id).toEqual(SLACK_API_CONNECTOR_ID); + expect(connectorTypeModel.iconClass).toEqual('logoSlack'); + }); +}); + +describe('Slack action params validation', () => { + test('should succeed when action params include valid message and channels list', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: 'some text' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: [], + channels: [], + }, + }); + }); + + test('should fail when channels field is missing in action params', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: [], + channels: ['Selected channel is required.'], + }, + }); + }); + + test('should fail when field text does not exist', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { channels: ['general'] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: ['Message is required.'], + channels: [], + }, + }); + }); + + test('should fail when text is empty string', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: '' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: ['Message is required.'], + channels: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx new file mode 100644 index 0000000000000..6b985dbb90e34 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -0,0 +1,76 @@ +/* + * 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 { lazy } from 'react'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public/types'; +import { + ACTION_TYPE_TITLE, + CHANNEL_REQUIRED, + MESSAGE_REQUIRED, + SELECT_MESSAGE, +} from './translations'; +import type { + SlackApiActionParams, + SlackApiSecrets, + PostMessageParams, +} from '../../../common/slack_api/types'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; +import { SlackActionParams } from '../types'; +import { subtype } from '../slack/slack'; + +export const getConnectorType = (): ConnectorTypeModel< + unknown, + SlackApiSecrets, + PostMessageParams +> => ({ + id: SLACK_API_CONNECTOR_ID, + subtype, + hideInUi: true, + modalWidth: 675, + iconClass: 'logoSlack', + selectMessage: SELECT_MESSAGE, + actionTypeTitle: ACTION_TYPE_TITLE, + validateParams: async ( + actionParams: SlackApiActionParams + ): Promise> => { + const errors = { + text: new Array(), + channels: new Array(), + }; + const validationResult = { errors }; + if (actionParams.subAction === 'postMessage') { + if (!actionParams.subActionParams.text) { + errors.text.push(MESSAGE_REQUIRED); + } + if (!actionParams.subActionParams.channels?.length) { + errors.channels.push(CHANNEL_REQUIRED); + } + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./slack_connectors')), + actionParamsFields: lazy(() => import('./slack_params')), + convertParamsBetweenGroups: ( + params: SlackActionParams | PostMessageParams + ): SlackActionParams | PostMessageParams | {} => { + if ('message' in params) { + return { + subAction: 'postMessage', + subActionParams: { + channels: [], + text: params.message, + }, + }; + } else if ('subAction' in params) { + return params; + } + return {}; + }, +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx new file mode 100644 index 0000000000000..ef9877c5a8772 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { act, render, fireEvent, screen } from '@testing-library/react'; +import SlackActionFields from './slack_connectors'; +import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils'; + +jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); + +describe('SlackActionFields renders', () => { + const onSubmit = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('all connector fields is rendered for web_api type', async () => { + const actionConnector = { + secrets: { + token: 'some token', + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + config: {}, + isDeprecated: false, + }; + + render( + + {}} /> + + ); + + expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); + expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); + }); + + it('connector validation succeeds when connector config is valid for Web API type', async () => { + const actionConnector = { + secrets: { + token: 'some token', + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + config: {}, + isDeprecated: false, + }; + + render( + + {}} /> + + ); + await waitForComponentToUpdate(); + await act(async () => { + fireEvent.click(screen.getByTestId('form-test-provide-submit')); + }); + expect(onSubmit).toBeCalledTimes(1); + expect(onSubmit).toBeCalledWith({ + data: { + secrets: { + token: 'some token', + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }, + isValid: true, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx new file mode 100644 index 0000000000000..4d36cc851ce69 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + ActionConnectorFieldsProps, + SecretsFieldSchema, + SimpleConnectorForm, +} from '@kbn/triggers-actions-ui-plugin/public'; +import * as i18n from './translations'; + +const secretsFormSchema: SecretsFieldSchema[] = [ + { + id: 'token', + label: i18n.TOKEN_LABEL, + isPasswordField: true, + }, +]; + +const SlackActionFields: React.FC = ({ readOnly, isEdit }) => { + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackActionFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx new file mode 100644 index 0000000000000..e8353a3bbabf3 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx @@ -0,0 +1,201 @@ +/* + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import SlackParamsFields from './slack_params'; +import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +interface Result { + isLoading: boolean; + response: Record; + error: null | Error; +} + +const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public'; + +const mockUseSubAction = jest.fn]>( + jest.fn]>(() => ({ + isLoading: false, + response: { + channels: [ + { + id: 'id', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + ], + }, + error: null, + })) +); + +const mockToasts = { danger: jest.fn(), warning: jest.fn() }; +jest.mock(triggersActionsPath, () => { + const original = jest.requireActual(triggersActionsPath); + return { + ...original, + useSubAction: (params: UseSubActionParams) => mockUseSubAction(params), + useKibana: () => ({ + ...original.useKibana(), + notifications: { toasts: mockToasts }, + }), + }; +}); + +describe('SlackParamsFields renders', () => { + test('when useDefaultMessage is set to true and the default message changes, the underlying message is replaced with the default message', () => { + const editAction = jest.fn(); + const { rerender } = render( + + + + ); + expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text'); + rerender( + + + + ); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: ['general'], text: 'some different default message' }, + 0 + ); + }); + + test('when useDefaultMessage is set to false and the default message changes, the underlying message is not changed, Web API', () => { + const editAction = jest.fn(); + const { rerender } = render( + + + + ); + expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text'); + + rerender( + + + + ); + expect(editAction).not.toHaveBeenCalled(); + }); + + test('all params fields is rendered for postMessage call', async () => { + render( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + + expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text'); + }); + + test('all params fields is rendered for getChannels call', async () => { + render( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + + expect(screen.getByTestId('slackChannelsButton')).toHaveTextContent('Channels'); + fireEvent.click(screen.getByTestId('slackChannelsButton')); + expect(screen.getByTestId('slackChannelsSelectableList')).toBeInTheDocument(); + expect(screen.getByTestId('slackChannelsSelectableList')).toHaveTextContent('general'); + fireEvent.click(screen.getByText('general')); + expect(screen.getByTitle('general').getAttribute('aria-checked')).toEqual('true'); + }); + + test('show error message when no channel is selected', async () => { + render( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + expect(screen.getByText('my error message')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx new file mode 100644 index 0000000000000..6d5f284e764b5 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import { + EuiSpacer, + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiSelectable, + EuiSelectableOption, + EuiFormRow, +} from '@elastic/eui'; +import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { GetChannelsResponse, PostMessageParams } from '../../../common/slack_api/types'; + +interface ChannelsStatus { + label: string; + checked?: 'on'; +} + +const SlackParamsFields: React.FunctionComponent> = ({ + actionConnector, + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, + useDefaultMessage, +}) => { + const { subAction, subActionParams } = actionParams; + const { channels = [], text } = subActionParams ?? {}; + const { toasts } = useKibana().notifications; + + useEffect(() => { + if (useDefaultMessage || !text) { + editAction('subActionParams', { channels, text: defaultMessage }, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultMessage, useDefaultMessage]); + + if (!subAction) { + editAction('subAction', 'postMessage', index); + } + if (!subActionParams) { + editAction( + 'subActionParams', + { + channels, + text, + }, + index + ); + } + + const { + response: { channels: channelsInfo } = {}, + isLoading: isLoadingChannels, + error: channelsError, + } = useSubAction({ + connectorId: actionConnector?.id, + subAction: 'getChannels', + }); + + useEffect(() => { + if (channelsError) { + toasts.danger({ + title: i18n.translate( + 'xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed', + { + defaultMessage: 'Failed to retrieve Slack channels list', + } + ), + body: channelsError.message, + }); + } + }, [toasts, channelsError]); + + const slackChannels = useMemo( + () => + channelsInfo + ?.filter((slackChannel) => slackChannel.is_channel) + .map((slackChannel) => ({ label: slackChannel.name })) ?? [], + [channelsInfo] + ); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [selectedChannels, setSelectedChannels] = useState(channels ?? []); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + numFilters={selectedChannels.length} + hasActiveFilters={selectedChannels.length > 0} + numActiveFilters={selectedChannels.length} + data-test-subj="slackChannelsButton" + > + + + ); + + const options: ChannelsStatus[] = useMemo( + () => + slackChannels.map((slackChannel) => ({ + label: slackChannel.label, + ...(selectedChannels.includes(slackChannel.label) ? { checked: 'on' } : {}), + })), + [slackChannels, selectedChannels] + ); + + const onChange = useCallback( + (newOptions: EuiSelectableOption[]) => { + const newSelectedChannels = newOptions.reduce((result, option) => { + if (option.checked === 'on') { + result = [...result, option.label]; + } + return result; + }, []); + + setSelectedChannels(newSelectedChannels); + editAction('subActionParams', { channels: newSelectedChannels, text }, index); + }, + [editAction, index, text] + ); + + return ( + <> + 0 && channels.length === 0} + > + + setIsPopoverOpen(false)} + > + + {(list, search) => ( + <> + {search} + + {list} + + )} + + + + + + + editAction('subActionParams', { channels, text: value }, index) + } + messageVariables={messageVariables} + paramsProperty="webApi" + inputTargetValue={text} + label={i18n.translate('xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', { + defaultMessage: 'Message', + })} + errors={(errors.text ?? []) as string[]} + /> + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts new file mode 100644 index 0000000000000..2c3ea5276ab92 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.slack.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } +); +export const CHANNEL_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.slack.error.requiredSlackChannel', + { + defaultMessage: 'Selected channel is required.', + } +); +export const TOKEN_LABEL = i18n.translate( + 'xpack.stackConnectors.components.slack.tokenTextFieldLabel', + { + defaultMessage: 'API Token', + } +); +export const WEB_API = i18n.translate('xpack.stackConnectors.components.slack.webApi', { + defaultMessage: 'Web API', +}); +export const SELECT_MESSAGE = i18n.translate( + 'xpack.stackConnectors.components.slack.selectMessageText', + { + defaultMessage: 'Send a message to a Slack channel or user.', + } +); +export const ACTION_TYPE_TITLE = i18n.translate( + 'xpack.stackConnectors.components.slack.connectorTypeTitle', + { + defaultMessage: 'Send to Slack', + } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 648695bc7cbbd..0cd9a3b5a7194 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -20,7 +20,8 @@ import { getConnectorType as getIndexConnectorType } from './es_index'; import { getConnectorType as getPagerDutyConnectorType } from './pagerduty'; import { getConnectorType as getSwimlaneConnectorType } from './swimlane'; import { getConnectorType as getServerLogConnectorType } from './server_log'; -import { getConnectorType as getSlackConnectorType } from './slack'; +import { getConnectorType as getSlackWebhookConnectorType } from './slack'; +import { getConnectorType as getSlackApiConnectorType } from './slack_api'; import { getConnectorType as getWebhookConnectorType } from './webhook'; import { getConnectorType as getXmattersConnectorType } from './xmatters'; import { getConnectorType as getTeamsConnectorType } from './teams'; @@ -45,8 +46,10 @@ export type { ActionParamsType as PagerDutyActionParams } from './pagerduty'; export { ConnectorTypeId as ServerLogConnectorTypeId } from './server_log'; export type { ActionParamsType as ServerLogActionParams } from './server_log'; export { ServiceNowITOMConnectorTypeId } from './servicenow_itom'; -export { ConnectorTypeId as SlackConnectorTypeId } from './slack'; -export type { ActionParamsType as SlackActionParams } from './slack'; +export { ConnectorTypeId as SlackWebhookConnectorTypeId } from './slack'; +export type { ActionParamsType as SlackWebhookActionParams } from './slack'; +export { SLACK_API_CONNECTOR_ID as SlackApiConnectorTypeId } from '../../common/slack_api/constants'; +export type { SlackApiActionParams as SlackApiActionParams } from '../../common/slack_api/types'; export { ConnectorTypeId as TeamsConnectorTypeId } from './teams'; export type { ActionParamsType as TeamsActionParams } from './teams'; export { ConnectorTypeId as WebhookConnectorTypeId } from './webhook'; @@ -80,7 +83,8 @@ export function registerConnectorTypes({ actions.registerType(getPagerDutyConnectorType()); actions.registerType(getSwimlaneConnectorType()); actions.registerType(getServerLogConnectorType()); - actions.registerType(getSlackConnectorType({})); + actions.registerType(getSlackWebhookConnectorType({})); + actions.registerType(getSlackApiConnectorType()); actions.registerType(getWebhookConnectorType()); actions.registerType(getCasesWebhookConnectorType()); actions.registerType(getXmattersConnectorType()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts new file mode 100644 index 0000000000000..2ae4a998b261a --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SlackApiService } from '../../../common/slack_api/types'; +import { api } from './api'; + +const createMock = (): jest.Mocked => { + const service = { + postMessage: jest.fn().mockImplementation(() => ({ + ok: true, + channel: 'general', + message: { + text: 'a message', + type: 'message', + }, + })), + getChannels: jest.fn().mockImplementation(() => [ + { + ok: true, + channels: [ + { + id: 'channel_id_1', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + { + id: 'channel_id_2', + name: 'privat', + is_channel: true, + is_archived: false, + is_private: false, + }, + ], + }, + ]), + }; + + return service; +}; + +const slackServiceMock = { + create: createMock, +}; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = slackServiceMock.create(); + }); + + test('getChannels', async () => { + const res = await api.getChannels({ + externalService, + }); + + expect(res).toEqual([ + { + channels: [ + { + id: 'channel_id_1', + is_archived: false, + is_channel: true, + is_private: true, + name: 'general', + }, + { + id: 'channel_id_2', + is_archived: false, + is_channel: true, + is_private: false, + name: 'privat', + }, + ], + ok: true, + }, + ]); + }); + + test('postMessage', async () => { + const res = await api.postMessage({ + externalService, + params: { channels: ['general'], text: 'a message' }, + }); + + expect(res).toEqual({ + channel: 'general', + message: { + text: 'a message', + type: 'message', + }, + ok: true, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts new file mode 100644 index 0000000000000..b0445b7c26e41 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PostMessageSubActionParams, SlackApiService } from '../../../common/slack_api/types'; + +const getChannelsHandler = async ({ externalService }: { externalService: SlackApiService }) => + await externalService.getChannels(); + +const postMessageHandler = async ({ + externalService, + params: { channels, text }, +}: { + externalService: SlackApiService; + params: PostMessageSubActionParams; +}) => await externalService.postMessage({ channels, text }); + +export const api = { + getChannels: getChannelsHandler, + postMessage: postMessageHandler, +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts new file mode 100644 index 0000000000000..66bc3fba1219c --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts @@ -0,0 +1,303 @@ +/* + * 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 axios from 'axios'; +import { Logger } from '@kbn/core/server'; +import { Services } from '@kbn/actions-plugin/server/types'; +import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { getConnectorType } from '.'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { loggerMock } from '@kbn/logging-mocks'; +import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; +import type { PostMessageParams, SlackApiConnectorType } from '../../../common/slack_api/types'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; +import { SLACK_CONNECTOR_NAME } from './translations'; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +const requestMock = utils.request as jest.Mock; + +const services: Services = actionsMock.createServices(); +const mockedLogger: jest.Mocked = loggerMock.create(); + +let connectorType: SlackApiConnectorType; +let configurationUtilities: jest.Mocked; + +beforeEach(() => { + configurationUtilities = actionsConfigMock.create(); + connectorType = getConnectorType(); +}); + +describe('connector registration', () => { + test('returns connector type', () => { + expect(connectorType.id).toEqual(SLACK_API_CONNECTOR_ID); + expect(connectorType.name).toEqual(SLACK_CONNECTOR_NAME); + }); +}); + +describe('validate params', () => { + test('should validate and throw error when params are invalid', () => { + expect(() => { + validateParams(connectorType, {}, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."` + ); + + expect(() => { + validateParams(connectorType, { message: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."` + ); + }); + + test('should validate and pass when params are valid for post message', () => { + expect( + validateParams( + connectorType, + { subAction: 'postMessage', subActionParams: { channels: ['general'], text: 'a text' } }, + { configurationUtilities } + ) + ).toEqual({ + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: 'a text' }, + }); + }); + + test('should validate and pass when params are valid for get channels', () => { + expect( + validateParams(connectorType, { subAction: 'getChannels' }, { configurationUtilities }) + ).toEqual({ + subAction: 'getChannels', + }); + }); +}); + +describe('validate secrets', () => { + test('should validate and throw error when secrets is empty', () => { + expect(() => { + validateSecrets(connectorType, {}, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [token]: expected value of type [string] but got [undefined]"` + ); + }); + + test('should validate and pass when secrets is valid', () => { + validateSecrets( + connectorType, + { + token: 'token', + }, + { configurationUtilities } + ); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(connectorType, { token: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [token]: expected value of type [string] but got [number]"` + ); + }); + + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { + const configUtils = { + ...actionsConfigMock.create(), + ensureUriAllowed: () => { + throw new Error(`target hostname is not added to allowedHosts`); + }, + }; + + expect(() => { + validateSecrets( + connectorType, + { token: 'fake token' }, + { configurationUtilities: configUtils } + ); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: error configuring slack action: target hostname is not added to allowedHosts"` + ); + }); +}); + +describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + axios.create = jest.fn().mockImplementation(() => axios); + connectorType = getConnectorType(); + }); + + test('should fail if params does not include subAction', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + message: { text: 'some text' }, + channel: 'general', + }, + })); + + await expect( + connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + config: {}, + services, + secrets: { token: 'some token' }, + params: {} as PostMessageParams, + configurationUtilities, + logger: mockedLogger, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[Action][ExternalService] -> [Slack API] Unsupported subAction type undefined."` + ); + }); + + test('should fail if subAction is not postMessage/getChannels', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + message: { text: 'some text' }, + channel: 'general', + }, + })); + + await expect( + connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + services, + config: {}, + secrets: { token: 'some token' }, + params: { + subAction: 'getMessage' as 'getChannels', + }, + configurationUtilities, + logger: mockedLogger, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[Action][ExternalService] -> [Slack API] Unsupported subAction type getMessage."` + ); + }); + + test('renders parameter templates as expected', async () => { + expect(connectorType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + subAction: 'postMessage' as const, + subActionParams: { text: 'some text', channels: ['general'] }, + }; + const variables = { rogue: '*bold*' }; + const params = connectorType.renderParameterTemplates!( + paramsWithTemplates, + variables + ) as PostMessageParams; + expect(params.subActionParams.text).toBe('some text'); + }); + + test('should execute with success for post message', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + message: { text: 'some text' }, + channel: 'general', + }, + })); + + const response = await connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + services, + config: {}, + secrets: { token: 'some token' }, + params: { + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: 'some text' }, + }, + configurationUtilities, + logger: mockedLogger, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + configurationUtilities, + logger: mockedLogger, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'some text' }, + }); + + expect(response).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + data: { + channel: 'general', + message: { + text: 'some text', + }, + ok: true, + }, + + status: 'ok', + }); + }); + + test('should execute with success for get channels', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + channels: [ + { + id: 'id', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + ], + }, + })); + const response = await connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + services, + config: {}, + secrets: { token: 'some token' }, + params: { + subAction: 'getChannels', + }, + configurationUtilities, + logger: mockedLogger, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + configurationUtilities, + logger: mockedLogger, + method: 'get', + url: 'conversations.list?types=public_channel,private_channel', + }); + + expect(response).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + data: { + channels: [ + { + id: 'id', + is_archived: false, + is_channel: true, + is_private: true, + name: 'general', + }, + ], + ok: true, + }, + status: 'ok', + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts new file mode 100644 index 0000000000000..ee467dad3d8a2 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { + AlertingConnectorFeatureId, + SecurityConnectorFeatureId, +} from '@kbn/actions-plugin/common/types'; +import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; +import type { ValidatorServices } from '@kbn/actions-plugin/server/types'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import type { + SlackApiExecutorOptions, + SlackApiConnectorType, + SlackApiParams, + SlackApiSecrets, +} from '../../../common/slack_api/types'; +import { SlackApiSecretsSchema, SlackApiParamsSchema } from '../../../common/slack_api/schema'; +import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; +import { SLACK_CONNECTOR_NAME } from './translations'; +import { api } from './api'; +import { createExternalService } from './service'; + +const supportedSubActions = ['getChannels', 'postMessage']; + +export const getConnectorType = (): SlackApiConnectorType => { + return { + id: SLACK_API_CONNECTOR_ID, + minimumLicenseRequired: 'gold', + name: SLACK_CONNECTOR_NAME, + supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], + validate: { + config: { schema: schema.object({}, { defaultValue: {} }) }, + secrets: { + schema: SlackApiSecretsSchema, + customValidator: validateSlackUrl, + }, + params: { + schema: SlackApiParamsSchema, + }, + }, + renderParameterTemplates, + executor: async (execOptions: SlackApiExecutorOptions) => await slackApiExecutor(execOptions), + }; +}; + +const validateSlackUrl = (secretsObject: SlackApiSecrets, validatorServices: ValidatorServices) => { + const { configurationUtilities } = validatorServices; + + try { + configurationUtilities.ensureUriAllowed(SLACK_URL); + } catch (allowedListError) { + throw new Error( + i18n.translate('xpack.stackConnectors.slack_api.configurationError', { + defaultMessage: 'error configuring slack action: {message}', + values: { + message: allowedListError.message, + }, + }) + ); + } +}; + +const renderParameterTemplates = (params: SlackApiParams, variables: Record) => { + if (params.subAction === 'postMessage') + return { + subAction: params.subAction, + subActionParams: { + ...params.subActionParams, + text: renderMustacheString(params.subActionParams.text, variables, 'slack'), + }, + }; + return params; +}; + +const slackApiExecutor = async ({ + actionId, + params, + secrets, + configurationUtilities, + logger, +}: SlackApiExecutorOptions): Promise> => { + const subAction = params.subAction; + + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] -> [Slack API] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] -> [Slack API] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + const externalService = createExternalService( + { + secrets, + }, + logger, + configurationUtilities + ); + + if (subAction === 'getChannels') { + return await api.getChannels({ + externalService, + }); + } + + if (subAction === 'postMessage') { + return await api.postMessage({ + externalService, + params: params.subActionParams, + }); + } + + return { status: 'ok', data: {}, actionId }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts new file mode 100644 index 0000000000000..350c6fc103fb4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts @@ -0,0 +1,181 @@ +/* + * 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 axios from 'axios'; +import { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { createExternalService } from './service'; +import { SlackApiService } from '../../../common/slack_api/types'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const channels = [ + { + id: 'channel_id_1', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + { + id: 'channel_id_2', + name: 'privat', + is_channel: true, + is_archived: false, + is_private: false, + }, +]; + +const getChannelsResponse = createAxiosResponse({ + data: { + ok: true, + channels, + }, +}); + +const postMessageResponse = createAxiosResponse({ + data: [ + { + ok: true, + channel: 'general', + message: { + text: 'a message', + type: 'message', + }, + }, + { + ok: true, + channel: 'privat', + message: { + text: 'a message', + type: 'message', + }, + }, + ], +}); + +describe('Slack API service', () => { + let service: SlackApiService; + + beforeAll(() => { + service = createExternalService( + { + secrets: { token: 'token' }, + }, + logger, + configurationUtilities + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Secrets validation', () => { + test('throws without token', () => { + expect(() => + createExternalService( + { + secrets: { token: '' }, + }, + logger, + configurationUtilities + ) + ).toThrowErrorMatchingInlineSnapshot(`"[Action][Slack API]: Wrong configuration."`); + }); + }); + + describe('getChannels', () => { + test('should get slack channels', async () => { + requestMock.mockImplementation(() => getChannelsResponse); + const res = await service.getChannels(); + expect(res).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + data: { + ok: true, + channels, + }, + status: 'ok', + }); + }); + + test('should call request with correct arguments', async () => { + requestMock.mockImplementation(() => getChannelsResponse); + + await service.getChannels(); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + method: 'get', + url: 'conversations.list?types=public_channel,private_channel', + }); + }); + + test('should throw an error if request to slack fail', async () => { + requestMock.mockImplementation(() => { + throw new Error('request fail'); + }); + + expect(await service.getChannels()).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + message: 'error posting slack message', + serviceMessage: 'request fail', + status: 'error', + }); + }); + }); + + describe('postMessage', () => { + test('should call request with correct arguments', async () => { + requestMock.mockImplementation(() => postMessageResponse); + + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'a message' }, + }); + }); + + test('should throw an error if request to slack fail', async () => { + requestMock.mockImplementation(() => { + throw new Error('request fail'); + }); + + expect( + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }) + ).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + message: 'error posting slack message', + serviceMessage: 'request fail', + status: 'error', + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts new file mode 100644 index 0000000000000..723d629a74418 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts @@ -0,0 +1,162 @@ +/* + * 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 axios, { AxiosResponse } from 'axios'; +import { Logger } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { map, getOrElse } from 'fp-ts/lib/Option'; +import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { SLACK_CONNECTOR_NAME } from './translations'; +import type { + PostMessageSubActionParams, + SlackApiService, + PostMessageResponse, +} from '../../../common/slack_api/types'; +import { + retryResultSeconds, + retryResult, + serviceErrorResult, + errorResult, + successResult, +} from '../../../common/slack_api/lib'; +import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; +import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header'; + +const buildSlackExecutorErrorResponse = ({ + slackApiError, + logger, +}: { + slackApiError: { + message: string; + response: { + status: number; + statusText: string; + headers: Record; + }; + }; + logger: Logger; +}) => { + if (!slackApiError.response) { + return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiError.message); + } + + const { status, statusText, headers } = slackApiError.response; + + // special handling for 5xx + if (status >= 500) { + return retryResult(SLACK_API_CONNECTOR_ID, slackApiError.message); + } + + // special handling for rate limiting + if (status === 429) { + return pipe( + getRetryAfterIntervalFromHeaders(headers), + map((retry) => retryResultSeconds(SLACK_API_CONNECTOR_ID, slackApiError.message, retry)), + getOrElse(() => retryResult(SLACK_API_CONNECTOR_ID, slackApiError.message)) + ); + } + + const errorMessage = i18n.translate( + 'xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage', + { + defaultMessage: 'unexpected http response from slack: {httpStatus} {httpStatusText}', + values: { + httpStatus: status, + httpStatusText: statusText, + }, + } + ); + logger.error(`error on ${SLACK_API_CONNECTOR_ID} slack action: ${errorMessage}`); + + return errorResult(SLACK_API_CONNECTOR_ID, errorMessage); +}; + +const buildSlackExecutorSuccessResponse = ({ + slackApiResponseData, +}: { + slackApiResponseData: PostMessageResponse; +}) => { + if (!slackApiResponseData) { + const errMessage = i18n.translate( + 'xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage', + { + defaultMessage: 'unexpected null response from slack', + } + ); + return errorResult(SLACK_API_CONNECTOR_ID, errMessage); + } + + if (!slackApiResponseData.ok) { + return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiResponseData.error); + } + + return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); +}; + +export const createExternalService = ( + { secrets }: { secrets: { token: string } }, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +): SlackApiService => { + const { token } = secrets; + + if (!token) { + throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`); + } + + const axiosInstance = axios.create({ + baseURL: SLACK_URL, + headers: { + Authorization: `Bearer ${token}`, + 'Content-type': 'application/json; charset=UTF-8', + }, + }); + + const getChannels = async (): Promise> => { + try { + const result = await request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: 'conversations.list?types=public_channel,private_channel', + }); + + return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + } catch (error) { + return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); + } + }; + + const postMessage = async ({ + channels, + text, + }: PostMessageSubActionParams): Promise> => { + try { + const result: AxiosResponse = await request({ + axios: axiosInstance, + method: 'post', + url: 'chat.postMessage', + logger, + data: { channel: channels[0], text }, + configurationUtilities, + }); + + return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + } catch (error) { + return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); + } + }; + + return { + getChannels, + postMessage, + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts new file mode 100644 index 0000000000000..03157b6c6d53f --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SLACK_CONNECTOR_NAME = i18n.translate('xpack.stackConnectors.slackApi.title', { + defaultMessage: 'Slack API', +}); diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index bfc2bb9fd4197..a572970e0be15 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -25,7 +25,7 @@ describe('Stack Connectors Plugin', () => { it('should register built in connector types', () => { const actionsSetup = actionsMock.createSetup(); plugin.setup(coreSetup, { actions: actionsSetup }); - expect(actionsSetup.registerType).toHaveBeenCalledTimes(16); + expect(actionsSetup.registerType).toHaveBeenCalledTimes(17); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -69,63 +69,63 @@ describe('Stack Connectors Plugin', () => { }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 7, + 8, expect.objectContaining({ id: '.webhook', name: 'Webhook', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 8, + 9, expect.objectContaining({ id: '.cases-webhook', name: 'Webhook - Case Management', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 9, + 10, expect.objectContaining({ id: '.xmatters', name: 'xMatters', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 10, + 11, expect.objectContaining({ id: '.servicenow', name: 'ServiceNow ITSM', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 11, + 12, expect.objectContaining({ id: '.servicenow-sir', name: 'ServiceNow SecOps', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 12, + 13, expect.objectContaining({ id: '.servicenow-itom', name: 'ServiceNow ITOM', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 13, + 14, expect.objectContaining({ id: '.jira', name: 'Jira', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 14, + 15, expect.objectContaining({ id: '.resilient', name: 'IBM Resilient', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 15, + 16, expect.objectContaining({ id: '.teams', name: 'Microsoft Teams', diff --git a/x-pack/plugins/stack_connectors/server/types.ts b/x-pack/plugins/stack_connectors/server/types.ts index 697c6a358fbe0..d9cd9f9b99cad 100644 --- a/x-pack/plugins/stack_connectors/server/types.ts +++ b/x-pack/plugins/stack_connectors/server/types.ts @@ -21,8 +21,10 @@ export type { PagerDutyActionParams, ServerLogConnectorTypeId, ServerLogActionParams, - SlackConnectorTypeId, - SlackActionParams, + SlackApiConnectorTypeId, + SlackApiActionParams, + SlackWebhookConnectorTypeId, + SlackWebhookActionParams, WebhookConnectorTypeId, WebhookActionParams, ServiceNowITSMConnectorTypeId, diff --git a/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts b/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts index adfa002e8e20a..23ceb20ad75d6 100644 --- a/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts +++ b/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; import { + CodeEditorMode, BrowserAdvancedFields, BrowserSimpleFields, CommonFields, @@ -198,19 +199,25 @@ export const DEFAULT_HTTP_SIMPLE_FIELDS: HTTPSimpleFields = { export const DEFAULT_HTTP_ADVANCED_FIELDS: HTTPAdvancedFields = { [ConfigKey.PASSWORD]: '', [ConfigKey.PROXY_URL]: '', + [ConfigKey.PROXY_HEADERS]: {}, [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: [], [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: [], + [ConfigKey.RESPONSE_JSON_CHECK]: [], [ConfigKey.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicy.ON_ERROR, [ConfigKey.RESPONSE_HEADERS_CHECK]: {}, [ConfigKey.RESPONSE_HEADERS_INDEX]: true, [ConfigKey.RESPONSE_STATUS_CHECK]: [], [ConfigKey.REQUEST_BODY_CHECK]: { value: '', - type: Mode.PLAINTEXT, + type: CodeEditorMode.PLAINTEXT, }, [ConfigKey.REQUEST_HEADERS_CHECK]: {}, [ConfigKey.REQUEST_METHOD_CHECK]: HTTPMethod.GET, [ConfigKey.USERNAME]: '', + [ConfigKey.MODE]: Mode.ANY, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: '1024', + [ConfigKey.IPV4]: true, + [ConfigKey.IPV6]: true, }; export const DEFAULT_ICMP_SIMPLE_FIELDS: ICMPSimpleFields = { @@ -238,6 +245,15 @@ export const DEFAULT_TCP_ADVANCED_FIELDS: TCPAdvancedFields = { [ConfigKey.PROXY_USE_LOCAL_RESOLVER]: false, [ConfigKey.RESPONSE_RECEIVE_CHECK]: '', [ConfigKey.REQUEST_SEND_CHECK]: '', + [ConfigKey.MODE]: Mode.ANY, + [ConfigKey.IPV4]: true, + [ConfigKey.IPV6]: true, +}; + +export const DEFAULT_ICMP_ADVANCED_FIELDS = { + [ConfigKey.MODE]: Mode.ANY, + [ConfigKey.IPV4]: true, + [ConfigKey.IPV6]: true, }; export const DEFAULT_TLS_FIELDS: TLSFields = { @@ -262,6 +278,7 @@ export const DEFAULT_FIELDS: MonitorDefaults = { }, [DataStream.ICMP]: { ...DEFAULT_ICMP_SIMPLE_FIELDS, + ...DEFAULT_ICMP_ADVANCED_FIELDS, }, [DataStream.BROWSER]: { ...DEFAULT_BROWSER_SIMPLE_FIELDS, diff --git a/x-pack/plugins/synthetics/common/constants/monitor_management.ts b/x-pack/plugins/synthetics/common/constants/monitor_management.ts index 01da8ebce5d43..be3802f132a58 100644 --- a/x-pack/plugins/synthetics/common/constants/monitor_management.ts +++ b/x-pack/plugins/synthetics/common/constants/monitor_management.ts @@ -27,6 +27,7 @@ export enum ConfigKey { JOURNEY_ID = 'journey_id', MAX_REDIRECTS = 'max_redirects', METADATA = '__ui', + MODE = 'mode', MONITOR_TYPE = 'type', NAME = 'name', NAMESPACE = 'namespace', @@ -37,12 +38,15 @@ export enum ConfigKey { ORIGINAL_SPACE = 'original_space', // the original space the montior was saved in. Used by push monitors to ensure uniqueness of monitor id sent to heartbeat and prevent data collisions PORT = 'url.port', PROXY_URL = 'proxy_url', + PROXY_HEADERS = 'proxy_headers', PROXY_USE_LOCAL_RESOLVER = 'proxy_use_local_resolver', RESPONSE_BODY_CHECK_NEGATIVE = 'check.response.body.negative', RESPONSE_BODY_CHECK_POSITIVE = 'check.response.body.positive', + RESPONSE_JSON_CHECK = 'check.response.json', RESPONSE_BODY_INDEX = 'response.include_body', RESPONSE_HEADERS_CHECK = 'check.response.headers', RESPONSE_HEADERS_INDEX = 'response.include_headers', + RESPONSE_BODY_MAX_BYTES = 'response.include_body_max_bytes', RESPONSE_RECEIVE_CHECK = 'check.receive', RESPONSE_STATUS_CHECK = 'check.response.status', REQUEST_BODY_CHECK = 'check.request.body', @@ -54,6 +58,8 @@ export enum ConfigKey { SCREENSHOTS = 'screenshots', SOURCE_PROJECT_CONTENT = 'source.project.content', SOURCE_INLINE = 'source.inline.script', + IPV4 = 'ipv4', + IPV6 = 'ipv6', PROJECT_ID = 'project_id', SYNTHETICS_ARGS = 'synthetics_args', TEXT_ASSERTION = 'playwright_text_assertion', @@ -73,6 +79,7 @@ export enum ConfigKey { } export const secretKeys = [ + ConfigKey.PROXY_HEADERS, ConfigKey.PARAMS, ConfigKey.PASSWORD, ConfigKey.REQUEST_BODY_CHECK, @@ -80,6 +87,7 @@ export const secretKeys = [ ConfigKey.REQUEST_SEND_CHECK, ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE, ConfigKey.RESPONSE_BODY_CHECK_POSITIVE, + ConfigKey.RESPONSE_JSON_CHECK, ConfigKey.RESPONSE_HEADERS_CHECK, ConfigKey.RESPONSE_RECEIVE_CHECK, ConfigKey.SOURCE_INLINE, diff --git a/x-pack/plugins/synthetics/common/formatters/http/formatters.ts b/x-pack/plugins/synthetics/common/formatters/http/formatters.ts index 300e4f9fdb9ff..437112939f283 100644 --- a/x-pack/plugins/synthetics/common/formatters/http/formatters.ts +++ b/x-pack/plugins/synthetics/common/formatters/http/formatters.ts @@ -23,9 +23,11 @@ export const httpFormatters: HTTPFormatMap = { [ConfigKey.USERNAME]: null, [ConfigKey.PASSWORD]: null, [ConfigKey.PROXY_URL]: null, + [ConfigKey.PROXY_HEADERS]: objectToJsonFormatter, [ConfigKey.PORT]: null, [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: arrayToJsonFormatter, [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: arrayToJsonFormatter, + [ConfigKey.RESPONSE_JSON_CHECK]: arrayToJsonFormatter, [ConfigKey.RESPONSE_HEADERS_CHECK]: objectToJsonFormatter, [ConfigKey.RESPONSE_STATUS_CHECK]: arrayToJsonFormatter, [ConfigKey.REQUEST_HEADERS_CHECK]: objectToJsonFormatter, @@ -33,6 +35,10 @@ export const httpFormatters: HTTPFormatMap = { fields[ConfigKey.REQUEST_BODY_CHECK]?.value ? JSON.stringify(fields[ConfigKey.REQUEST_BODY_CHECK]?.value) : null, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: null, + [ConfigKey.MODE]: null, + [ConfigKey.IPV4]: null, + [ConfigKey.IPV6]: null, ...tlsFormatters, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts b/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts index 0ebf69db5b408..f58e15c86b3ad 100644 --- a/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts +++ b/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts @@ -15,5 +15,8 @@ export type ICMPFormatMap = Record; export const icmpFormatters: ICMPFormatMap = { [ConfigKey.HOSTS]: null, [ConfigKey.WAIT]: secondsToCronFormatter, + [ConfigKey.MODE]: null, + [ConfigKey.IPV4]: null, + [ConfigKey.IPV6]: null, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts b/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts index 6acb9abe21877..2d850e95ceaf1 100644 --- a/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts +++ b/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts @@ -23,6 +23,9 @@ export const tcpFormatters: TCPFormatMap = { [ConfigKey.PROXY_URL]: null, [ConfigKey.PORT]: null, [ConfigKey.URLS]: null, + [ConfigKey.MODE]: null, + [ConfigKey.IPV4]: null, + [ConfigKey.IPV6]: null, ...tlsFormatters, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/common/rules/alert_actions.ts b/x-pack/plugins/synthetics/common/rules/alert_actions.ts index 0c9782108743f..2dc1990e26b45 100644 --- a/x-pack/plugins/synthetics/common/rules/alert_actions.ts +++ b/x-pack/plugins/synthetics/common/rules/alert_actions.ts @@ -20,7 +20,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ActionConnector, ActionTypeId } from './types'; import { DefaultEmail } from '../runtime_types'; -export const SLACK_ACTION_ID: ActionTypeId = '.slack'; +export const SLACK_WEBHOOK_ACTION_ID: ActionTypeId = '.slack'; export const PAGER_DUTY_ACTION_ID: ActionTypeId = '.pagerduty'; export const SERVER_LOG_ACTION_ID: ActionTypeId = '.server-log'; export const INDEX_ACTION_ID: ActionTypeId = '.index'; @@ -98,7 +98,7 @@ export function populateAlertActions({ recoveredAction.params = getWebhookActionParams(translations, true); actions.push(recoveredAction); break; - case SLACK_ACTION_ID: + case SLACK_WEBHOOK_ACTION_ID: case TEAMS_ACTION_ID: action.params = { message: translations.defaultActionMessage, diff --git a/x-pack/plugins/synthetics/common/rules/types.ts b/x-pack/plugins/synthetics/common/rules/types.ts index 101ce9c1418c6..c398d66e376a2 100644 --- a/x-pack/plugins/synthetics/common/rules/types.ts +++ b/x-pack/plugins/synthetics/common/rules/types.ts @@ -11,7 +11,7 @@ import type { PagerDutyConnectorTypeId, ServerLogConnectorTypeId, ServiceNowITSMConnectorTypeId as ServiceNowConnectorTypeId, - SlackConnectorTypeId, + SlackWebhookConnectorTypeId, TeamsConnectorTypeId, WebhookConnectorTypeId, EmailConnectorTypeId, @@ -20,7 +20,7 @@ import type { import type { ActionConnector as RawActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; export type ActionTypeId = - | typeof SlackConnectorTypeId + | typeof SlackWebhookConnectorTypeId | typeof PagerDutyConnectorTypeId | typeof ServerLogConnectorTypeId | typeof IndexConnectorTypeId diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts index e616d887df4f7..dcd6b18974da4 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts @@ -54,15 +54,15 @@ export const MonacoEditorLangIdCodec = tEnum( ); export type MonacoEditorLangIdType = t.TypeOf; -export enum Mode { +export enum CodeEditorMode { FORM = 'form', JSON = 'json', PLAINTEXT = 'text', XML = 'xml', } -export const ModeCodec = tEnum('Mode', Mode); -export type ModeType = t.TypeOf; +export const CodeEditorModeCodec = tEnum('CodeEditorMode', CodeEditorMode); +export type CodeEditorModeType = t.TypeOf; export enum ContentType { JSON = 'application/json', @@ -127,3 +127,16 @@ export enum FormMonitorType { } export const FormMonitorTypeCodec = tEnum('FormMonitorType', FormMonitorType); + +export enum Mode { + ANY = 'any', + ALL = 'all', +} +export const ModeCodec = tEnum('Mode', Mode); +export type ModeType = t.TypeOf; + +export const ResponseCheckJSONCodec = t.interface({ + description: t.string, + expression: t.string, +}); +export type ResponseCheckJSON = t.TypeOf; diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts index 97363987afcc4..d5a8d26568633 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts @@ -11,11 +11,13 @@ import { secretKeys } from '../../constants/monitor_management'; import { ConfigKey } from './config_key'; import { MonitorServiceLocationCodec, ServiceLocationErrors } from './locations'; import { + CodeEditorModeCodec, DataStream, DataStreamCodec, FormMonitorTypeCodec, ModeCodec, ResponseBodyIndexPolicyCodec, + ResponseCheckJSONCodec, ScheduleUnitCodec, SourceTypeCodec, TLSVersionCodec, @@ -94,10 +96,17 @@ export const TCPSimpleFieldsCodec = t.intersection([ export type TCPSimpleFields = t.TypeOf; // TCPAdvancedFields -export const TCPAdvancedFieldsCodec = t.interface({ - [ConfigKey.PROXY_URL]: t.string, - [ConfigKey.PROXY_USE_LOCAL_RESOLVER]: t.boolean, -}); +export const TCPAdvancedFieldsCodec = t.intersection([ + t.interface({ + [ConfigKey.PROXY_URL]: t.string, + [ConfigKey.PROXY_USE_LOCAL_RESOLVER]: t.boolean, + }), + t.partial({ + [ConfigKey.MODE]: ModeCodec, + [ConfigKey.IPV4]: t.boolean, + [ConfigKey.IPV6]: t.boolean, + }), +]); export const TCPSensitiveAdvancedFieldsCodec = t.interface({ [ConfigKey.RESPONSE_RECEIVE_CHECK]: t.string, @@ -136,7 +145,18 @@ export const ICMPSimpleFieldsCodec = t.intersection([ ]); export type ICMPSimpleFields = t.TypeOf; -export type ICMPFields = t.TypeOf; + +// ICMPAdvancedFields +export const ICMPAdvancedFieldsCodec = t.partial({ + [ConfigKey.MODE]: ModeCodec, + [ConfigKey.IPV4]: t.boolean, + [ConfigKey.IPV6]: t.boolean, +}); + +// ICMPFields +export const ICMPFieldsCodec = t.intersection([ICMPSimpleFieldsCodec, ICMPAdvancedFieldsCodec]); + +export type ICMPFields = t.TypeOf; // HTTPSimpleFields export const HTTPSimpleFieldsCodec = t.intersection([ @@ -152,23 +172,37 @@ export const HTTPSimpleFieldsCodec = t.intersection([ export type HTTPSimpleFields = t.TypeOf; // HTTPAdvancedFields -export const HTTPAdvancedFieldsCodec = t.interface({ - [ConfigKey.PROXY_URL]: t.string, - [ConfigKey.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicyCodec, - [ConfigKey.RESPONSE_HEADERS_INDEX]: t.boolean, - [ConfigKey.RESPONSE_STATUS_CHECK]: t.array(t.string), - [ConfigKey.REQUEST_METHOD_CHECK]: t.string, -}); +export const HTTPAdvancedFieldsCodec = t.intersection([ + t.interface({ + [ConfigKey.PROXY_URL]: t.string, + [ConfigKey.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicyCodec, + [ConfigKey.RESPONSE_HEADERS_INDEX]: t.boolean, + [ConfigKey.RESPONSE_STATUS_CHECK]: t.array(t.string), + [ConfigKey.REQUEST_METHOD_CHECK]: t.string, + }), + t.partial({ + [ConfigKey.MODE]: ModeCodec, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: t.string, + [ConfigKey.IPV4]: t.boolean, + [ConfigKey.IPV6]: t.boolean, + }), +]); -export const HTTPSensitiveAdvancedFieldsCodec = t.interface({ - [ConfigKey.PASSWORD]: t.string, - [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: t.array(t.string), - [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: t.array(t.string), - [ConfigKey.RESPONSE_HEADERS_CHECK]: t.record(t.string, t.string), - [ConfigKey.REQUEST_BODY_CHECK]: t.interface({ value: t.string, type: ModeCodec }), - [ConfigKey.REQUEST_HEADERS_CHECK]: t.record(t.string, t.string), - [ConfigKey.USERNAME]: t.string, -}); +export const HTTPSensitiveAdvancedFieldsCodec = t.intersection([ + t.interface({ + [ConfigKey.PASSWORD]: t.string, + [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: t.array(t.string), + [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: t.array(t.string), + [ConfigKey.RESPONSE_HEADERS_CHECK]: t.record(t.string, t.string), + [ConfigKey.REQUEST_BODY_CHECK]: t.interface({ value: t.string, type: CodeEditorModeCodec }), + [ConfigKey.REQUEST_HEADERS_CHECK]: t.record(t.string, t.string), + [ConfigKey.USERNAME]: t.string, + }), + t.partial({ + [ConfigKey.PROXY_HEADERS]: t.record(t.string, t.string), + [ConfigKey.RESPONSE_JSON_CHECK]: t.array(ResponseCheckJSONCodec), + }), +]); export const HTTPAdvancedCodec = t.intersection([ HTTPAdvancedFieldsCodec, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx index 6f920bf10d84a..c08434460aab5 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../utils/testing/rtl_helpers'; import { HeaderField, contentTypes } from './header_field'; -import { Mode } from '../types'; +import { CodeEditorMode } from '../types'; describe('', () => { const onChange = jest.fn(); @@ -95,14 +95,14 @@ describe('', () => { }); it('handles content mode', async () => { - const contentMode: Mode = Mode.PLAINTEXT; + const contentMode: CodeEditorMode = CodeEditorMode.PLAINTEXT; render( ); await waitFor(() => { expect(onChange).toBeCalledWith({ - 'Content-Type': contentTypes[Mode.PLAINTEXT], + 'Content-Type': contentTypes[CodeEditorMode.PLAINTEXT], }); }); }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx index a26fe8616d90b..c3159043b9958 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx @@ -7,12 +7,12 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ContentType, Mode } from '../types'; +import { ContentType, CodeEditorMode } from '../types'; import { KeyValuePairsField, Pair } from './key_value_field'; export interface HeaderFieldProps { - contentMode?: Mode; + contentMode?: CodeEditorMode; defaultValue: Record; onChange: (value: Record) => void; onBlur?: () => void; @@ -72,9 +72,9 @@ export const HeaderField = ({ ); }; -export const contentTypes: Record = { - [Mode.JSON]: ContentType.JSON, - [Mode.PLAINTEXT]: ContentType.TEXT, - [Mode.XML]: ContentType.XML, - [Mode.FORM]: ContentType.FORM, +export const contentTypes: Record = { + [CodeEditorMode.JSON]: ContentType.JSON, + [CodeEditorMode.PLAINTEXT]: ContentType.TEXT, + [CodeEditorMode.XML]: ContentType.XML, + [CodeEditorMode.FORM]: ContentType.FORM, }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx index 95b2348fd0219..7d062fbde7548 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx @@ -46,13 +46,15 @@ export type Pair = [ string // value ]; -interface Props { +export interface KeyValuePairsFieldProps { addPairControlLabel: string | React.ReactElement; defaultPairs: Pair[]; onChange: (pairs: Pair[]) => void; onBlur?: () => void; 'data-test-subj'?: string; readOnly?: boolean; + keyLabel?: string | React.ReactElement; + valueLabel?: string | React.ReactElement; } export const KeyValuePairsField = ({ @@ -62,7 +64,9 @@ export const KeyValuePairsField = ({ onBlur, 'data-test-subj': dataTestSubj, readOnly, -}: Props) => { + keyLabel, + valueLabel, +}: KeyValuePairsFieldProps) => { const [pairs, setPairs] = useState(defaultPairs); const handleOnChange = useCallback( @@ -121,20 +125,20 @@ export const KeyValuePairsField = ({ children: ( - { + {keyLabel || ( - } + )} - { + {valueLabel || ( - } + )} ), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx index a472c3231053b..d3a8bd8076fe1 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx @@ -13,7 +13,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import { mockGlobals } from '../../../utils/testing'; import { render } from '../../../utils/testing/rtl_helpers'; import { RequestBodyField } from './request_body_field'; -import { Mode } from '../types'; +import { CodeEditorMode } from '../types'; mockGlobals(); @@ -40,7 +40,7 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { }); describe('', () => { - const defaultMode = Mode.PLAINTEXT; + const defaultMode = CodeEditorMode.PLAINTEXT; const defaultValue = 'sample value'; const WrappedComponent = ({ readOnly }: { readOnly?: boolean }) => { const [config, setConfig] = useState({ @@ -55,7 +55,7 @@ describe('', () => { type: config.type, }} onChange={useCallback( - (code) => setConfig({ type: code.type as Mode, value: code.value }), + (code) => setConfig({ type: code.type as CodeEditorMode, value: code.value }), [setConfig] )} readOnly={readOnly} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx index fe117a4703ffb..e6877942f4f14 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx @@ -9,15 +9,15 @@ import { stringify, parse } from 'query-string'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiTabbedContent } from '@elastic/eui'; -import { Mode, MonacoEditorLangId } from '../types'; +import { CodeEditorMode, MonacoEditorLangId } from '../types'; import { KeyValuePairsField, Pair } from './key_value_field'; import { CodeEditor } from './code_editor'; export interface RequestBodyFieldProps { - onChange: (requestBody: { type: Mode; value: string }) => void; + onChange: (requestBody: { type: CodeEditorMode; value: string }) => void; onBlur?: () => void; value: { - type: Mode; + type: CodeEditorMode; value: string; }; readOnly?: boolean; @@ -36,22 +36,27 @@ export const RequestBodyField = ({ readOnly, }: RequestBodyFieldProps) => { const [values, setValues] = useState>({ - [ResponseBodyType.FORM]: type === Mode.FORM ? value : '', - [ResponseBodyType.CODE]: type !== Mode.FORM ? value : '', + [ResponseBodyType.FORM]: type === CodeEditorMode.FORM ? value : '', + [ResponseBodyType.CODE]: type !== CodeEditorMode.FORM ? value : '', }); useEffect(() => { onChange({ type, - value: type === Mode.FORM ? values[ResponseBodyType.FORM] : values[ResponseBodyType.CODE], + value: + type === CodeEditorMode.FORM + ? values[ResponseBodyType.FORM] + : values[ResponseBodyType.CODE], }); }, [onChange, type, values]); const handleSetMode = useCallback( - (currentMode: Mode) => { + (currentMode: CodeEditorMode) => { onChange({ type: currentMode, value: - currentMode === Mode.FORM ? values[ResponseBodyType.FORM] : values[ResponseBodyType.CODE], + currentMode === CodeEditorMode.FORM + ? values[ResponseBodyType.FORM] + : values[ResponseBodyType.CODE], }); }, [onChange, values] @@ -71,14 +76,14 @@ export const RequestBodyField = ({ }, {}); return setValues((prevValues) => ({ ...prevValues, - [Mode.FORM]: stringify(formattedPairs), + [CodeEditorMode.FORM]: stringify(formattedPairs), })); }, [setValues] ); const defaultFormPairs: Pair[] = useMemo(() => { - const pairs = parse(values[Mode.FORM]); + const pairs = parse(values[CodeEditorMode.FORM]); const keys = Object.keys(pairs); const formattedPairs: Pair[] = keys.map((key: string) => { // key, value, checked; @@ -89,9 +94,9 @@ export const RequestBodyField = ({ const tabs = [ { - id: Mode.PLAINTEXT, - name: modeLabels[Mode.PLAINTEXT], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.PLAINTEXT}`, + id: CodeEditorMode.PLAINTEXT, + name: modeLabels[CodeEditorMode.PLAINTEXT], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.PLAINTEXT}`, content: ( { setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })); @@ -112,9 +117,9 @@ export const RequestBodyField = ({ ), }, { - id: Mode.JSON, - name: modeLabels[Mode.JSON], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.JSON}`, + id: CodeEditorMode.JSON, + name: modeLabels[CodeEditorMode.JSON], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.JSON}`, content: ( { setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })); @@ -135,9 +140,9 @@ export const RequestBodyField = ({ ), }, { - id: Mode.XML, - name: modeLabels[Mode.XML], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.XML}`, + id: CodeEditorMode.XML, + name: modeLabels[CodeEditorMode.XML], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.XML}`, content: ( { setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })); @@ -158,9 +163,9 @@ export const RequestBodyField = ({ ), }, { - id: Mode.FORM, - name: modeLabels[Mode.FORM], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.FORM}`, + id: CodeEditorMode.FORM, + name: modeLabels[CodeEditorMode.FORM], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.FORM}`, content: ( tab.id === type)} autoFocus="selected" onTabClick={(tab) => { - handleSetMode(tab.id as Mode); + handleSetMode(tab.id as CodeEditorMode); }} />
@@ -195,25 +200,25 @@ export const RequestBodyField = ({ }; const modeLabels = { - [Mode.FORM]: i18n.translate( + [CodeEditorMode.FORM]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.form', { defaultMessage: 'Form', } ), - [Mode.PLAINTEXT]: i18n.translate( + [CodeEditorMode.PLAINTEXT]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.text', { defaultMessage: 'Text', } ), - [Mode.JSON]: i18n.translate( + [CodeEditorMode.JSON]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.JSON', { defaultMessage: 'JSON', } ), - [Mode.XML]: i18n.translate( + [CodeEditorMode.XML]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.XML', { defaultMessage: 'XML', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx index 7aed077680d4b..cd39245c1c7ec 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx @@ -24,7 +24,6 @@ export const Field = memo( props, fieldKey, controlled, - showWhen, shouldUseSetValue, required, validation, @@ -32,6 +31,7 @@ export const Field = memo( fieldError, dependencies, customHook, + hidden, }: Props) => { const { register, watch, control, setValue, reset, getFieldState, formState } = useFormContext(); @@ -41,13 +41,7 @@ export const Field = memo( const [dependenciesFieldMeta, setDependenciesFieldMeta] = useState< Record >({}); - let show = true; let dependenciesValues: unknown[] = []; - if (showWhen) { - const [showKey, expectedValue] = showWhen; - const [actualValue] = watch([showKey]); - show = actualValue === expectedValue; - } if (dependencies) { dependenciesValues = watch(dependencies); } @@ -64,7 +58,7 @@ export const Field = memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(dependenciesValues || []), dependencies, getFieldState]); - if (!show) { + if (hidden && hidden(dependenciesValues)) { return null; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx index b2839da207ff8..9653c415e171a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { isValidNamespace } from '@kbn/fleet-plugin/common'; @@ -15,7 +16,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, - EuiTextArea, EuiSelectProps, EuiFieldTextProps, EuiSwitchProps, @@ -54,6 +54,8 @@ import { ResponseBodyIndexField, ResponseBodyIndexFieldProps, ControlledFieldProp, + KeyValuePairsField, + TextArea, ThrottlingWrapper, } from './field_wrappers'; import { getDocLinks } from '../../../../../kibana_services'; @@ -64,16 +66,20 @@ import { FormMonitorType, HTTPMethod, ScreenshotOption, + Mode, MonitorFields, TLSVersion, VerificationMode, FieldMap, FormLocation, + ResponseBodyIndexPolicy, + ResponseCheckJSON, ThrottlingConfig, } from '../types'; import { AlertConfigKey, ALLOWED_SCHEDULES_IN_MINUTES } from '../constants'; import { getDefaultFormFields } from './defaults'; import { validate, validateHeaders, WHOLE_NUMBERS_ONLY, FLOATS_ONLY } from './validation'; +import { KeyValuePairsFieldProps } from '../fields/key_value_field'; const getScheduleContent = (value: number) => { if (value > 60) { @@ -765,7 +771,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_STATUS_CHECK, component: FormattedComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.label', { - defaultMessage: 'Check response status equals', + defaultMessage: 'Response status equals', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.helpText', { defaultMessage: @@ -794,7 +800,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_HEADERS_CHECK, component: HeaderField, label: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.label', { - defaultMessage: 'Check response headers contain', + defaultMessage: 'Response headers contain', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.helpText', { defaultMessage: 'A list of expected response headers.', @@ -814,7 +820,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_BODY_CHECK_POSITIVE, component: FormattedComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheck.label', { - defaultMessage: 'Check response body contains', + defaultMessage: 'Response body contains', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheck.helpText', { defaultMessage: @@ -830,7 +836,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE, component: FormattedComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheckNegative.label', { - defaultMessage: 'Check response body does not contain', + defaultMessage: 'Response body does not contain', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheckNegative.helpText', { defaultMessage: @@ -846,7 +852,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_RECEIVE_CHECK, component: FieldText, label: i18n.translate('xpack.synthetics.monitorConfig.responseReceiveCheck.label', { - defaultMessage: 'Check response contains', + defaultMessage: 'Response contains', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseReceiveCheck.helpText', { defaultMessage: 'The expected remote host response.', @@ -986,7 +992,11 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Verifies that the provided certificate is signed by a trusted authority (CA) and also verifies that the server’s hostname (or IP address) matches the names identified within the certificate. If the Subject Alternative Name is empty, it returns an error.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiSelectProps => ({ options: Object.values(VerificationMode).map((method) => ({ value: method, @@ -1002,7 +1012,11 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Supported TLS protocols', }), controlled: true, - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: ({ field, setValue }): EuiComboBoxProps => { return { options: Object.values(TLSVersion).map((version) => ({ @@ -1023,42 +1037,54 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }, [ConfigKey.TLS_CERTIFICATE_AUTHORITIES]: { fieldKey: ConfigKey.TLS_CERTIFICATE_AUTHORITIES, - component: EuiTextArea, + component: TextArea, label: i18n.translate('xpack.synthetics.monitorConfig.certificateAuthorities.label', { defaultMessage: 'Certificate authorities', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.certificateAuthorities.helpText', { defaultMessage: 'PEM-formatted custom certificate authorities.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiTextAreaProps => ({ readOnly, }), }, [ConfigKey.TLS_CERTIFICATE]: { fieldKey: ConfigKey.TLS_CERTIFICATE, - component: EuiTextArea, + component: TextArea, label: i18n.translate('xpack.synthetics.monitorConfig.clientCertificate.label', { defaultMessage: 'Client certificate', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.clientCertificate.helpText', { defaultMessage: 'PEM-formatted certificate for TLS client authentication.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiTextAreaProps => ({ readOnly, }), }, [ConfigKey.TLS_KEY]: { fieldKey: ConfigKey.TLS_KEY, - component: EuiTextArea, + component: TextArea, label: i18n.translate('xpack.synthetics.monitorConfig.clientKey.label', { defaultMessage: 'Client key', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.clientKey.helpText', { defaultMessage: 'PEM-formatted certificate key for TLS client authentication.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiTextAreaProps => ({ readOnly, }), @@ -1072,7 +1098,11 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ helpText: i18n.translate('xpack.synthetics.monitorConfig.clientKeyPassphrase.helpText', { defaultMessage: 'Certificate key passphrase for TLS client authentication.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiFieldPasswordProps => ({ readOnly, }), @@ -1252,4 +1282,165 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ isDisabled: readOnly, }), }, + [ConfigKey.MODE]: { + fieldKey: ConfigKey.MODE, + component: Select, + label: i18n.translate('xpack.synthetics.monitorConfig.mode.label', { + defaultMessage: 'Mode', + }), + helpText: ( + all, + any: any, + }} + /> + ), + props: (): EuiSelectProps => ({ + options: Object.values(Mode).map((value) => ({ + value, + text: value, + })), + disabled: readOnly, + }), + }, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: { + fieldKey: ConfigKey.RESPONSE_BODY_MAX_BYTES, + component: FieldNumber, + label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyMaxBytes.label', { + defaultMessage: 'Response body max bytes', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyMaxBytes.helpText', { + defaultMessage: 'Controls the maximum size of the stored body contents.', + }), + hidden: (dependencies) => { + const [responseBodyIndex] = dependencies || []; + return responseBodyIndex === ResponseBodyIndexPolicy.NEVER; + }, + props: (): EuiFieldNumberProps => ({ min: 1, step: 'any', readOnly }), + dependencies: [ConfigKey.RESPONSE_BODY_INDEX], + }, + [ConfigKey.IPV4]: { + fieldKey: ConfigKey.IPV4, // also controls ipv6 + component: ComboBox, + label: i18n.translate('xpack.synthetics.monitorConfig.ipv4.label', { + defaultMessage: 'IP protocols', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.ipv4.helpText', { + defaultMessage: 'IP protocols to use when pinging the remote host.', + }), + controlled: true, + dependencies: [ConfigKey.IPV6], + props: ({ field, setValue, dependencies }): EuiComboBoxProps => { + const [ipv6] = dependencies; + const ipv4 = field?.value; + const values: string[] = []; + if (ipv4) { + values.push('IPv4'); + } + if (ipv6) { + values.push('IPv6'); + } + return { + options: [ + { + label: 'IPv4', + }, + { + label: 'IPv6', + }, + ], + selectedOptions: values.map((version) => ({ + label: version, + })), + onChange: (updatedValues: Array>) => { + setValue( + ConfigKey.IPV4, + updatedValues.some((value) => value.label === 'IPv4') + ); + setValue( + ConfigKey.IPV6, + updatedValues.some((value) => value.label === 'IPv6') + ); + }, + isDisabled: readOnly, + }; + }, + }, + [ConfigKey.PROXY_HEADERS]: { + fieldKey: ConfigKey.PROXY_HEADERS, + component: HeaderField, + label: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.label', { + defaultMessage: 'Proxy headers', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.helpText', { + defaultMessage: 'Additional headers to send to proxies for CONNECT requests.', + }), + controlled: true, + validation: () => ({ + validate: (headers) => !validateHeaders(headers), + }), + error: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.error', { + defaultMessage: 'The header key must be a valid HTTP token.', + }), + props: (): HeaderFieldProps => ({ + readOnly, + }), + }, + ['check.response.json']: { + fieldKey: ConfigKey.RESPONSE_JSON_CHECK, + component: KeyValuePairsField, + label: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.label', { + defaultMessage: 'Response body contains JSON', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.helpText', { + defaultMessage: + 'A list of expressions executed against the body when parsed as JSON. The body size must be less than or equal to 100 MiB.', + }), + controlled: true, + props: ({ field, setValue }): KeyValuePairsFieldProps => ({ + readOnly, + keyLabel: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.key.label', { + defaultMessage: 'Description', + }), + valueLabel: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.value.label', { + defaultMessage: 'Expression', + }), + addPairControlLabel: i18n.translate( + 'xpack.synthetics.monitorConfig.responseJSON.addPair.label', + { + defaultMessage: 'Add expression', + } + ), + onChange: (pairs) => { + const value: ResponseCheckJSON[] = pairs + .map((pair) => { + const [description, expression] = pair; + return { + description, + expression, + }; + }) + .filter((pair) => pair.description || pair.expression); + if (!isEqual(value, field?.value)) { + setValue(ConfigKey.RESPONSE_JSON_CHECK, value); + } + }, + defaultPairs: field?.value.map((check) => [check.description, check.expression]) || [], + }), + validation: () => { + return { + validate: (value: ResponseCheckJSON[]) => { + if (value.some((check) => !check.expression || !check.description)) { + return i18n.translate('xpack.synthetics.monitorConfig.responseJSON.error', { + defaultMessage: + "This JSON expression isn't valid. Make sure that both the label and expression are defined.", + }); + } + }, + }; + }, + }, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx index 27a8a3ee5ab4d..b2ae5b290aecc 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx @@ -25,6 +25,8 @@ import { EuiButtonGroupProps, EuiComboBox, EuiComboBoxProps, + EuiTextArea, + EuiTextAreaProps, } from '@elastic/eui'; import { ThrottlingConfigField, @@ -47,6 +49,10 @@ import { HeaderField as DefaultHeaderField, HeaderFieldProps as DefaultHeaderFieldProps, } from '../fields/header_field'; +import { + KeyValuePairsField as DefaultKeyValuePairsField, + KeyValuePairsFieldProps as DefaultKeyValuePairsFieldProps, +} from '../fields/key_value_field'; import { RequestBodyField as DefaultRequestBodyField, RequestBodyFieldProps as DefaultRequestBodyFieldProps, @@ -81,6 +87,10 @@ export const FieldText = React.forwardRef( ) ); +export const TextArea = React.forwardRef((props, ref) => ( + +)); + export const FieldNumber = React.forwardRef((props, ref) => ( )); @@ -129,6 +139,10 @@ export const HeaderField = React.forwardRef((p )); +export const KeyValuePairsField = React.forwardRef( + (props, _ref) => +); + export const RequestBodyField = React.forwardRef( (props, _ref) => ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx index 46a482512413a..8e74162ecef77 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx @@ -39,10 +39,13 @@ const HTTP_ADVANCED = (readOnly: boolean) => ({ components: [ FIELD(readOnly)[ConfigKey.USERNAME], FIELD(readOnly)[ConfigKey.PASSWORD], - FIELD(readOnly)[ConfigKey.PROXY_URL], FIELD(readOnly)[ConfigKey.REQUEST_METHOD_CHECK], FIELD(readOnly)[ConfigKey.REQUEST_HEADERS_CHECK], FIELD(readOnly)[ConfigKey.REQUEST_BODY_CHECK], + FIELD(readOnly)[ConfigKey.PROXY_URL], + FIELD(readOnly)[ConfigKey.PROXY_HEADERS], + FIELD(readOnly)[ConfigKey.MODE], + FIELD(readOnly)[ConfigKey.IPV4], ], }, responseConfig: { @@ -58,6 +61,7 @@ const HTTP_ADVANCED = (readOnly: boolean) => ({ components: [ FIELD(readOnly)[ConfigKey.RESPONSE_HEADERS_INDEX], FIELD(readOnly)[ConfigKey.RESPONSE_BODY_INDEX], + FIELD(readOnly)[ConfigKey.RESPONSE_BODY_MAX_BYTES], ], }, responseChecks: { @@ -75,6 +79,7 @@ const HTTP_ADVANCED = (readOnly: boolean) => ({ FIELD(readOnly)[ConfigKey.RESPONSE_HEADERS_CHECK], FIELD(readOnly)[ConfigKey.RESPONSE_BODY_CHECK_POSITIVE], FIELD(readOnly)[ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE], + FIELD(readOnly)[ConfigKey.RESPONSE_JSON_CHECK], ], }, }); @@ -93,6 +98,8 @@ export const TCP_ADVANCED = (readOnly: boolean) => ({ components: [ FIELD(readOnly)[`${ConfigKey.PROXY_URL}__tcp`], FIELD(readOnly)[ConfigKey.REQUEST_SEND_CHECK], + FIELD(readOnly)[ConfigKey.MODE], + FIELD(readOnly)[ConfigKey.IPV4], ], }, responseChecks: { @@ -109,6 +116,21 @@ export const TCP_ADVANCED = (readOnly: boolean) => ({ }, }); +export const ICMP_ADVANCED = (readOnly: boolean) => ({ + requestConfig: { + title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfigICMP.title', { + defaultMessage: 'Request configuration', + }), + description: i18n.translate( + 'xpack.synthetics.monitorConfig.section.requestConfigICMP.description', + { + defaultMessage: 'Configure the payload sent to the remote host.', + } + ), + components: [FIELD(readOnly)[ConfigKey.MODE], FIELD(readOnly)[ConfigKey.IPV4]], + }, +}); + export const BROWSER_ADVANCED = (readOnly: boolean) => [ { title: i18n.translate('xpack.synthetics.monitorConfig.section.syntAgentOptions.title', { @@ -264,6 +286,6 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({ FIELD(readOnly)[ConfigKey.ENABLED], FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED], ], - advanced: [DEFAULT_DATA_OPTIONS(readOnly)], + advanced: [DEFAULT_DATA_OPTIONS(readOnly), ICMP_ADVANCED(readOnly).requestConfig], }, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts index b3b86eef542fe..6abe63786563e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts @@ -17,6 +17,7 @@ import { ServiceLocation, FormMonitorType, MonitorFields, + ResponseCheckJSON, } from '../../../../../common/runtime_types/monitor_management'; import { AlertConfigKey } from './constants'; @@ -55,6 +56,11 @@ export type FormConfig = MonitorFields & { ssl: { supported_protocols: MonitorFields[ConfigKey.TLS_VERSION]; }; + check: { + response: { + json: ResponseCheckJSON[]; + }; + }; }; export interface FieldMeta { @@ -63,6 +69,7 @@ export interface FieldMeta { label?: string; ariaLabel?: string; helpText?: string | React.ReactNode; + hidden?: (depenencies: unknown[]) => boolean; props?: (params: { field?: ControllerRenderProps; formState: FormState; @@ -88,7 +95,6 @@ export interface FieldMeta { event: React.ChangeEvent, formOnChange: (event: React.ChangeEvent) => void ) => void; - showWhen?: [keyof FormConfig, any]; // show field when another field equals an arbitrary value validation?: (dependencies: unknown[]) => Parameters[1]; error?: React.ReactNode; dependencies?: Array; // fields that another field may depend for or validation. Values are passed to the validation function @@ -123,16 +129,19 @@ export interface FieldMap { [ConfigKey.USERNAME]: FieldMeta; [ConfigKey.PASSWORD]: FieldMeta; [ConfigKey.PROXY_URL]: FieldMeta; + [ConfigKey.PROXY_HEADERS]: FieldMeta; ['proxy_url__tcp']: FieldMeta; [ConfigKey.REQUEST_METHOD_CHECK]: FieldMeta; [ConfigKey.REQUEST_HEADERS_CHECK]: FieldMeta; [ConfigKey.REQUEST_BODY_CHECK]: FieldMeta; [ConfigKey.RESPONSE_HEADERS_INDEX]: FieldMeta; [ConfigKey.RESPONSE_BODY_INDEX]: FieldMeta; + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: FieldMeta; [ConfigKey.RESPONSE_STATUS_CHECK]: FieldMeta; [ConfigKey.RESPONSE_HEADERS_CHECK]: FieldMeta; [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: FieldMeta; [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: FieldMeta; + [ConfigKey.RESPONSE_JSON_CHECK]: FieldMeta; [ConfigKey.RESPONSE_RECEIVE_CHECK]: FieldMeta; [ConfigKey.REQUEST_SEND_CHECK]: FieldMeta; ['source.inline']: FieldMeta; @@ -142,4 +151,6 @@ export interface FieldMap { [ConfigKey.PLAYWRIGHT_OPTIONS]: FieldMeta; [ConfigKey.SYNTHETICS_ARGS]: FieldMeta; [ConfigKey.IGNORE_HTTPS_ERRORS]: FieldMeta; + [ConfigKey.MODE]: FieldMeta; + [ConfigKey.IPV4]: FieldMeta; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx index 85ab6773033be..7ca1d1e003cc4 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx @@ -42,7 +42,7 @@ export const DurationPanel = (props: DurationPanelProps) => { attributes={[ { time: props, - name: AVG_DURATION_LABEL, + name: MEDIAN_DURATION_LABEL, dataType: 'synthetics', selectedMetricField: 'monitor_duration', reportDefinitions: { @@ -55,9 +55,9 @@ export const DurationPanel = (props: DurationPanelProps) => { ); }; -export const AVG_DURATION_LABEL = i18n.translate( - 'xpack.synthetics.monitorDetails.summary.avgDuration', +export const MEDIAN_DURATION_LABEL = i18n.translate( + 'xpack.synthetics.monitorDetails.summary.medianDuration', { - defaultMessage: 'Avg. duration', + defaultMessage: 'Median duration', } ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx index 1c1370d4da3ab..5851d1c47cdf5 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ReportTypes } from '@kbn/exploratory-view-plugin/public'; import { useTheme } from '@kbn/observability-plugin/public'; -import { AVG_DURATION_LABEL } from './duration_panel'; +import { MEDIAN_DURATION_LABEL } from './duration_panel'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; import { ClientPluginsStart } from '../../../../../plugin'; import { useSelectedLocation } from '../hooks/use_selected_location'; @@ -47,7 +47,7 @@ export const DurationSparklines = (props: DurationSparklinesProps) => { { seriesType: 'area', time: props, - name: AVG_DURATION_LABEL, + name: MEDIAN_DURATION_LABEL, dataType: 'synthetics', selectedMetricField: 'monitor.duration.us', reportDefinitions: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx index 88cfaf75e061c..369010408917c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts'; -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiIconTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DARK_THEME } from '@elastic/charts'; import { useTheme } from '@kbn/observability-plugin/public'; import { useDispatch, useSelector } from 'react-redux'; @@ -46,13 +46,19 @@ export const getColor = ( export const MetricItem = ({ monitor, - averageDuration, + medianDuration, + maxDuration, + minDuration, + avgDuration, data, onClick, }: { monitor: MonitorOverviewItem; data: Array<{ x: number; y: number }>; - averageDuration: number; + medianDuration: number; + avgDuration: number; + minDuration: number; + maxDuration: number; onClick: (params: { id: string; configId: string; location: string; locationId: string }) => void; }) => { const [isMouseOver, setIsMouseOver] = useState(false); @@ -119,15 +125,42 @@ export const MetricItem = ({ { title: monitor.name, subtitle: locationName, - value: averageDuration, + value: medianDuration, trendShape: MetricTrendShape.Area, trend: data, extra: ( - - {i18n.translate('xpack.synthetics.overview.duration.label', { - defaultMessage: 'Duration Avg.', - })} - + + + {i18n.translate('xpack.synthetics.overview.duration.label', { + defaultMessage: 'Duration', + })} + + + + + ), valueFormatter: (d: number) => formatDuration(d), color: getColor(theme, monitor.isEnabled, status), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx index 8238d7b26b62a..ed470f6f24bce 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx @@ -64,9 +64,14 @@ describe('Overview Grid', () => { const perPage = 20; it('renders correctly', async () => { - jest - .spyOn(hooks, 'useLast50DurationChart') - .mockReturnValue({ data: getMockChart(), averageDuration: 30000, loading: false }); + jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ + data: getMockChart(), + avgDuration: 30000, + minDuration: 0, + maxDuration: 50000, + medianDuration: 15000, + loading: false, + }); const { getByText, getAllByTestId, queryByText } = render(, { state: { @@ -124,9 +129,14 @@ describe('Overview Grid', () => { }); it('displays showing all monitors label when reaching the end of the list', async () => { - jest - .spyOn(hooks, 'useLast50DurationChart') - .mockReturnValue({ data: getMockChart(), averageDuration: 30000, loading: false }); + jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ + data: getMockChart(), + avgDuration: 30000, + minDuration: 0, + maxDuration: 50000, + medianDuration: 15000, + loading: false, + }); const { getByText } = render(, { state: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx index de153cf01eca7..952a48d424733 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx @@ -32,12 +32,20 @@ export const OverviewGridItem = ({ const { timestamp } = useStatusByLocationOverview(monitor.configId, locationName); - const { data, averageDuration } = useLast50DurationChart({ + const { data, medianDuration, maxDuration, avgDuration, minDuration } = useLast50DurationChart({ locationId: monitor.location?.id, monitorId: monitor.id, timestamp, }); return ( - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts index bb2e74712322f..47cb97793bab1 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts @@ -33,7 +33,10 @@ describe('useLast50DurationChart', () => { { wrapper: WrappedHelper } ); expect(result.current).toEqual({ - averageDuration: 4.5, + medianDuration: 5, + maxDuration: 9, + minDuration: 0, + avgDuration: 4.5, data: [ { x: 0, @@ -132,7 +135,10 @@ describe('useLast50DurationChart', () => { ]; expect(result.current).toEqual({ - averageDuration: data.reduce((acc, datum) => (acc += datum.y), 0) / 9, + medianDuration: [...data].sort((a, b) => a.y - b.y)[Math.floor(data.length / 2)].y, + maxDuration: 9, + minDuration: 0, + avgDuration: 4.4, data, loading: false, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts index 78e4cc6cecbbf..8cb7d524635c7 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts @@ -28,20 +28,23 @@ export function useLast50DurationChart({ size: 50, timestamp, }); - const { data, averageDuration } = useMemo(() => { + const { data, median, min, max, avg } = useMemo(() => { if (loading) { return { data: [], - averageDuration: 0, + median: 0, + avg: 0, + min: 0, + max: 0, }; } - let totalDuration = 0; + + // calculate min, max, average duration and median const coords = hits .reverse() // results are returned in desc order by timestamp. Reverse to ensure the data is in asc order by timestamp .map((hit, index) => { const duration = hit?.['monitor.duration.us']?.[0]; - totalDuration += duration || 0; if (duration === undefined) { return null; } @@ -52,18 +55,30 @@ export function useLast50DurationChart({ }) .filter((item) => item !== null); + const sortedByDuration = [...hits].sort( + (a, b) => (a?.['monitor.duration.us']?.[0] || 0) - (b?.['monitor.duration.us']?.[0] || 0) + ); + return { data: coords as Array<{ x: number; y: number }>, - averageDuration: totalDuration / coords.length, + median: sortedByDuration[Math.floor(hits.length / 2)]?.['monitor.duration.us']?.[0] || 0, + avg: + sortedByDuration.reduce((acc, curr) => acc + (curr?.['monitor.duration.us']?.[0] || 0), 0) / + hits.length, + min: sortedByDuration[0]?.['monitor.duration.us']?.[0] || 0, + max: sortedByDuration[sortedByDuration.length - 1]?.['monitor.duration.us']?.[0] || 0, }; }, [hits, loading]); return useMemo( () => ({ data, - averageDuration, + medianDuration: median, + avgDuration: avg, + minDuration: min, + maxDuration: max, loading, }), - [loading, data, averageDuration] + [data, median, avg, min, max, loading] ); } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts index 5dab17f55ad68..433d4a9c8cfec 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts @@ -25,8 +25,14 @@ export const microsToMillis = (microseconds: number | null): number | null => { return Math.floor(microseconds / NUM_MICROSECONDS_IN_MILLISECOND); }; -export const formatDuration = (durationMicros: number) => { +export const formatDuration = (durationMicros: number, { noSpace }: { noSpace?: true } = {}) => { if (durationMicros < MILLIS_LIMIT) { + if (noSpace) { + return i18n.translate('xpack.synthetics.overview.durationMsFormattingNoSpace', { + values: { millis: microsToMillis(durationMicros) }, + defaultMessage: '{millis}ms', + }); + } return i18n.translate('xpack.synthetics.overview.durationMsFormatting', { values: { millis: microsToMillis(durationMicros) }, defaultMessage: '{millis} ms', @@ -34,6 +40,13 @@ export const formatDuration = (durationMicros: number) => { } const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); + if (noSpace) { + return i18n.translate('xpack.synthetics.overview.durationSecondsFormattingNoSpace', { + values: { seconds }, + defaultMessage: '{seconds}s', + }); + } + return i18n.translate('xpack.synthetics.overview.durationSecondsFormatting', { values: { seconds }, defaultMessage: '{seconds} s', diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts index 426984969c191..9aec3104fc2bf 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts @@ -105,7 +105,7 @@ const getNearestSupportedSchedule = (currentSchedule: string): string => { return closest; } catch { - return ALLOWED_SCHEDULES_IN_MINUTES[0]; + return '10'; } }; diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts index 12fe8306d1731..7c2498242b35c 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts @@ -9,6 +9,7 @@ import { BrowserAdvancedFields, BrowserFields, BrowserSimpleFields, + CodeEditorMode, CommonFields, ConfigKey, DataStream, @@ -18,7 +19,6 @@ import { HTTPSimpleFields, ICMPSimpleFields, Metadata, - Mode, MonitorFields, ResponseBodyIndexPolicy, ScheduleUnit, @@ -142,7 +142,7 @@ describe('validateMonitor', () => { [ConfigKey.RESPONSE_HEADERS_CHECK]: {}, [ConfigKey.RESPONSE_HEADERS_INDEX]: true, [ConfigKey.RESPONSE_STATUS_CHECK]: ['200', '201'], - [ConfigKey.REQUEST_BODY_CHECK]: { value: 'testValue', type: Mode.JSON }, + [ConfigKey.REQUEST_BODY_CHECK]: { value: 'testValue', type: CodeEditorMode.JSON }, [ConfigKey.REQUEST_HEADERS_CHECK]: {}, [ConfigKey.REQUEST_METHOD_CHECK]: '', [ConfigKey.USERNAME]: 'test-username', diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts index e5ff19ddaf1c0..921fd737e5d3e 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts @@ -14,7 +14,7 @@ import { import { ConfigKey, DataStream, - Mode, + CodeEditorMode, MonitorFields, ResponseBodyIndexPolicy, ScheduleUnit, @@ -39,11 +39,19 @@ const testHTTPConfig: Partial = { proxy_url: '${proxyUrl}', 'check.response.body.negative': [], 'check.response.body.positive': [], + 'check.response.json': [ + { + description: 'test description', + expression: 'foo.bar == "myValue"', + }, + ], 'response.include_body': 'on_error' as ResponseBodyIndexPolicy, - 'check.response.headers': {}, + 'check.response.headers': { + 'test-header': 'test-value', + }, 'response.include_headers': true, 'check.response.status': [], - 'check.request.body': { type: 'text' as Mode, value: '' }, + 'check.request.body': { type: 'text' as CodeEditorMode, value: '' }, 'check.request.headers': {}, 'check.request.method': 'GET', 'ssl.verification_mode': VerificationMode.NONE, @@ -99,6 +107,15 @@ describe('formatMonitorConfig', () => { expect(yamlConfig).toEqual({ 'check.request.method': 'GET', + 'check.response.headers': { + 'test-header': 'test-value', + }, + 'check.response.json': [ + { + description: 'test description', + expression: 'foo.bar == "myValue"', + }, + ], enabled: true, locations: [], max_redirects: '0', @@ -129,6 +146,15 @@ describe('formatMonitorConfig', () => { expect(yamlConfig).toEqual({ 'check.request.method': 'GET', + 'check.response.headers': { + 'test-header': 'test-value', + }, + 'check.response.json': [ + { + description: 'test description', + expression: 'foo.bar == "myValue"', + }, + ], enabled: true, locations: [], max_redirects: '0', diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts index 43c1a5e76ea70..4f65e7546d966 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts @@ -17,10 +17,12 @@ export const httpFormatters: HTTPFormatMap = { [ConfigKey.METADATA]: objectFormatter, [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: arrayFormatter, [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: arrayFormatter, + [ConfigKey.RESPONSE_JSON_CHECK]: arrayFormatter, [ConfigKey.RESPONSE_HEADERS_CHECK]: objectFormatter, [ConfigKey.RESPONSE_STATUS_CHECK]: arrayFormatter, [ConfigKey.REQUEST_HEADERS_CHECK]: objectFormatter, [ConfigKey.REQUEST_BODY_CHECK]: (fields) => fields[ConfigKey.REQUEST_BODY_CHECK]?.value || null, + [ConfigKey.PROXY_HEADERS]: objectFormatter, ...tlsFormatters, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts index cd7ac7a26f6dd..0045cc711f9bc 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts @@ -7,11 +7,11 @@ import { get } from 'lodash'; import { DEFAULT_FIELDS } from '../../../../common/constants/monitor_defaults'; import { + CodeEditorMode, ConfigKey, DataStream, FormMonitorType, HTTPFields, - Mode, TLSVersion, } from '../../../../common/runtime_types/monitor_management'; import { @@ -70,6 +70,11 @@ export const getNormalizeHTTPFields = ({ (yamlConfig as Record)[ConfigKey.REQUEST_BODY_CHECK] as string, defaultFields[ConfigKey.REQUEST_BODY_CHECK] ), + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: `${get( + yamlConfig, + ConfigKey.RESPONSE_BODY_MAX_BYTES, + defaultFields[ConfigKey.RESPONSE_BODY_MAX_BYTES] + )}`, [ConfigKey.TLS_VERSION]: get(monitor, ConfigKey.TLS_VERSION) ? (getOptionalListField(get(monitor, ConfigKey.TLS_VERSION)) as TLSVersion[]) : defaultFields[ConfigKey.TLS_VERSION], @@ -94,14 +99,14 @@ export const getRequestBodyField = ( defaultValue: HTTPFields[ConfigKey.REQUEST_BODY_CHECK] ): HTTPFields[ConfigKey.REQUEST_BODY_CHECK] => { let parsedValue: string; - let type: Mode; + let type: CodeEditorMode; if (typeof value === 'object') { parsedValue = JSON.stringify(value); - type = Mode.JSON; + type = CodeEditorMode.JSON; } else { parsedValue = value; - type = Mode.PLAINTEXT; + type = CodeEditorMode.PLAINTEXT; } return { type, diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 62a88a51b7b27..3922c32df2181 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -6,18 +6,21 @@ */ import { useContext } from 'react'; +import { of } from 'rxjs'; +import type { + IKibanaSearchResponse, + IKibanaSearchRequest, + ISearchGeneric, +} from '@kbn/data-plugin/public'; import type { ScopedHistory } from '@kbn/core/public'; - import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; - import { SharePluginStart } from '@kbn/share-plugin/public'; - import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; @@ -35,6 +38,36 @@ const dataViewsStart = dataViewPluginMocks.createStartContract(); // Replace mock to support syntax using `.then()` as used in transform code. coreStart.savedObjects.client.find = jest.fn().mockResolvedValue({ savedObjects: [] }); +// Replace mock to support tests for `use_index_data`. +dataStart.search.search = jest.fn(({ params }: IKibanaSearchRequest) => { + const hits = []; + + // simulate a cross cluster search result + // against a cluster that doesn't support fields + if (params.index.includes(':')) { + hits.push({ + _id: 'the-doc', + _index: 'the-index', + }); + } + + return of({ + rawResponse: { + hits: { + hits, + total: { + value: 0, + relation: 'eq', + }, + max_score: 0, + }, + timed_out: false, + took: 10, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, + }, + }); +}) as ISearchGeneric; + const appDependencies: AppDependencies = { application: coreStart.application, charts: chartPluginMock.createStartContract(), diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 61e6baf5c250e..41564d393913c 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import type { IHttpFetchError } from '@kbn/core-http-browser'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; @@ -135,32 +133,6 @@ const apiFactory = () => ({ ): Promise { return Promise.resolve({ messages: [], total: 0 }); }, - async esSearch(payload: any): Promise { - const hits = []; - - // simulate a cross cluster search result - // against a cluster that doesn't support fields - if (payload.index.includes(':')) { - hits.push({ - _id: 'the-doc', - _index: 'the-index', - }); - } - - return Promise.resolve({ - hits: { - hits, - total: { - value: 0, - relation: 'eq', - }, - max_score: 0, - }, - timed_out: false, - took: 10, - _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, - }); - }, async getEsIndices(): Promise { return Promise.resolve([]); diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 3364ed58d5af6..3b39d39a3a7bc 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -7,8 +7,6 @@ import { useMemo } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import type { IHttpFetchError } from '@kbn/core-http-browser'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; @@ -244,13 +242,6 @@ export const useApi = () => { return e; } }, - async esSearch(payload: any): Promise { - try { - return await http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); - } catch (e) { - return e; - } - }, async getEsIndices(): Promise { try { return await http.get(`/api/index_management/indices`); diff --git a/x-pack/plugins/transform/public/app/hooks/use_data_search.ts b/x-pack/plugins/transform/public/app/hooks/use_data_search.ts new file mode 100644 index 0000000000000..af4bb440f9e24 --- /dev/null +++ b/x-pack/plugins/transform/public/app/hooks/use_data_search.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 { useCallback } from 'react'; +import { lastValueFrom } from 'rxjs'; + +import type { IKibanaSearchRequest } from '@kbn/data-plugin/common'; + +import { useAppDependencies } from '../app_dependencies'; + +export const useDataSearch = () => { + const { data } = useAppDependencies(); + + return useCallback( + async (esSearchRequestParams: IKibanaSearchRequest['params'], abortSignal?: AbortSignal) => { + try { + const { rawResponse: resp } = await lastValueFrom( + data.search.search( + { + params: esSearchRequestParams, + }, + { abortSignal } + ) + ); + + return resp; + } catch (error) { + if (error.name === 'AbortError') { + // ignore abort errors + } else { + return error; + } + } + }, + [data] + ); +}; diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index f4d10b5562001..97005c11c3661 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -33,6 +33,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; +import { useDataSearch } from './use_data_search'; export const useIndexData = ( dataView: SearchItems['dataView'], @@ -43,6 +44,7 @@ export const useIndexData = ( const indexPattern = useMemo(() => dataView.getIndexPattern(), [dataView]); const api = useApi(); + const dataSearch = useDataSearch(); const toastNotifications = useToastNotifications(); const { ml: { @@ -78,56 +80,62 @@ export const useIndexData = ( }, }; - // Fetch 500 random documents to determine populated fields. - // This is a workaround to avoid passing potentially thousands of unpopulated fields - // (for example, as part of filebeat/metricbeat/ECS based indices) - // to the data grid component which would significantly slow down the page. - const fetchDataGridSampleDocuments = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const esSearchRequest = { - index: indexPattern, - body: { - fields: ['*'], - _source: false, - query: { - function_score: { - query: defaultQuery, - random_score: {}, + useEffect(() => { + const abortController = new AbortController(); + + // Fetch 500 random documents to determine populated fields. + // This is a workaround to avoid passing potentially thousands of unpopulated fields + // (for example, as part of filebeat/metricbeat/ECS based indices) + // to the data grid component which would significantly slow down the page. + const fetchDataGridSampleDocuments = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const esSearchRequest = { + index: indexPattern, + body: { + fields: ['*'], + _source: false, + query: { + function_score: { + query: defaultQuery, + random_score: {}, + }, }, + size: 500, }, - size: 500, - }, - }; + }; - const resp = await api.esSearch(esSearchRequest); + const resp = await dataSearch(esSearchRequest, abortController.signal); - if (!isEsSearchResponse(resp)) { - setErrorMessage(getErrorMessage(resp)); - setStatus(INDEX_STATUS.ERROR); - return; - } + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); + setStatus(INDEX_STATUS.ERROR); + return; + } - const isCrossClusterSearch = indexPattern.includes(':'); - const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + const isCrossClusterSearch = indexPattern.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - // Get all field names for each returned doc and flatten it - // to a list of unique field names used across all docs. - const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); - const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allDataViewFields.includes(d)) - .sort(); + // Get all field names for each returned doc and flatten it + // to a list of unique field names used across all docs. + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); + const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] + .filter((d) => allDataViewFields.includes(d)) + .sort(); - setCcsWarning(isCrossClusterSearch && isMissingFields); - setStatus(INDEX_STATUS.LOADED); - setDataViewFields(populatedFields); - }; + setCcsWarning(isCrossClusterSearch && isMissingFields); + setStatus(INDEX_STATUS.LOADED); + setDataViewFields(populatedFields); + }; - useEffect(() => { fetchDataGridSampleDocuments(); + + return () => { + abortController.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [timeRangeMs]); @@ -190,96 +198,62 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify([query, timeRangeMs])]); - const fetchDataGridData = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const sort: EsSorting = sortingColumns.reduce((s, column) => { - s[column.id] = { order: column.direction }; - return s; - }, {} as EsSorting); - - const esSearchRequest = { - index: indexPattern, - body: { - fields: ['*'], - _source: false, - query: isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, - from: pagination.pageIndex * pagination.pageSize, - size: pagination.pageSize, - ...(Object.keys(sort).length > 0 ? { sort } : {}), - ...(isRuntimeMappings(combinedRuntimeMappings) - ? { runtime_mappings: combinedRuntimeMappings } - : {}), - }, - }; - const resp = await api.esSearch(esSearchRequest); - - if (!isEsSearchResponse(resp)) { - setErrorMessage(getErrorMessage(resp)); - setStatus(INDEX_STATUS.ERROR); - return; - } - - const isCrossClusterSearch = indexPattern.includes(':'); - const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + useEffect(() => { + const abortController = new AbortController(); + + const fetchDataGridData = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const sort: EsSorting = sortingColumns.reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const esSearchRequest = { + index: indexPattern, + body: { + fields: ['*'], + _source: false, + query: isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + ...(isRuntimeMappings(combinedRuntimeMappings) + ? { runtime_mappings: combinedRuntimeMappings } + : {}), + }, + }; + const resp = await dataSearch(esSearchRequest, abortController.signal); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); + setStatus(INDEX_STATUS.ERROR); + return; + } - setCcsWarning(isCrossClusterSearch && isMissingFields); - setRowCountInfo({ - rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, - rowCountRelation: - typeof resp.hits.total === 'number' - ? ('eq' as estypes.SearchTotalHitsRelation) - : resp.hits.total!.relation, - }); - setTableItems(docs); - setStatus(INDEX_STATUS.LOADED); - }; + const isCrossClusterSearch = indexPattern.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); - const fetchColumnChartsData = async function () { - const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); - const columnChartsData = await api.getHistogramsForFields( - indexPattern, - columns - .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) - .map((cT) => { - // If a column field name has a corresponding keyword field, - // fetch the keyword field instead to be able to do aggregations. - const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allDataViewFieldNames) - ? { - fieldName: `${fieldName}.keyword`, - type: getFieldType(undefined), - } - : { - fieldName, - type: getFieldType(cT.schema), - }; - }), - isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, - combinedRuntimeMappings - ); - - if (!isFieldHistogramsResponseSchema(columnChartsData)) { - showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); - return; - } + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - setColumnCharts( - // revert field names with `.keyword` used to do aggregations to their original column name - columnChartsData.map((d) => ({ - ...d, - ...(isKeywordDuplicate(d.id, allDataViewFieldNames) - ? { id: removeKeywordPostfix(d.id) } - : {}), - })) - ); - }; + setCcsWarning(isCrossClusterSearch && isMissingFields); + setRowCountInfo({ + rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, + rowCountRelation: + typeof resp.hits.total === 'number' + ? ('eq' as estypes.SearchTotalHitsRelation) + : resp.hits.total!.relation, + }); + setTableItems(docs); + setStatus(INDEX_STATUS.LOADED); + }; - useEffect(() => { fetchDataGridData(); + + return () => { + abortController.abort(); + }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -296,6 +270,46 @@ export const useIndexData = ( ]); useEffect(() => { + const fetchColumnChartsData = async function () { + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); + const columnChartsData = await api.getHistogramsForFields( + indexPattern, + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => { + // If a column field name has a corresponding keyword field, + // fetch the keyword field instead to be able to do aggregations. + const fieldName = cT.id; + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) + ? { + fieldName: `${fieldName}.keyword`, + type: getFieldType(undefined), + } + : { + fieldName, + type: getFieldType(cT.schema), + }; + }), + isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, + combinedRuntimeMappings + ); + + if (!isFieldHistogramsResponseSchema(columnChartsData)) { + showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); + return; + } + + setColumnCharts( + // revert field names with `.keyword` used to do aggregations to their original column name + columnChartsData.map((d) => ({ + ...d, + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) + ? { id: removeKeywordPostfix(d.id) } + : {}), + })) + ); + }; + if (chartsVisible) { fetchColumnChartsData(); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 11f9dadbb359c..6749786865083 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -5,22 +5,27 @@ * 2.0. */ +import { debounce } from 'lodash'; import React, { useCallback, useContext, useEffect, useState } from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; -import { debounce } from 'lodash'; -import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { i18n } from '@kbn/i18n'; + +import { useDataSearch } from '../../../../../../../hooks/use_data_search'; import { isEsSearchResponseWithAggregations, isMultiBucketAggregate, } from '../../../../../../../../../common/api_schemas/type_guards'; -import { useApi } from '../../../../../../../hooks'; import { CreateTransformWizardContext } from '../../../../wizard/wizard'; -import { FilterAggConfigTerm } from '../types'; import { useToastNotifications } from '../../../../../../../app_dependencies'; +import { FilterAggConfigTerm } from '../types'; + /** * Form component for the term filter aggregation. */ @@ -29,16 +34,39 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm onChange, selectedField, }) => { - const api = useApi(); const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); + const dataSearch = useDataSearch(); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [searchValue, setSearchValue] = useState(''); + + const onSearchChange = (newSearchValue: string) => { + setSearchValue(newSearchValue); + }; + + const updateConfig = useCallback( + (update) => { + onChange({ + config: { + ...config, + ...update, + }, + }); + }, + [config, onChange] + ); + + useEffect(() => { + const abortController = new AbortController(); + + const fetchOptions = debounce(async () => { + if (selectedField === undefined) return; + + setIsLoading(true); + setOptions([]); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const fetchOptions = useCallback( - debounce(async (searchValue: string) => { const esSearchRequest = { index: dataView!.title, body: { @@ -62,7 +90,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm }, }; - const response = await api.esSearch(esSearchRequest); + const response = await dataSearch(esSearchRequest, abortController.signal); setIsLoading(false); @@ -88,42 +116,21 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm .buckets as estypes.AggregationsSignificantLongTermsBucket[] ).map((value) => ({ label: value.key + '' })) ); - }, 600), - [selectedField] - ); - - const onSearchChange = useCallback( - async (searchValue) => { - if (selectedField === undefined) return; + }, 600); - setIsLoading(true); - setOptions([]); + fetchOptions(); - await fetchOptions(searchValue); - }, - [fetchOptions, selectedField] - ); - - const updateConfig = useCallback( - (update) => { - onChange({ - config: { - ...config, - ...update, - }, - }); - }, - [config, onChange] - ); - - useEffect(() => { - // Simulate initial load. - onSearchChange(''); return () => { // make sure the ongoing request is canceled fetchOptions.cancel(); + abortController.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [selectedField]); + + useEffect(() => { + // Simulate initial load. + onSearchChange(''); }, []); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index d19d9eb1a2963..36459711a7b34 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { @@ -529,33 +528,6 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { ) ); - /** - * @apiGroup Transforms - * - * @api {post} /api/transform/es_search Transform ES Search Proxy - * @apiName PostTransformEsSearchProxy - * @apiDescription ES Search Proxy - * - * @apiSchema (body) any - */ - router.post( - { - path: addBasePath('es_search'), - validate: { - body: schema.maybe(schema.any()), - }, - }, - license.guardApiRoute(async (ctx, req, res) => { - try { - const esClient = (await ctx.core).elasticsearch.client; - const body = await esClient.asCurrentUser.search(req.body, { maxRetries: 0 }); - return res.ok({ body }); - } catch (e) { - return res.customError(wrapError(wrapEsError(e))); - } - }) - ); - registerTransformsAuditMessagesRoutes(routeDependencies); registerTransformNodesRoutes(routeDependencies); } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d08257e729f6a..191f6310ace50 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7094,11 +7094,8 @@ "xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs": "Vous pouvez configurer le nom du nœud de service via {seeDocs}.", "xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions": "{versionsCount, plural, one {1 version} other {# versions}}", "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "score {value} {value, select, critical {} other {et supérieur}}", - "xpack.apm.alertTypes.errorCount.reason": "Le nombre d'erreurs est {measured} dans le dernier {interval} pour {serviceName}. Alerte lorsque > {threshold}.", "xpack.apm.alertTypes.minimumWindowSize.description": "La valeur minimale recommandée est {sizeValue} {sizeUnit}. Elle permet de s'assurer que l'alerte comporte suffisamment de données à évaluer. Si vous choisissez une valeur trop basse, l'alerte ne se déclenchera peut-être pas comme prévu.", - "xpack.apm.alertTypes.transactionDuration.reason": "La latence de {aggregationType} est {measured} dans le dernier {interval} pour {serviceName}. Alerte lorsque > {threshold}.", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "Une anomalie {severityLevel} avec un score de {measured} a été détectée dans le dernier {interval} pour {serviceName}.", - "xpack.apm.alertTypes.transactionErrorRate.reason": "L'échec des transactions est {measured} dans le dernier {interval} pour {serviceName}. Alerte lorsque > {threshold}.", "xpack.apm.anomalyDetection.createJobs.failed.text": "Une erreur est survenue lors de la création d'une ou de plusieurs tâches de détection des anomalies pour les environnements de service APM [{environments}]. Erreur : \"{errorMessage}\"", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "Tâches de détection des anomalies créées avec succès pour les environnements de service APM [{environments}]. Le démarrage de l'analyse du trafic à la recherche d'anomalies par le Machine Learning va prendre un certain temps.", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "La détection des anomalies n'est pas encore activée pour l'environnement \"{currentEnvironment}\". Cliquez pour continuer la configuration.", @@ -34809,7 +34806,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "Disponibilité", - "xpack.synthetics.monitorDetails.summary.avgDuration": "Durée moy.", "xpack.synthetics.monitorDetails.summary.brushArea": "Brosser une zone pour une plus haute fidélité", "xpack.synthetics.monitorDetails.summary.complete": "Terminé", "xpack.synthetics.monitorDetails.summary.duration": "Durée", @@ -37950,4 +37946,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1ebc1aeae602e..48521b3e3b558 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7095,11 +7095,8 @@ "xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs": "{seeDocs}を使用してサービスノード名を構成できます。", "xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions": "{versionsCount, plural, other {#個のバージョン}}", "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "スコア{value}{value, select, critical {} other {以上}}", - "xpack.apm.alertTypes.errorCount.reason": "エラーカウントは{serviceName}の最後の{interval}で{measured}です。> {threshold}のときにアラートを通知します。", "xpack.apm.alertTypes.minimumWindowSize.description": "推奨される最小値は{sizeValue} {sizeUnit}です。これにより、アラートに評価する十分なデータがあることが保証されます。低すぎる値を選択した場合、アラートが想定通りに実行されない可能性があります。", - "xpack.apm.alertTypes.transactionDuration.reason": "{serviceName}の{aggregationType}のレイテンシは{measured}で最後の{interval}で測定されます。> {threshold}のときにアラートを通知します。", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "{severityLevel}の異常が{measured}のスコアで{serviceName}の最後の{interval}で検出されました。", - "xpack.apm.alertTypes.transactionErrorRate.reason": "{serviceName}の失敗したトランザクションは最後の{interval}で{measured}されます。> {threshold}のときにアラートを通知します。", "xpack.apm.anomalyDetection.createJobs.failed.text": "APMサービス環境[{environments}]用に1つ以上の異常検知ジョブを作成しているときに問題が発生しました。エラー「{errorMessage}」", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APMサービス環境[{environments}]の異常検知ジョブが正常に作成されました。機械学習がトラフィック異常値の分析を開始するには、少し時間がかかります。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "「{currentEnvironment}」環境では、まだ異常検知が有効ではありません。クリックすると、セットアップを続行します。", @@ -34788,7 +34785,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "可用性", - "xpack.synthetics.monitorDetails.summary.avgDuration": "平均期間", "xpack.synthetics.monitorDetails.summary.brushArea": "信頼度を高めるためにエリアを精査", "xpack.synthetics.monitorDetails.summary.complete": "完了", "xpack.synthetics.monitorDetails.summary.duration": "期間", @@ -37918,4 +37914,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0c2b495e1e3fa..d3b2c66d7d642 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7095,10 +7095,8 @@ "xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs": "您可以通过 {seeDocs} 配置服务节点名称。", "xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions": "{versionsCount, plural, other {# 个版本}}", "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "分数 {value} {value, select, critical {} other {及以上}}", - "xpack.apm.alertTypes.errorCount.reason": "对于 {serviceName},过去 {interval}的错误计数为 {measured}。大于 {threshold} 时告警。", "xpack.apm.alertTypes.minimumWindowSize.description": "建议的最小值为 {sizeValue} {sizeUnit}这是为了确保告警具有足够的待评估数据。如果选择的值太小,可能无法按预期触发告警。", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "对于 {serviceName},过去 {interval}检测到分数为 {measured} 的 {severityLevel} 异常。", - "xpack.apm.alertTypes.transactionErrorRate.reason": "对于 {serviceName},过去 {interval}的失败事务数为 {measured}。大于 {threshold} 时告警。", "xpack.apm.anomalyDetection.createJobs.failed.text": "为 APM 服务环境 [{environments}] 创建一个或多个异常检测作业时出现问题。错误:“{errorMessage}”", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APM 服务环境 [{environments}] 的异常检测作业已成功创建。Machine Learning 要过一些时间才会开始分析流量以发现异常。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "尚未针对环境“{currentEnvironment}”启用异常检测。单击可继续设置。", @@ -34804,7 +34802,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "可用性", - "xpack.synthetics.monitorDetails.summary.avgDuration": "平均持续时间", "xpack.synthetics.monitorDetails.summary.brushArea": "轻刷某个区域以提高保真度", "xpack.synthetics.monitorDetails.summary.complete": "已完成", "xpack.synthetics.monitorDetails.summary.duration": "持续时间", @@ -37945,4 +37942,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 6b7ede2f01747..25feead9c518e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -228,7 +228,8 @@ export const ActionForm = ({ return; } setIsAddActionPanelOpen(false); - const actionTypeConnectors = connectors.filter( + const allowGroupConnector = (actionTypeModel?.subtype ?? []).map((atm) => atm.id); + let actionTypeConnectors = connectors.filter( (field) => field.actionTypeId === actionTypeModel.id ); @@ -241,7 +242,22 @@ export const ActionForm = ({ frequency: defaultRuleFrequency, }); setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); + } else { + actionTypeConnectors = connectors.filter((field) => + allowGroupConnector.includes(field.actionTypeId) + ); + if (actionTypeConnectors.length > 0) { + actions.push({ + id: '', + actionTypeId: actionTypeConnectors[0].actionTypeId, + group: defaultActionGroupId, + params: {}, + frequency: DEFAULT_FREQUENCY, + }); + setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); + } } + if (actionTypeConnectors.length === 0) { // if no connectors exists or all connectors is already assigned an action under current alert // set actionType as id to be able to create new connector within the alert form @@ -263,7 +279,7 @@ export const ActionForm = ({ const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); actionTypeNodes = actionTypeRegistry .list() - .filter((item) => actionTypesIndex[item.id]) + .filter((item) => actionTypesIndex[item.id] && !item.hideInUi) .filter((item) => !!item.actionParamsFields) .sort((a, b) => actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id], preconfiguredConnectors) @@ -378,6 +394,27 @@ export const ActionForm = ({ }} onSelectConnector={(connectorId: string) => { setActionIdByIndex(connectorId, index); + const newConnector = connectors.find((connector) => connector.id === connectorId); + if (newConnector && newConnector.actionTypeId) { + const actionTypeRegistered = actionTypeRegistry.get(newConnector.actionTypeId); + if (actionTypeRegistered.convertParamsBetweenGroups) { + const updatedActions = actions.map((_item: RuleAction, i: number) => { + if (i === index) { + return { + ..._item, + actionTypeId: newConnector.actionTypeId, + id: connectorId, + params: + actionTypeRegistered.convertParamsBetweenGroups != null + ? actionTypeRegistered.convertParamsBetweenGroups(_item.params) + : {}, + }; + } + return _item; + }); + setActions(updatedActions); + } + } }} /> ); @@ -407,6 +444,31 @@ export const ActionForm = ({ }} onConnectorSelected={(id: string) => { setActionIdByIndex(id, index); + const newConnector = connectors.find((connector) => connector.id === id); + if ( + newConnector && + actionConnector && + newConnector.actionTypeId !== actionConnector.actionTypeId + ) { + const actionTypeRegistered = actionTypeRegistry.get(newConnector.actionTypeId); + if (actionTypeRegistered.convertParamsBetweenGroups) { + const updatedActions = actions.map((_item: RuleAction, i: number) => { + if (i === index) { + return { + ..._item, + actionTypeId: newConnector.actionTypeId, + id, + params: + actionTypeRegistered.convertParamsBetweenGroups != null + ? actionTypeRegistered.convertParamsBetweenGroups(_item.params) + : {}, + }; + } + return _item; + }); + setActions(updatedActions); + } + } }} actionTypeRegistry={actionTypeRegistry} onDeleteAction={() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 1743d435b722f..a5222ac091800 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -308,6 +308,7 @@ export const ActionTypeForm = ({ const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); if (!actionTypeRegistered) return null; + const allowGroupConnector = (actionTypeRegistered?.subtype ?? []).map((atr) => atr.id); const showActionGroupErrorIcon = (): boolean => { return !isOpen && some(actionParamsErrors.errors, (error) => !isEmpty(error)); @@ -362,6 +363,7 @@ export const ActionTypeForm = ({ } > { }, actionConnectorFields: null, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: true, @@ -137,8 +137,8 @@ describe('connector_add_flyout', () => { }, actionConnectorFields: null, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, @@ -180,8 +180,8 @@ describe('connector_add_flyout', () => { }, actionConnectorFields: null, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, @@ -221,8 +221,8 @@ describe('connector_add_flyout', () => { actionConnectorFields: null, isExperimental: false, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, @@ -263,8 +263,8 @@ describe('connector_add_flyout', () => { actionConnectorFields: null, isExperimental: true, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 60b153badffe6..f4717bb512a0c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -22,6 +22,7 @@ interface Props { onActionTypeChange: (actionType: ActionType) => void; featureId?: string; setHasActionsUpgradeableByTrial?: (value: boolean) => void; + setAllActionTypes?: (actionsType: ActionTypeIndex) => void; actionTypeRegistry: ActionTypeRegistryContract; } @@ -29,6 +30,7 @@ export const ActionTypeMenu = ({ onActionTypeChange, featureId, setHasActionsUpgradeableByTrial, + setAllActionTypes, actionTypeRegistry, }: Props) => { const { @@ -37,7 +39,6 @@ export const ActionTypeMenu = ({ } = useKibana().services; const [loadingActionTypes, setLoadingActionTypes] = useState(false); const [actionTypesIndex, setActionTypesIndex] = useState(undefined); - useEffect(() => { (async () => { try { @@ -50,6 +51,9 @@ export const ActionTypeMenu = ({ index[actionTypeItem.id] = actionTypeItem; } setActionTypesIndex(index); + if (setAllActionTypes) { + setAllActionTypes(index); + } // determine if there are actions disabled by license that that // would be enabled by upgrading to gold or trial if (setHasActionsUpgradeableByTrial) { @@ -73,9 +77,13 @@ export const ActionTypeMenu = ({ })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const registeredActionTypes = Object.entries(actionTypesIndex ?? []) - .filter(([id, details]) => actionTypeRegistry.has(id) && details.enabledInConfig === true) + .filter( + ([id, details]) => + actionTypeRegistry.has(id) && + details.enabledInConfig === true && + !actionTypeRegistry.get(id).hideInUi + ) .map(([id, actionType]) => { const actionTypeModel = actionTypeRegistry.get(id); return { @@ -100,7 +108,9 @@ export const ActionTypeMenu = ({ title={item.name} description={item.selectMessage} isDisabled={!checkEnabledResult.isEnabled} - onClick={() => onActionTypeChange(item.actionType)} + onClick={() => { + onActionTypeChange(item.actionType); + }} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx index 530d41a4c27d1..ec5e4ef01ceec 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -65,6 +65,7 @@ export const AddConnectorInline = ({ ? actionTypesIndex[actionItem.actionTypeId].name : actionItem.actionTypeId; const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + const allowGroupConnector = (actionTypeRegistered?.subtype ?? []).map((subtype) => subtype.id); const connectorDropdownErrors = useMemo( () => [`Unable to load ${actionTypeRegistered.actionTypeTitle} connector`], [actionTypeRegistered.actionTypeTitle] @@ -90,7 +91,12 @@ export const AddConnectorInline = ({ ); useEffect(() => { - const filteredConnectors = getValidConnectors(connectors, actionItem, actionTypesIndex); + const filteredConnectors = getValidConnectors( + connectors, + actionItem, + actionTypesIndex, + allowGroupConnector + ); if (filteredConnectors.length > 0) { setHasConnectors(true); @@ -134,6 +140,7 @@ export const AddConnectorInline = ({ actionTypeRegistered={actionTypeRegistered} connectors={connectors} onConnectorSelected={onSelectConnector} + allowGroupConnector={allowGroupConnector} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 4f704af65827d..1c2e1ae1e7318 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -19,16 +19,26 @@ import { EuiIcon, EuiFlexGroup, EuiBetaBadge, + EuiButtonGroup, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import './connector_add_modal.scss'; import { betaBadgeProps } from './beta_badge_props'; import { hasSaveActionsCapability } from '../../lib/capabilities'; -import { ActionType, ActionConnector, ActionTypeRegistryContract } from '../../../types'; +import { + ActionType, + ActionConnector, + ActionTypeRegistryContract, + ActionTypeModel, + ActionTypeIndex, +} from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { useCreateConnector } from '../../hooks/use_create_connector'; -import { ConnectorForm, ConnectorFormState } from './connector_form'; +import { ConnectorForm, ConnectorFormState, ResetForm } from './connector_form'; import { ConnectorFormSchema } from './types'; +import { loadActionTypes } from '../../lib/action_connector_api'; +import { SectionLoading } from '../../components'; export interface ConnectorAddModalProps { actionType: ActionType; @@ -38,27 +48,74 @@ export interface ConnectorAddModalProps { } const ConnectorAddModal = ({ - actionType, + actionType: tempActionType, onClose, postSaveEventHandler, actionTypeRegistry, }: ConnectorAddModalProps) => { const { application: { capabilities }, + http, + notifications: { toasts }, } = useKibana().services; - + const [actionType, setActionType] = useState(tempActionType); + const [loadingActionTypes, setLoadingActionTypes] = useState(false); + const [allActionTypes, setAllActionTypes] = useState(undefined); const { isLoading: isSavingConnector, createConnector } = useCreateConnector(); const isMounted = useRef(false); - const initialConnector = { - actionTypeId: actionType.id, + const [initialConnector, setInitialConnector] = useState({ + actionTypeId: actionType?.id ?? '', isDeprecated: false, - isMissingSecrets: false, config: {}, secrets: {}, - }; + isMissingSecrets: false, + }); const canSave = hasSaveActionsCapability(capabilities); const actionTypeModel = actionTypeRegistry.get(actionType.id); + const groupActionTypeModel: Array = + actionTypeModel && actionTypeModel.subtype + ? (actionTypeModel?.subtype ?? []).map((subtypeAction) => ({ + ...actionTypeRegistry.get(subtypeAction.id), + name: subtypeAction.name, + })) + : []; + + const groupActionButtons = groupActionTypeModel.map((gAction) => ({ + id: gAction.id, + label: gAction.name, + 'data-test-subj': `${gAction.id}Button`, + })); + + const resetConnectorForm = useRef(); + + const setResetForm = (reset: ResetForm) => { + resetConnectorForm.current = reset; + }; + + const onChangeGroupAction = (id: string) => { + if (allActionTypes && allActionTypes[id]) { + setActionType(allActionTypes[id]); + setInitialConnector({ + actionTypeId: id, + isDeprecated: false, + config: {}, + secrets: {}, + isMissingSecrets: false, + }); + if (resetConnectorForm.current) { + resetConnectorForm.current({ + resetValues: true, + defaultValue: { + actionTypeId: id, + isDeprecated: false, + config: {}, + secrets: {}, + }, + }); + } + } + }; const [preSubmitValidationErrorMessage, setPreSubmitValidationErrorMessage] = useState(null); @@ -131,8 +188,39 @@ const ConnectorAddModal = ({ }; }, []); + useEffect(() => { + (async () => { + try { + setLoadingActionTypes(true); + const availableActionTypes = await loadActionTypes({ http }); + setLoadingActionTypes(false); + + const index: ActionTypeIndex = {}; + for (const actionTypeItem of availableActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setAllActionTypes(index); + } catch (e) { + if (toasts) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadConnectorTypesMessage', + { defaultMessage: 'Unable to load connector types' } + ), + }); + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - + {actionTypeModel && actionTypeModel.iconClass ? ( @@ -167,13 +255,40 @@ const ConnectorAddModal = ({ - - {preSubmitValidationErrorMessage} + {loadingActionTypes ? ( + + + + ) : ( + <> + {groupActionTypeModel && ( + <> + + + + )} + + {preSubmitValidationErrorMessage} + + )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx index b0d8072dae652..d9e59ac19db94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx @@ -27,6 +27,16 @@ export interface ConnectorFormState { preSubmitValidator: ConnectorValidationFunc | null; } +export type ResetForm = ( + options?: + | { + resetValues?: boolean | undefined; + defaultValue?: + | Partial, Record>> + | undefined; + } + | undefined +) => void; interface Props { actionTypeModel: ActionTypeModel | null; connector: ConnectorFormSchema & { isMissingSecrets: boolean }; @@ -35,6 +45,7 @@ interface Props { onChange?: (state: ConnectorFormState) => void; /** Handler to receive update on the form "isModified" state */ onFormModifiedChange?: (isModified: boolean) => void; + setResetForm?: (value: ResetForm) => void; } /** * The serializer and deserializer are needed to transform the headers of @@ -101,13 +112,14 @@ const ConnectorFormComponent: React.FC = ({ isEdit, onChange, onFormModifiedChange, + setResetForm, }) => { const { form } = useForm({ defaultValue: connector, serializer: formSerializer, deserializer: formDeserializer, }); - const { submit, isValid: isFormValid, isSubmitted, isSubmitting } = form; + const { submit, isValid: isFormValid, isSubmitted, isSubmitting, reset } = form; const [preSubmitValidator, setPreSubmitValidator] = useState( null ); @@ -133,6 +145,13 @@ const ConnectorFormComponent: React.FC = ({ } }, [isFormModified, onFormModifiedChange]); + useEffect(() => { + if (setResetForm) { + setResetForm(reset); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reset]); + return (
getValidConnectors(connectors, actionItem, actionTypesIndex), - [actionItem, actionTypesIndex, connectors] + () => getValidConnectors(connectors, actionItem, actionTypesIndex, allowGroupConnector), + [actionItem, actionTypesIndex, allowGroupConnector, connectors] ); const selectedConnectors = useMemo( - () => getValueOfSelectedConnector(actionItem.id, validConnectors, actionTypeRegistered), - [actionItem.id, validConnectors, actionTypeRegistered] + () => + getValueOfSelectedConnector( + actionItem.id, + validConnectors, + actionTypeRegistered, + allowGroupConnector + ), + [actionItem.id, validConnectors, actionTypeRegistered, allowGroupConnector] ); const options = useMemo( @@ -83,10 +91,15 @@ function ConnectorsSelectionComponent({ const getValueOfSelectedConnector = ( actionItemId: string, connectors: ActionConnector[], - actionTypeRegistered: ActionTypeModel + actionTypeRegistered: ActionTypeModel, + allowGroupConnector: string[] = [] ): Array> => { - const selectedConnector = connectors.find((connector) => connector.id === actionItemId); - + let selectedConnector = connectors.find((connector) => connector.id === actionItemId); + if (allowGroupConnector.length > 0 && !selectedConnector) { + selectedConnector = connectors.find((connector) => + allowGroupConnector.includes(connector.actionTypeId) + ); + } if (!selectedConnector) { return []; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx index 32bd7931aecc8..5cf6f6f8de69b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx @@ -6,21 +6,30 @@ */ import React, { memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody } from '@elastic/eui'; +import { + EuiButton, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiSpacer, +} from '@elastic/eui'; -import { getConnectorCompatibility } from '@kbn/actions-plugin/common'; +import { getConnectorCompatibility, UptimeConnectorFeatureId } from '@kbn/actions-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { ActionConnector, ActionType, ActionTypeModel, + ActionTypeIndex, ActionTypeRegistryContract, } from '../../../../types'; import { hasSaveActionsCapability } from '../../../lib/capabilities'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionTypeMenu } from '../action_type_menu'; import { useCreateConnector } from '../../../hooks/use_create_connector'; -import { ConnectorForm, ConnectorFormState } from '../connector_form'; +import { ConnectorForm, ConnectorFormState, ResetForm } from '../connector_form'; import { ConnectorFormSchema } from '../types'; import { FlyoutHeader } from './header'; import { FlyoutFooter } from './footer'; @@ -47,6 +56,7 @@ const CreateConnectorFlyoutComponent: React.FC = ({ const { isLoading: isSavingConnector, createConnector } = useCreateConnector(); const isMounted = useRef(false); + const [allActionTypes, setAllActionTypes] = useState(undefined); const [actionType, setActionType] = useState(null); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); const canSave = hasSaveActionsCapability(capabilities); @@ -78,6 +88,47 @@ const CreateConnectorFlyoutComponent: React.FC = ({ const actionTypeModel: ActionTypeModel | null = actionType != null ? actionTypeRegistry.get(actionType.id) : null; + /* Future Developer + * We are excluding `UptimeConnectorFeatureId` because as this time Synthetics won't work + * with slack API on their UI, We need to add an ISSUE here so they can fix it + */ + const groupActionTypeModel: Array = + actionTypeModel && actionTypeModel.subtype && featureId !== UptimeConnectorFeatureId + ? (actionTypeModel?.subtype ?? []).map((subtypeAction) => ({ + ...actionTypeRegistry.get(subtypeAction.id), + name: subtypeAction.name, + })) + : []; + + const groupActionButtons = groupActionTypeModel.map((gAction) => ({ + id: gAction.id, + label: gAction.name, + 'data-test-subj': `${gAction.id}Button`, + })); + + const resetConnectorForm = useRef(); + + const setResetForm = (reset: ResetForm) => { + resetConnectorForm.current = reset; + }; + + const onChangeGroupAction = (id: string) => { + if (allActionTypes && allActionTypes[id]) { + setActionType(allActionTypes[id]); + if (resetConnectorForm.current) { + resetConnectorForm.current({ + resetValues: true, + defaultValue: { + actionTypeId: id, + isDeprecated: false, + config: {}, + secrets: {}, + }, + }); + } + } + }; + const validateAndCreateConnector = useCallback(async () => { setPreSubmitValidationErrorMessage(null); @@ -166,14 +217,29 @@ const CreateConnectorFlyoutComponent: React.FC = ({ > {hasConnectorTypeSelected ? ( <> + {groupActionTypeModel && ( + <> + + + + )} {!!preSubmitValidationErrorMessage &&

{preSubmitValidationErrorMessage}

} - <> @@ -220,6 +286,7 @@ const CreateConnectorFlyoutComponent: React.FC = ({ featureId={featureId} onActionTypeChange={setActionType} setHasActionsUpgradeableByTrial={setHasActionsUpgradeableByTrial} + setAllActionTypes={setAllActionTypes} actionTypeRegistry={actionTypeRegistry} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts index 6543d74ecd7a2..af09d984f9417 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts @@ -10,13 +10,15 @@ import { ActionConnector, ActionTypeIndex, RuleAction } from '../../../types'; export const getValidConnectors = ( connectors: ActionConnector[], actionItem: RuleAction, - actionTypesIndex: ActionTypeIndex + actionTypesIndex: ActionTypeIndex, + allowGroupConnector: string[] = [] ): ActionConnector[] => { const actionType = actionTypesIndex[actionItem.actionTypeId]; return connectors.filter( (connector) => - connector.actionTypeId === actionItem.actionTypeId && + (allowGroupConnector.includes(connector.actionTypeId) || + connector.actionTypeId === actionItem.actionTypeId) && // include only enabled by config connectors or preconfigured (actionType?.enabledInConfig || connector.isPreconfigured) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index b5278bde4e175..756ea14488fad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -254,6 +254,10 @@ export interface ActionTypeModel; customConnectorSelectItem?: CustomConnectorSelectionItem; isExperimental?: boolean; + subtype?: Array<{ id: string; name: string }>; + convertParamsBetweenGroups?: (params: ActionParams) => ActionParams | {}; + hideInUi?: boolean; + modalWidth?: number; } export interface GenericValidationResult { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 4e8f8c45abb5b..d418b268f69ce 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -45,6 +45,7 @@ const enabledActionTypes = [ '.jira', '.resilient', '.slack', + '.slack_api', '.tines', '.webhook', '.xmatters', @@ -174,6 +175,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) 'localhost', 'some.non.existent.com', 'smtp.live.com', + 'slack.com', ])}`, `--xpack.actions.enableFooterInEmail=${enableFooterInEmail}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts new file mode 100644 index 0000000000000..12c17d2a7a4f9 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function slackTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Slack API action', () => { + it('should return 200 when creating a slack action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A slack api action', + connector_type_id: '.slack_api', + secrets: { + token: 'some token', + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'A slack api action', + connector_type_id: '.slack_api', + config: {}, + }); + + expect(typeof createdAction.id).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'A slack api action', + connector_type_id: '.slack_api', + config: {}, + }); + }); + + it('should respond with a 400 Bad Request when creating a slack action with no token', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A slack api action', + connector_type_id: '.slack_api', + secrets: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [token]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts similarity index 99% rename from x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts index 305afa0fdcaf9..d9c32ccd643b0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts @@ -18,7 +18,7 @@ export default function slackTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - describe('slack action', () => { + describe('Slack webhook action', () => { let simulatedActionId = ''; let slackSimulatorURL: string = ''; let slackServer: http.Server; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 66f83daff0429..05bd4da72c19c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -32,7 +32,8 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./connector_types/opsgenie')); loadTestFile(require.resolve('./connector_types/pagerduty')); loadTestFile(require.resolve('./connector_types/server_log')); - loadTestFile(require.resolve('./connector_types/slack')); + loadTestFile(require.resolve('./connector_types/slack_webhook')); + loadTestFile(require.resolve('./connector_types/slack_api')); loadTestFile(require.resolve('./connector_types/webhook')); loadTestFile(require.resolve('./connector_types/xmatters')); loadTestFile(require.resolve('./connector_types/tines')); @@ -48,6 +49,6 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide /** * Sub action framework */ - loadTestFile(require.resolve('./sub_action_framework')); + // loadTestFile(require.resolve('./sub_action_framework')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 830f5e6f8d96d..f0578f6dbd7ce 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -34,6 +34,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.swimlane', '.server-log', '.slack', + '.slack_api', '.webhook', '.cases-webhook', '.xmatters', diff --git a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts index c953aff7000c5..2a055ce007fee 100644 --- a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts +++ b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts @@ -30,8 +30,8 @@ export default function ({ getService }: FtrProviderContext) { return fieldStat.name === 'geo_point'; } ); - expect(geoPointFieldStats.count).to.be(39); - expect(geoPointFieldStats.index_count).to.be(10); + expect(geoPointFieldStats.count).to.be(31); + expect(geoPointFieldStats.index_count).to.be(9); const geoShapeFieldStats = apiResponse.cluster_stats.indices.mappings.field_types.find( (fieldStat: estypes.ClusterStatsFieldTypes) => { diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts index d680e99d13405..b48cf2c9a56ca 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts @@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertestAPI - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); @@ -455,7 +455,7 @@ export default function ({ getService }: FtrProviderContext) { pkgPolicy.id === monitorId + '-' + testFleetPolicyID + `-default` ); - expect(packagePolicy.package.version).eql('0.11.4'); + expect(packagePolicy.package.version).eql('0.12.0'); await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); const policyResponseAfterUpgrade = await supertestAPI.get( @@ -465,7 +465,7 @@ export default function ({ getService }: FtrProviderContext) { (pkgPolicy: PackagePolicy) => pkgPolicy.id === monitorId + '-' + testFleetPolicyID + `-default` ); - expect(semver.gte(packagePolicyAfterUpgrade.package.version, '0.11.4')).eql(true); + expect(semver.gte(packagePolicyAfterUpgrade.package.version, '0.12.0')).eql(true); } finally { await supertestAPI .delete(API_URLS.SYNTHETICS_MONITORS + '/' + monitorId) diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts index e6a8ae14cda1a..7674d2fbc534f 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts @@ -77,7 +77,7 @@ export default function ({ getService }: FtrProviderContext) { await supertest.put(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); @@ -321,6 +321,9 @@ export default function ({ getService }: FtrProviderContext) { custom_heartbeat_id: `${journeyId}-${project}-default`, 'check.response.body.negative': [], 'check.response.body.positive': ['Saved', 'saved'], + 'check.response.json': [ + { description: 'check status', expression: 'foo.bar == "myValue"' }, + ], 'check.response.headers': {}, 'check.request.body': { type: 'text', @@ -357,8 +360,10 @@ export default function ({ getService }: FtrProviderContext) { username: '', password: '', proxy_url: '', + proxy_headers: {}, 'response.include_body': 'always', 'response.include_headers': false, + 'response.include_body_max_bytes': '900', revision: 1, schedule: { number: '60', @@ -378,6 +383,9 @@ export default function ({ getService }: FtrProviderContext) { 'url.port': null, id: `${journeyId}-${project}-default`, hash: 'ekrjelkjrelkjre', + mode: 'any', + ipv6: true, + ipv4: true, }); } } finally { @@ -486,6 +494,9 @@ export default function ({ getService }: FtrProviderContext) { urls: '', id: `${journeyId}-${project}-default`, hash: 'ekrjelkjrelkjre', + mode: 'any', + ipv6: true, + ipv4: true, }); } } finally { @@ -590,6 +601,9 @@ export default function ({ getService }: FtrProviderContext) { : `${parseInt(monitor.wait?.slice(0, -1) || '1', 10) * 60}`, id: `${journeyId}-${project}-default`, hash: 'ekrjelkjrelkjre', + mode: 'any', + ipv4: true, + ipv6: true, }); } } finally { @@ -1784,6 +1798,7 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [{ description: 'check status', expression: 'foo.bar == "myValue"' }], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -1792,6 +1807,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, @@ -1885,6 +1901,12 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -1893,6 +1915,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, @@ -1946,6 +1969,12 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -1954,6 +1983,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, @@ -2008,6 +2038,12 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -2016,6 +2052,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts index 65768d80a53f3..0e2cec6c3a5c3 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts @@ -86,7 +86,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); @@ -256,6 +256,9 @@ export default function ({ getService }: FtrProviderContext) { custom_heartbeat_id: `${journeyId}-test-suite-default`, 'check.response.body.negative': [], 'check.response.body.positive': ['Saved', 'saved'], + 'check.response.json': [ + { description: 'check status', expression: 'foo.bar == "myValue"' }, + ], 'check.response.headers': {}, 'check.request.body': { type: 'text', @@ -292,8 +295,10 @@ export default function ({ getService }: FtrProviderContext) { username: '', password: '', proxy_url: '', + proxy_headers: {}, 'response.include_body': 'always', 'response.include_headers': false, + 'response.include_body_max_bytes': '900', revision: 1, schedule: { number: '60', @@ -313,6 +318,9 @@ export default function ({ getService }: FtrProviderContext) { 'url.port': null, id: `${journeyId}-test-suite-default`, hash: 'ekrjelkjrelkjre', + ipv6: true, + ipv4: true, + mode: 'any', }); } } finally { @@ -418,6 +426,9 @@ export default function ({ getService }: FtrProviderContext) { urls: '', id: `${journeyId}-test-suite-default`, hash: 'ekrjelkjrelkjre', + ipv6: true, + ipv4: true, + mode: 'any', }); } } finally { @@ -520,6 +531,9 @@ export default function ({ getService }: FtrProviderContext) { : `${parseInt(monitor.wait?.slice(0, -1) || '1', 10) * 60}`, id: `${journeyId}-test-suite-default`, hash: 'ekrjelkjrelkjre', + ipv6: true, + ipv4: true, + mode: 'any', }); } } finally { @@ -1802,11 +1816,13 @@ export default function ({ getService }: FtrProviderContext) { timeout: { value: '80s', type: 'text' }, max_redirects: { value: '0', type: 'integer' }, proxy_url: { value: '', type: 'text' }, + proxy_headers: { value: null, type: 'yaml' }, tags: { value: '["tag2","tag2"]', type: 'yaml' }, username: { value: '', type: 'text' }, password: { value: '', type: 'password' }, 'response.include_headers': { value: false, type: 'bool' }, 'response.include_body': { value: 'always', type: 'text' }, + 'response.include_body_max_bytes': { value: '900', type: 'text' }, 'check.request.method': { value: 'POST', type: 'text' }, 'check.request.headers': { value: '{"Content-Type":"application/x-www-form-urlencoded"}', @@ -1817,6 +1833,10 @@ export default function ({ getService }: FtrProviderContext) { 'check.response.headers': { value: null, type: 'yaml' }, 'check.response.body.positive': { value: '["Saved","saved"]', type: 'yaml' }, 'check.response.body.negative': { value: null, type: 'yaml' }, + 'check.response.json': { + value: '[{"description":"check status","expression":"foo.bar == \\"myValue\\""}]', + type: 'yaml', + }, 'ssl.certificate_authorities': { value: null, type: 'yaml' }, 'ssl.certificate': { value: null, type: 'yaml' }, 'ssl.key': { value: null, type: 'yaml' }, @@ -1842,6 +1862,9 @@ export default function ({ getService }: FtrProviderContext) { type: 'text', value: 'test-suite', }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text', value: 'any' }, }, id: `synthetics/http-http-${id}-${testPolicyId}`, compiled_stream: { @@ -1866,6 +1889,15 @@ export default function ({ getService }: FtrProviderContext) { 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], 'run_from.geo.name': 'Test private location 0', 'run_from.id': 'Test private location 0', + 'check.response.json': [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], + ipv4: true, + ipv6: true, + mode: 'any', processors: [ { add_fields: { diff --git a/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts b/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts index 87d033be74743..9ab6063775844 100644 --- a/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts +++ b/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts @@ -41,7 +41,7 @@ export default function ({ getService }: FtrProviderContext) { _httpMonitorJson = getFixtureJson('http_monitor'); await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts index 8ba507beb7e4c..13bcab980d248 100644 --- a/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts index d24a3775bbf44..beefd6c561829 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts b/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts index eff1026cb4486..5eec726cdb328 100644 --- a/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts +++ b/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts @@ -21,7 +21,7 @@ export const getTestBrowserSyntheticsPolicy = ({ version: 'WzEzNzYsMV0=', name: 'Test HTTP Monitor 03-Test private location 0-default', namespace: 'testnamespace', - package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.11.4' }, + package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.12.0' }, enabled: true, policy_id: 'fe621d20-7b01-11ed-803f-475d82e1f9ca', inputs: [ @@ -44,11 +44,13 @@ export const getTestBrowserSyntheticsPolicy = ({ timeout: { type: 'text' }, max_redirects: { type: 'integer' }, proxy_url: { type: 'text' }, + proxy_headers: { type: 'yaml' }, tags: { type: 'yaml' }, username: { type: 'text' }, password: { type: 'password' }, 'response.include_headers': { type: 'bool' }, 'response.include_body': { type: 'text' }, + 'response.include_body_max_bytes': { type: 'text' }, 'check.request.method': { type: 'text' }, 'check.request.headers': { type: 'yaml' }, 'check.request.body': { type: 'yaml' }, @@ -56,6 +58,7 @@ export const getTestBrowserSyntheticsPolicy = ({ 'check.response.headers': { type: 'yaml' }, 'check.response.body.positive': { type: 'yaml' }, 'check.response.body.negative': { type: 'yaml' }, + 'check.response.json': { type: 'yaml' }, 'ssl.certificate_authorities': { type: 'yaml' }, 'ssl.certificate': { type: 'yaml' }, 'ssl.key': { type: 'yaml' }, @@ -69,6 +72,9 @@ export const getTestBrowserSyntheticsPolicy = ({ origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/http-http-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', }, @@ -109,6 +115,9 @@ export const getTestBrowserSyntheticsPolicy = ({ origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/tcp-tcp-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', }, @@ -140,6 +149,9 @@ export const getTestBrowserSyntheticsPolicy = ({ origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/icmp-icmp-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', }, @@ -249,10 +261,7 @@ export const getTestBrowserSyntheticsPolicy = ({ }, id: 'synthetics/browser-browser.network-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, { @@ -264,10 +273,7 @@ export const getTestBrowserSyntheticsPolicy = ({ }, id: 'synthetics/browser-browser.screenshot-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, ], diff --git a/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts b/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts index 82e19948779bb..b0962e4d285e6 100644 --- a/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts +++ b/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts @@ -21,7 +21,7 @@ export const getTestSyntheticsPolicy = ( version: 'WzE2MjYsMV0=', name: 'test-monitor-name-Test private location 0-default', namespace: namespace || 'testnamespace', - package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.11.4' }, + package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.12.0' }, enabled: true, policy_id: '5347cd10-0368-11ed-8df7-a7424c6f5167', inputs: [ @@ -55,11 +55,13 @@ export const getTestSyntheticsPolicy = ( timeout: { value: '3ms', type: 'text' }, max_redirects: { value: '3', type: 'integer' }, proxy_url: { value: proxyUrl ?? 'http://proxy.com', type: 'text' }, + proxy_headers: { value: null, type: 'yaml' }, tags: { value: '["tag1","tag2"]', type: 'yaml' }, username: { value: 'test-username', type: 'text' }, password: { value: 'test', type: 'password' }, 'response.include_headers': { value: true, type: 'bool' }, 'response.include_body': { value: 'never', type: 'text' }, + 'response.include_body_max_bytes': { value: '1024', type: 'text' }, 'check.request.method': { value: '', type: 'text' }, 'check.request.headers': { value: '{"sampleHeader":"sampleHeaderValue"}', @@ -70,6 +72,7 @@ export const getTestSyntheticsPolicy = ( 'check.response.headers': { value: null, type: 'yaml' }, 'check.response.body.positive': { value: null, type: 'yaml' }, 'check.response.body.negative': { value: null, type: 'yaml' }, + 'check.response.json': { value: null, type: 'yaml' }, 'ssl.certificate_authorities': { value: isTLSEnabled ? '"t.string"' : null, type: 'yaml', @@ -89,6 +92,9 @@ export const getTestSyntheticsPolicy = ( origin: { value: 'ui', type: 'text' }, 'monitor.project.id': { type: 'text', value: null }, 'monitor.project.name': { type: 'text', value: null }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text', value: 'any' }, }, id: 'synthetics/http-http-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', compiled_stream: { @@ -116,6 +122,9 @@ export const getTestSyntheticsPolicy = ( 'check.request.headers': { sampleHeader: 'sampleHeaderValue' }, 'check.request.body': 'testValue', 'check.response.status': ['200', '201'], + ipv4: true, + ipv6: true, + mode: 'any', ...(isTLSEnabled ? { 'ssl.certificate': 't.string', @@ -179,6 +188,9 @@ export const getTestSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/tcp-tcp-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', }, @@ -213,6 +225,9 @@ export const getTestSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/icmp-icmp-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', }, @@ -299,10 +314,7 @@ export const getTestSyntheticsPolicy = ( }, id: 'synthetics/browser-browser.network-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, { @@ -318,10 +330,7 @@ export const getTestSyntheticsPolicy = ( }, id: 'synthetics/browser-browser.screenshot-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, ], diff --git a/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts b/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts index ba48a26ff50fe..bc015cb4bd77c 100644 --- a/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts +++ b/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts @@ -34,7 +34,7 @@ export const getTestProjectSyntheticsPolicy = ( version: 'WzEzMDksMV0=', name: `4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-Test private location 0`, namespace: 'default', - package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.11.4' }, + package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.12.0' }, enabled: true, policy_id: '46034710-0ba6-11ed-ba04-5f123b9faa8b', inputs: [ @@ -60,11 +60,13 @@ export const getTestProjectSyntheticsPolicy = ( timeout: { type: 'text' }, max_redirects: { type: 'integer' }, proxy_url: { type: 'text' }, + proxy_headers: { type: 'yaml' }, tags: { type: 'yaml' }, username: { type: 'text' }, password: { type: 'password' }, 'response.include_headers': { type: 'bool' }, 'response.include_body': { type: 'text' }, + 'response.include_body_max_bytes': { type: 'text' }, 'check.request.method': { type: 'text' }, 'check.request.headers': { type: 'yaml' }, 'check.request.body': { type: 'yaml' }, @@ -72,6 +74,7 @@ export const getTestProjectSyntheticsPolicy = ( 'check.response.headers': { type: 'yaml' }, 'check.response.body.positive': { type: 'yaml' }, 'check.response.body.negative': { type: 'yaml' }, + 'check.response.json': { type: 'yaml' }, 'ssl.certificate_authorities': { type: 'yaml' }, 'ssl.certificate': { type: 'yaml' }, 'ssl.key': { type: 'yaml' }, @@ -85,6 +88,9 @@ export const getTestProjectSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: `synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, }, @@ -128,6 +134,9 @@ export const getTestProjectSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: `synthetics/tcp-tcp-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, }, @@ -162,6 +171,9 @@ export const getTestProjectSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: `synthetics/icmp-icmp-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, }, @@ -287,10 +299,7 @@ export const getTestProjectSyntheticsPolicy = ( }, id: `synthetics/browser-browser.network-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, { @@ -306,10 +315,7 @@ export const getTestProjectSyntheticsPolicy = ( }, id: `synthetics/browser-browser.screenshot-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, ], diff --git a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts index acb6b4250628d..f5ff56d2ee506 100644 --- a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts +++ b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts @@ -44,7 +44,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertestAPI - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json index 091f18e6afe57..b2961eb0660d6 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json @@ -25,9 +25,12 @@ "urls": "https://nextjs-test-synthetics.vercel.app/api/users", "url.port": null, "proxy_url": "http://proxy.com", + "proxy_headers": {}, "check.response.body.negative": [], "check.response.body.positive": [], + "check.response.json": [], "response.include_body": "never", + "response.include_body_max_bytes": "1024", "check.request.headers": { "sampleHeader": "sampleHeaderValue" }, @@ -81,5 +84,8 @@ "form_monitor_type": "http", "journey_id": "", "id": "", - "hash": "" + "hash": "", + "mode": "any", + "ipv4": true, + "ipv6": true } diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json index ab172c3fe8ca5..ef94fcc067a98 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json @@ -26,5 +26,8 @@ "origin": "ui", "form_monitor_type": "icmp", "id": "", - "hash": "" + "hash": "", + "mode": "any", + "ipv4": true, + "ipv6": true } diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json index c8c2c27ce0f17..dd6d6eefecfcd 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json @@ -56,7 +56,8 @@ } }, "response": { - "include_body": "always" + "include_body": "always", + "include_body_max_bytes": 900 }, "tags": "tag2,tag2", "response.include_headers": false, @@ -69,7 +70,8 @@ "Saved", "saved" ] - } + }, + "json": [{"description":"check status","expression":"foo.bar == \"myValue\""}] }, "hash": "ekrjelkjrelkjre", "ssl.verification_mode": "strict" diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json index c3664c5646b16..a0390c0bc173e 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json @@ -34,5 +34,8 @@ "origin": "ui", "form_monitor_type": "tcp", "id": "", - "hash": "" + "hash": "", + "mode": "any", + "ipv4": true, + "ipv6": true } diff --git a/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts index 3edbabcf023f9..64ced57167ada 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts @@ -8,6 +8,7 @@ import { ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; import { errorCountMessage } from '@kbn/apm-plugin/common/rules/default_action_message'; import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import { getErrorGroupingKey } from '@kbn/apm-synthtrace-client/src/lib/apm/instance'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { @@ -16,7 +17,11 @@ import { fetchServiceInventoryAlertCounts, fetchServiceTabAlertCount, } from './alerting_api_helper'; -import { waitForRuleStatus, waitForDocumentInIndex } from './wait_for_rule_status'; +import { + waitForRuleStatus, + waitForDocumentInIndex, + waitForAlertInIndex, +} from './wait_for_rule_status'; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); @@ -32,7 +37,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { let ruleId: string; let actionId: string | undefined; - const INDEX_NAME = 'error-count'; + const APM_ALERTS_INDEX = '.alerts-observability.apm.alerts-default'; + const ALERT_ACTION_INDEX_NAME = 'alert-action-error-count'; + + const errorMessage = '[ResponseError] index_not_found_exception'; + const errorGroupingKey = getErrorGroupingKey(errorMessage); before(async () => { const opbeansJava = apm @@ -50,11 +59,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { .timestamp(timestamp) .duration(100) .failure() - .errors( - opbeansJava - .error({ message: '[ResponseError] index_not_found_exception' }) - .timestamp(timestamp + 50) - ), + .errors(opbeansJava.error({ message: errorMessage }).timestamp(timestamp + 50)), opbeansNode .transaction({ transactionName: 'tx-node' }) .timestamp(timestamp) @@ -69,8 +74,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { await synthtraceEsClient.clean(); await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); - await esDeleteAllIndices(INDEX_NAME); - await es.deleteByQuery({ index: '.alerts*', query: { match_all: {} } }); + await esDeleteAllIndices(ALERT_ACTION_INDEX_NAME); + await es.deleteByQuery({ + index: APM_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); await es.deleteByQuery({ index: '.kibana-event-log-*', query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, @@ -82,7 +90,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { actionId = await createIndexConnector({ supertest, name: 'Error count API test', - indexName: INDEX_NAME, + indexName: ALERT_ACTION_INDEX_NAME, }); const createdRule = await createApmRule({ supertest, @@ -93,13 +101,25 @@ export default function ApiTest({ getService }: FtrProviderContext) { threshold: 1, windowSize: 1, windowUnit: 'h', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.name', + 'error.grouping_key', + ], }, actions: [ { group: 'threshold_met', id: actionId, params: { - documents: [{ message: errorCountMessage }], + documents: [ + { + message: `${errorCountMessage} +- Transaction name: {{context.transactionName}} +- Error grouping key: {{context.errorGroupingKey}}`, + }, + ], }, frequency: { notify_when: 'onActionGroupChange', @@ -113,7 +133,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ruleId = createdRule.id; }); - it('checks if alert is active', async () => { + it('checks if rule is active', async () => { const executionStatus = await waitForRuleStatus({ id: ruleId, expectedStatus: 'active', @@ -125,7 +145,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns correct message', async () => { const resp = await waitForDocumentInIndex<{ message: string }>({ es, - indexName: INDEX_NAME, + indexName: ALERT_ACTION_INDEX_NAME, }); expect(resp.hits.hits[0]._source?.message).eql( @@ -134,10 +154,25 @@ export default function ApiTest({ getService }: FtrProviderContext) { - Service name: opbeans-java - Environment: production - Threshold: 1 -- Triggered value: 15 errors over the last 1 hr` +- Triggered value: 15 errors over the last 1 hr +- Transaction name: tx-java +- Error grouping key: ${errorGroupingKey}` ); }); + it('indexes alert document with all group-by fields', async () => { + const resp = await waitForAlertInIndex({ + es, + indexName: APM_ALERTS_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property('service.name', 'opbeans-java'); + expect(resp.hits.hits[0]._source).property('service.environment', 'production'); + expect(resp.hits.hits[0]._source).property('transaction.name', 'tx-java'); + expect(resp.hits.hits[0]._source).property('error.grouping_key', errorGroupingKey); + }); + it('shows the correct alert count for each service on service inventory', async () => { const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); expect(serviceInventoryAlertCounts).to.eql({ diff --git a/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts new file mode 100644 index 0000000000000..4fa40d566552f --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AggregationType, ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; +import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createApmRule, + createIndexConnector, + fetchServiceInventoryAlertCounts, + fetchServiceTabAlertCount, +} from './alerting_api_helper'; +import { + waitForRuleStatus, + waitForDocumentInIndex, + waitForAlertInIndex, +} from './wait_for_rule_status'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + + const supertest = getService('supertest'); + const es = getService('es'); + const apmApiClient = getService('apmApiClient'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('transaction duration alert', { config: 'basic', archives: [] }, () => { + let ruleId: string; + let actionId: string | undefined; + + const APM_ALERTS_INDEX = '.alerts-observability.apm.alerts-default'; + const ALERT_ACTION_INDEX_NAME = 'alert-action-transaction-duration'; + + before(async () => { + const opbeansJava = apm + .service({ name: 'opbeans-java', environment: 'production', agentName: 'java' }) + .instance('instance'); + const opbeansNode = apm + .service({ name: 'opbeans-node', environment: 'production', agentName: 'node' }) + .instance('instance'); + const events = timerange('now-15m', 'now') + .ratePerMinute(1) + .generator((timestamp) => { + return [ + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(5000) + .success(), + opbeansNode + .transaction({ transactionName: 'tx-node' }) + .timestamp(timestamp) + .duration(4000) + .success(), + ]; + }); + await synthtraceEsClient.index(events); + }); + + after(async () => { + await synthtraceEsClient.clean(); + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esDeleteAllIndices([ALERT_ACTION_INDEX_NAME]); + await es.deleteByQuery({ + index: APM_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await es.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + }); + }); + + describe('create alert with transaction.name group by', () => { + before(async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Transation duration API test', + indexName: ALERT_ACTION_INDEX_NAME, + }); + const createdRule = await createApmRule({ + supertest, + ruleTypeId: ApmRuleType.TransactionDuration, + name: 'Apm transaction duration', + params: { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + environment: 'production', + aggregationType: AggregationType.Avg, + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }, + actions: [ + { + group: 'threshold_met', + id: actionId, + params: { + documents: [{ message: 'Transaction Name: {{context.transactionName}}' }], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + expect(createdRule.id).to.not.eql(undefined); + ruleId = createdRule.id; + }); + + it('checks if rule is active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('returns correct message', async () => { + const resp = await waitForDocumentInIndex<{ message: string }>({ + es, + indexName: ALERT_ACTION_INDEX_NAME, + }); + + expect(resp.hits.hits[0]._source?.message).eql(`Transaction Name: tx-java`); + }); + + it('indexes alert document with all group-by fields', async () => { + const resp = await waitForAlertInIndex({ + es, + indexName: APM_ALERTS_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property('service.name', 'opbeans-java'); + expect(resp.hits.hits[0]._source).property('service.environment', 'production'); + expect(resp.hits.hits[0]._source).property('transaction.type', 'request'); + expect(resp.hits.hits[0]._source).property('transaction.name', 'tx-java'); + }); + + it('shows the correct alert count for each service on service inventory', async () => { + const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); + expect(serviceInventoryAlertCounts).to.eql({ + 'opbeans-node': 0, + 'opbeans-java': 1, + }); + }); + + it('shows the correct alert count in opbeans-java service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-java', + }); + expect(serviceTabAlertCount).to.be(1); + }); + + it('shows the correct alert count in opbeans-node service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-node', + }); + expect(serviceTabAlertCount).to.be(0); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts new file mode 100644 index 0000000000000..d4d6a0cf3585d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts @@ -0,0 +1,187 @@ +/* + * 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 { ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; +import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createApmRule, + createIndexConnector, + fetchServiceInventoryAlertCounts, + fetchServiceTabAlertCount, +} from './alerting_api_helper'; +import { + waitForRuleStatus, + waitForDocumentInIndex, + waitForAlertInIndex, +} from './wait_for_rule_status'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + + const supertest = getService('supertest'); + const es = getService('es'); + const apmApiClient = getService('apmApiClient'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('transaction error rate alert', { config: 'basic', archives: [] }, () => { + let ruleId: string; + let actionId: string | undefined; + + const APM_ALERTS_INDEX = '.alerts-observability.apm.alerts-default'; + const ALERT_ACTION_INDEX_NAME = 'alert-action-transaction-error-rate'; + + before(async () => { + const opbeansJava = apm + .service({ name: 'opbeans-java', environment: 'production', agentName: 'java' }) + .instance('instance'); + const opbeansNode = apm + .service({ name: 'opbeans-node', environment: 'production', agentName: 'node' }) + .instance('instance'); + const events = timerange('now-15m', 'now') + .ratePerMinute(1) + .generator((timestamp) => { + return [ + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(100) + .failure(), + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(200) + .success(), + opbeansNode + .transaction({ transactionName: 'tx-node' }) + .timestamp(timestamp) + .duration(400) + .success(), + ]; + }); + await synthtraceEsClient.index(events); + }); + + after(async () => { + await synthtraceEsClient.clean(); + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esDeleteAllIndices([ALERT_ACTION_INDEX_NAME]); + await es.deleteByQuery({ + index: APM_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await es.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + }); + }); + + describe('create alert with transaction.name group by', () => { + before(async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Transation error rate API test', + indexName: ALERT_ACTION_INDEX_NAME, + }); + const createdRule = await createApmRule({ + supertest, + ruleTypeId: ApmRuleType.TransactionErrorRate, + name: 'Apm error rate duration', + params: { + threshold: 50, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + environment: 'production', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }, + actions: [ + { + group: 'threshold_met', + id: actionId, + params: { + documents: [{ message: 'Transaction Name: {{context.transactionName}}' }], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + expect(createdRule.id).to.not.eql(undefined); + ruleId = createdRule.id; + }); + + it('checks if rule is active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('returns correct message', async () => { + const resp = await waitForDocumentInIndex<{ message: string }>({ + es, + indexName: ALERT_ACTION_INDEX_NAME, + }); + + expect(resp.hits.hits[0]._source?.message).eql(`Transaction Name: tx-java`); + }); + + it('indexes alert document with all group-by fields', async () => { + const resp = await waitForAlertInIndex({ + es, + indexName: APM_ALERTS_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property('service.name', 'opbeans-java'); + expect(resp.hits.hits[0]._source).property('service.environment', 'production'); + expect(resp.hits.hits[0]._source).property('transaction.type', 'request'); + expect(resp.hits.hits[0]._source).property('transaction.name', 'tx-java'); + }); + + it('shows the correct alert count for each service on service inventory', async () => { + const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); + expect(serviceInventoryAlertCounts).to.eql({ + 'opbeans-node': 0, + 'opbeans-java': 1, + }); + }); + + it('shows the correct alert count in opbeans-java service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-java', + }); + expect(serviceTabAlertCount).to.be(1); + }); + + it('shows the correct alert count in opbeans-node service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-node', + }); + expect(serviceTabAlertCount).to.be(0); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts b/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts index 44da2bea36bf2..f02285d5056eb 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts @@ -53,3 +53,33 @@ export async function waitForDocumentInIndex({ { retries: 10 } ); } + +export async function waitForAlertInIndex({ + es, + indexName, + ruleId, +}: { + es: Client; + indexName: string; + ruleId: string; +}): Promise>> { + return pRetry( + async () => { + const response = await es.search({ + index: indexName, + body: { + query: { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + }, + }); + if (response.hits.hits.length === 0) { + throw new Error('No hits found'); + } + return response; + }, + { retries: 10 } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts index cd3b4ed636272..91a8aac9bc3d3 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts @@ -7,6 +7,8 @@ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1']; + export async function generateMobileData({ start, end, @@ -22,7 +24,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '2.3' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] }) .deviceInfo({ manufacturer: 'Samsung', modelIdentifier: 'SM-G973F', @@ -52,7 +54,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '1.2' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] }) .deviceInfo({ manufacturer: 'Samsung', modelIdentifier: 'SM-G930F', @@ -89,7 +91,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '1.1' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] }) .deviceInfo({ manufacturer: 'Huawei', modelIdentifier: 'HUAWEI P2-0000', @@ -222,6 +224,7 @@ export async function generateMobileData({ return [ galaxy10 .transaction('Start View - View Appearing', 'Android Activity') + .errors(galaxy10.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(500) .success() @@ -265,6 +268,7 @@ export async function generateMobileData({ ), huaweiP2 .transaction('Start View - View Appearing', 'huaweiP2 Activity') + .errors(huaweiP2.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(20) .success() @@ -292,6 +296,7 @@ export async function generateMobileData({ ), galaxy7 .transaction('Start View - View Appearing', 'Android Activity') + .errors(galaxy7.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(20) .success() diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts new file mode 100644 index 0000000000000..601e3b81e6dad --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values'; +import { isEmpty } from 'lodash'; +import moment from 'moment'; +import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateMobileData, SERVICE_VERSIONS } from './generate_mobile_data'; + +type MobileDetailedStatisticsResponse = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const registry = getService('registry'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2023-01-01T00:00:00.000Z').getTime(); + const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1; + + async function getMobileDetailedStatisticsByField({ + environment = ENVIRONMENT_ALL.value, + kuery = '', + serviceName, + field, + offset, + }: { + environment?: string; + kuery?: string; + serviceName: string; + field: string; + offset?: string; + }) { + return await apmApiClient + .readUser({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + params: { + path: { serviceName }, + query: { + environment, + start: moment(end).subtract(7, 'minutes').toISOString(), + end: new Date(end).toISOString(), + offset, + kuery, + field, + fieldValues: JSON.stringify(SERVICE_VERSIONS), + }, + }, + }) + .then(({ body }) => body); + } + + registry.when( + 'Mobile detailed statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when no data', () => { + it('handles empty state', async () => { + const response = await getMobileDetailedStatisticsByField({ + serviceName: 'foo', + field: 'service.version', + }); + expect(response).to.be.eql({ currentPeriod: {}, previousPeriod: {} }); + }); + }); + } + ); + + registry.when( + 'Mobile detailed statistics when data is loaded', + { config: 'basic', archives: [] }, + () => { + before(async () => { + await generateMobileData({ + synthtraceEsClient, + start, + end, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe('when comparison is disable', () => { + it('returns current period data only', async () => { + const response = await getMobileDetailedStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + }); + expect(isEmpty(response.currentPeriod)).to.be.equal(false); + expect(isEmpty(response.previousPeriod)).to.be.equal(true); + }); + }); + + describe('when comparison is enable', () => { + let mobiledetailedStatisticResponse: MobileDetailedStatisticsResponse; + + before(async () => { + mobiledetailedStatisticResponse = await getMobileDetailedStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + offset: '8m', + }); + }); + it('returns some data for both periods', async () => { + expect(isEmpty(mobiledetailedStatisticResponse.currentPeriod)).to.be.equal(false); + expect(isEmpty(mobiledetailedStatisticResponse.previousPeriod)).to.be.equal(false); + }); + + it('returns same number of buckets for both periods', () => { + const currentPeriod = mobiledetailedStatisticResponse.currentPeriod[SERVICE_VERSIONS[0]]; + const previousPeriod = + mobiledetailedStatisticResponse.previousPeriod[SERVICE_VERSIONS[0]]; + + [ + [currentPeriod.latency, previousPeriod.latency], + [currentPeriod.throughput, previousPeriod.throughput], + ].forEach(([currentTimeseries, previousTimeseries]) => { + expect(currentTimeseries.length).to.equal(previousTimeseries.length); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts new file mode 100644 index 0000000000000..a58f6e58b99e6 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateMobileData } from './generate_mobile_data'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const registry = getService('registry'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2023-01-01T00:00:00.000Z').getTime(); + const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1; + + async function getMobileMainStatisticsByField({ + environment = ENVIRONMENT_ALL.value, + kuery = '', + serviceName, + field, + }: { + environment?: string; + kuery?: string; + serviceName: string; + field: string; + }) { + return await apmApiClient + .readUser({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + params: { + path: { serviceName }, + query: { + environment, + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery, + field, + }, + }, + }) + .then(({ body }) => body); + } + + registry.when( + 'Mobile main statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when no data', () => { + it('handles empty state', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'foo', + field: 'service.version', + }); + expect(response.mainStatistics.length).to.be(0); + }); + }); + } + ); + + registry.when('Mobile main statistics', { config: 'basic', archives: [] }, () => { + before(async () => { + await generateMobileData({ + synthtraceEsClient, + start, + end, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe('when data is loaded', () => { + it('returns the correct data for App version', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + }); + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql(['1.1', '1.2', '2.3']); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([172000, 20000, 20000]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([ + 1.0000011111123457, 0.20000022222246913, 0.20000022222246913, + ]); + }); + it('returns the correct data for Os version', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'host.os.version', + }); + + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql(['10']); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([128571.42857142857]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([1.4000015555572838]); + }); + it('returns the correct data for Devices', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'device.model.identifier', + }); + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql([ + 'HUAWEI P2-0000', + 'SM-G930F', + 'SM-G973F', + 'Pixel 7 Pro', + 'Pixel 8', + 'SM-G930F', + ]); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([400000, 20000, 20000, 20000, 20000, 20000]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([ + 0.40000044444493826, 0.20000022222246913, 0.20000022222246913, 0.20000022222246913, + 0.20000022222246913, 0.20000022222246913, + ]); + }); + }); + }); +} diff --git a/x-pack/test/defend_workflows_cypress/endpoint_config.ts b/x-pack/test/defend_workflows_cypress/endpoint_config.ts index f1ea9a9c81a12..e6191cea7074b 100644 --- a/x-pack/test/defend_workflows_cypress/endpoint_config.ts +++ b/x-pack/test/defend_workflows_cypress/endpoint_config.ts @@ -8,6 +8,7 @@ import { getLocalhostRealIp } from '@kbn/security-solution-plugin/scripts/endpoint/common/localhost_services'; import { FtrConfigProviderContext } from '@kbn/test'; +import { ExperimentalFeatures } from '@kbn/security-solution-plugin/common/experimental_features'; import { DefendWorkflowsCypressEndpointTestRunner } from './runner'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { @@ -15,6 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const config = defendWorkflowsCypressConfig.getAll(); const hostIp = getLocalhostRealIp(); + const enabledFeatureFlags: Array = ['responseActionExecuteEnabled']; + return { ...config, kbnTestServer: { @@ -27,6 +30,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { )}`, // set the packagerTaskInterval to 5s in order to speed up test executions when checking fleet artifacts '--xpack.securitySolution.packagerTaskInterval=5s', + `--xpack.securitySolution.enableExperimental=${JSON.stringify(enabledFeatureFlags)}`, ], }, testRunner: DefendWorkflowsCypressEndpointTestRunner, diff --git a/x-pack/test/functional/apps/lens/group1/field_formatters.ts b/x-pack/test/functional/apps/lens/group1/field_formatters.ts new file mode 100644 index 0000000000000..855515bd3d2ce --- /dev/null +++ b/x-pack/test/functional/apps/lens/group1/field_formatters.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'visualize', + 'lens', + 'header', + 'dashboard', + 'common', + 'settings', + ]); + const retry = getService('retry'); + const fieldEditor = getService('fieldEditor'); + + describe('lens fields formatters tests', () => { + before(async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + }); + + afterEach(async () => { + await PageObjects.lens.clickField('runtimefield'); + await PageObjects.lens.removeField('runtimefield'); + await fieldEditor.confirmDelete(); + await PageObjects.lens.waitForFieldMissing('runtimefield'); + }); + + it('should display url formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.URL); + await fieldEditor.setUrlFieldFormat('https://www.elastic.co?{{value}}'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtimefield'); + await PageObjects.lens.dragFieldToWorkspace('runtimefield'); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( + 'Top 5 values of runtimefield' + ); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('https://www.elastic.co?CN'); + }); + + it('should display static lookup formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.STATIC_LOOKUP); + await fieldEditor.setStaticLookupFormat('CN', 'China'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('China'); + }); + + it('should display color formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.COLOR); + await fieldEditor.setColorFormat('CN', '#ffffff', '#ff0000'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + const styleObj = await PageObjects.lens.getDatatableCellSpanStyle(0, 0); + expect(styleObj['background-color']).to.be('rgb(255, 0, 0)'); + expect(styleObj.color).to.be('rgb(255, 255, 255)'); + }); + + it('should display string formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.STRING); + await fieldEditor.setStringFormat('lower'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('cn'); + }); + + it('should display truncate string formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['links.raw'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.TRUNCATE); + await fieldEditor.setTruncateFormatLength('3'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('dal...'); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/group1/index.ts b/x-pack/test/functional/apps/lens/group1/index.ts index 03f4a4032154e..a129a66c519d9 100644 --- a/x-pack/test/functional/apps/lens/group1/index.ts +++ b/x-pack/test/functional/apps/lens/group1/index.ts @@ -83,6 +83,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./text_based_languages')); loadTestFile(require.resolve('./fields_list')); loadTestFile(require.resolve('./layer_actions')); + loadTestFile(require.resolve('./field_formatters')); } }); }; diff --git a/x-pack/test/functional/page_objects/grok_debugger_page.ts b/x-pack/test/functional/page_objects/grok_debugger_page.ts index 06848c6b9ed3b..b7bad6ed57b9d 100644 --- a/x-pack/test/functional/page_objects/grok_debugger_page.ts +++ b/x-pack/test/functional/page_objects/grok_debugger_page.ts @@ -9,7 +9,7 @@ import { FtrService } from '../ftr_provider_context'; export class GrokDebuggerPageObject extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); - private readonly aceEditor = this.ctx.getService('aceEditor'); + private readonly monacoEditor = this.ctx.getService('monacoEditor'); private readonly retry = this.ctx.getService('retry'); async simulateButton() { @@ -17,30 +17,19 @@ export class GrokDebuggerPageObject extends FtrService { } async getEventOutput() { - return await this.aceEditor.getValue( - 'grokDebuggerContainer > aceEventOutput > codeEditorContainer' - ); + return await this.testSubjects.getVisibleText('eventOutputCodeBlock'); } async setEventInput(value: string) { - await this.aceEditor.setValue( - 'grokDebuggerContainer > aceEventInput > codeEditorContainer', - value - ); + await this.monacoEditor.setCodeEditorValue(value, 0); } async setPatternInput(pattern: string) { - await this.aceEditor.setValue( - 'grokDebuggerContainer > acePatternInput > codeEditorContainer', - pattern - ); + await this.monacoEditor.setCodeEditorValue(pattern, 1); } async setCustomPatternInput(customPattern: string) { - await this.aceEditor.setValue( - 'grokDebuggerContainer > aceCustomPatternsInput > codeEditorContainer', - customPattern - ); + await this.monacoEditor.setCodeEditorValue(customPattern, 2); } async toggleSetCustomPattern() { diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index acd78cb0da0ea..6db18af0abeaa 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -1045,6 +1045,18 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, {}); }, + async getDatatableCellSpanStyle(rowIndex = 0, colIndex = 0) { + const el = await (await this.getDatatableCell(rowIndex, colIndex)).findByCssSelector('span'); + const styleString = await el.getAttribute('style'); + return styleString.split(';').reduce>((memo, cssLine) => { + const [prop, value] = cssLine.split(':'); + if (prop && value) { + memo[prop.trim()] = value.trim(); + } + return memo; + }, {}); + }, + async getCountOfDatatableColumns() { const table = await find.byCssSelector('.euiDataGrid'); const $ = await table.parseDomContent(); diff --git a/x-pack/test/functional/services/actions/index.ts b/x-pack/test/functional/services/actions/index.ts index c7539c37f2c32..7218f3079aafb 100644 --- a/x-pack/test/functional/services/actions/index.ts +++ b/x-pack/test/functional/services/actions/index.ts @@ -10,6 +10,7 @@ import { ActionsCommonServiceProvider } from './common'; import { ActionsOpsgenieServiceProvider } from './opsgenie'; import { ActionsTinesServiceProvider } from './tines'; import { ActionsAPIServiceProvider } from './api'; +import { ActionsSlackServiceProvider } from './slack'; export function ActionsServiceProvider(context: FtrProviderContext) { const common = ActionsCommonServiceProvider(context); @@ -19,5 +20,6 @@ export function ActionsServiceProvider(context: FtrProviderContext) { common: ActionsCommonServiceProvider(context), opsgenie: ActionsOpsgenieServiceProvider(context, common), tines: ActionsTinesServiceProvider(context, common), + slack: ActionsSlackServiceProvider(context, common), }; } diff --git a/x-pack/test/functional/services/actions/slack.ts b/x-pack/test/functional/services/actions/slack.ts new file mode 100644 index 0000000000000..b4297644c7c93 --- /dev/null +++ b/x-pack/test/functional/services/actions/slack.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import type { ActionsCommon } from './common'; + +export interface WebhookConnectorFormFields { + name: string; + url: string; +} + +export interface WebApiConnectorFormFields { + name: string; + token: string; +} + +export function ActionsSlackServiceProvider( + { getService }: FtrProviderContext, + common: ActionsCommon +) { + const testSubjects = getService('testSubjects'); + + return { + async createNewWebhook({ name, url }: WebhookConnectorFormFields) { + await common.openNewConnectorForm('slack'); + + await testSubjects.setValue('nameInput', name); + await testSubjects.setValue('slackWebhookUrlInput', url); + + const flyOutSaveButton = await testSubjects.find('create-connector-flyout-save-btn'); + expect(await flyOutSaveButton.isEnabled()).to.be(true); + await flyOutSaveButton.click(); + }, + async createNewWebAPI({ name, token }: WebApiConnectorFormFields) { + await common.openNewConnectorForm('slack'); + + const webApiTab = await testSubjects.find('.slack_apiButton'); + await webApiTab.click(); + + await testSubjects.setValue('nameInput', name); + await testSubjects.setValue('secrets.token-input', token); + + const flyOutSaveButton = await testSubjects.find('create-connector-flyout-save-btn'); + expect(await flyOutSaveButton.isEnabled()).to.be(true); + await flyOutSaveButton.click(); + }, + }; +} diff --git a/x-pack/test/functional/services/cases/single_case_view.ts b/x-pack/test/functional/services/cases/single_case_view.ts index f5c34feedf811..237881f2af78b 100644 --- a/x-pack/test/functional/services/cases/single_case_view.ts +++ b/x-pack/test/functional/services/cases/single_case_view.ts @@ -101,7 +101,7 @@ export function CasesSingleViewServiceProvider({ getService, getPageObject }: Ft async assertCaseDescription(expectedDescription: string) { const desc = await find.byCssSelector( - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]' ); const actualDescription = await desc.getVisibleText(); diff --git a/x-pack/test/functional/services/grok_debugger.js b/x-pack/test/functional/services/grok_debugger.js index 42a80edd70c85..618353130c20e 100644 --- a/x-pack/test/functional/services/grok_debugger.js +++ b/x-pack/test/functional/services/grok_debugger.js @@ -8,18 +8,14 @@ import expect from '@kbn/expect'; export function GrokDebuggerProvider({ getService }) { - const aceEditor = getService('aceEditor'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const monacoEditor = getService('monacoEditor'); // test subject selectors const SUBJ_CONTAINER = 'grokDebuggerContainer'; - const SUBJ_UI_ACE_EVENT_INPUT = `${SUBJ_CONTAINER} > aceEventInput > codeEditorContainer`; const SUBJ_UI_ACE_PATTERN_INPUT = `${SUBJ_CONTAINER} > acePatternInput > codeEditorContainer`; - const SUBJ_UI_ACE_CUSTOM_PATTERNS_INPUT = `${SUBJ_CONTAINER} > aceCustomPatternsInput > codeEditorContainer`; - const SUBJ_UI_ACE_EVENT_OUTPUT = `${SUBJ_CONTAINER} > aceEventOutput > codeEditorContainer`; - const SUBJ_BTN_TOGGLE_CUSTOM_PATTERNS_INPUT = `${SUBJ_CONTAINER} > btnToggleCustomPatternsInput`; const SUBJ_BTN_SIMULATE = `${SUBJ_CONTAINER} > btnSimulate`; @@ -29,11 +25,11 @@ export function GrokDebuggerProvider({ getService }) { } async setEventInput(value) { - await aceEditor.setValue(SUBJ_UI_ACE_EVENT_INPUT, value); + await monacoEditor.setCodeEditorValue(value, 0); } async setPatternInput(value) { - await aceEditor.setValue(SUBJ_UI_ACE_PATTERN_INPUT, value); + await monacoEditor.setCodeEditorValue(value, 1); } async toggleCustomPatternsInput() { @@ -41,11 +37,11 @@ export function GrokDebuggerProvider({ getService }) { } async setCustomPatternsInput(value) { - await aceEditor.setValue(SUBJ_UI_ACE_CUSTOM_PATTERNS_INPUT, value); + await monacoEditor.setCodeEditorValue(value, 2); } async getEventOutput() { - return await aceEditor.getValue(SUBJ_UI_ACE_EVENT_OUTPUT); + return await testSubjects.getVisibleText('eventOutputCodeBlock'); } async assertExists() { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index 40cbbc53c5596..8823f5144a0fc 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -51,7 +51,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { expect(await title.getVisibleText()).equal(caseTitle); // validate description - const description = await testSubjects.find('user-action-markdown'); + const description = await testSubjects.find('scrollable-markdown'); expect(await description.getVisibleText()).equal('test description'); // validate tag exists diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index d0df92ac4f7e7..233157e57e518 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -58,7 +58,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -207,7 +207,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -244,7 +244,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -266,7 +266,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -291,7 +291,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); const editCommentTextArea = await find.byCssSelector( - '[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea' + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' ); await header.waitUntilLoadingHasFinished(); @@ -307,12 +307,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('shows unsaved description message when page is refreshed', async () => { - await testSubjects.click('editable-description-edit-icon'); + await testSubjects.click('description-edit-icon'); await header.waitUntilLoadingHasFinished(); const editCommentTextArea = await find.byCssSelector( - '[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea' + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' ); await header.waitUntilLoadingHasFinished(); @@ -320,6 +320,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await editCommentTextArea.focus(); await editCommentTextArea.type('Edited description'); + await header.waitUntilLoadingHasFinished(); + await browser.refresh(); await header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts index 561bbae70bcca..93d12b5d908da 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts @@ -86,7 +86,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the description correctly', async () => { const desc = await find.byCssSelector( - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]' ); expect(await desc.getVisibleText()).equal(`Testing upgrade! Let's see how it goes.`); @@ -112,7 +112,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the first comment correctly', async () => { const comment = await find.byCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await comment.getVisibleText()).equal(`This is interesting. I am curious also.`); @@ -127,7 +127,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the second comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const secondComment = comments[1]; @@ -140,7 +140,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the third comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const thirdComment = comments[2]; @@ -200,7 +200,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the fourth comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const thirdComment = comments[3]; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts index 1d6420004c0cd..c246f92309d2d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts @@ -12,5 +12,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./opsgenie')); loadTestFile(require.resolve('./tines')); + loadTestFile(require.resolve('./slack')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts new file mode 100644 index 0000000000000..f975bed9f965e --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { ObjectRemover } from '../../../lib/object_remover'; +import { generateUniqueKey } from '../../../lib/get_test_data'; +import { createSlackConnectorAndObjectRemover, getConnectorByName } from './utils'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const retry = getService('retry'); + const supertest = getService('supertest'); + const actions = getService('actions'); + const rules = getService('rules'); + let objectRemover: ObjectRemover; + + describe('Slack', () => { + before(async () => { + objectRemover = await createSlackConnectorAndObjectRemover({ getService }); + }); + + after(async () => { + await objectRemover.removeAll(); + }); + + describe('connector page', () => { + beforeEach(async () => { + await pageObjects.common.navigateToApp('triggersActionsConnectors'); + }); + + it('should only show one slack connector', async () => { + if (await testSubjects.exists('createActionButton')) { + await testSubjects.click('createActionButton'); + } else { + await testSubjects.click('createFirstActionButton'); + } + await testSubjects.existOrFail('.slack-card'); + const slackApiCardExists = await testSubjects.exists('.slack_api-card'); + expect(slackApiCardExists).to.be(false); + }); + + it('should create the webhook connector', async () => { + const connectorName = generateUniqueKey(); + await actions.slack.createNewWebhook({ + name: connectorName, + url: 'https://test.com', + }); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Slack', + }, + ]); + const connector = await getConnectorByName(connectorName, supertest); + objectRemover.add(connector.id, 'action', 'actions'); + }); + + it('should create the web api connector', async () => { + const connectorName = generateUniqueKey(); + await actions.slack.createNewWebAPI({ + name: connectorName, + token: 'supersecrettoken', + }); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Slack API', + }, + ]); + const connector = await getConnectorByName(connectorName, supertest); + objectRemover.add(connector.id, 'action', 'actions'); + }); + }); + + describe('rule creation', async () => { + const webhookConnectorName = generateUniqueKey(); + const webApiConnectorName = generateUniqueKey(); + let webApiAction: { id: string }; + let webhookAction: { id: string }; + + const setupRule = async () => { + const ruleName = generateUniqueKey(); + await retry.try(async () => { + await rules.common.defineIndexThresholdAlert(ruleName); + }); + return ruleName; + }; + + const getRuleIdByName = async (name: string) => { + const response = await supertest + .get(`/api/alerts/_find?search=${name}&search_fields=name`) + .expect(200); + return response.body.data[0].id; + }; + + const selectSlackConnectorInRuleAction = async ({ connectorId }: { connectorId: string }) => { + await testSubjects.click('.slack-alerting-ActionTypeSelectOption'); // "Slack" in connector list + await testSubjects.click('selectActionConnector-.slack-0'); + await testSubjects.click(`dropdown-connector-${connectorId}`); + }; + + before(async () => { + webApiAction = await actions.api.createConnector({ + name: webApiConnectorName, + config: {}, + secrets: { token: 'supersecrettoken' }, + connectorTypeId: '.slack_api', + }); + + webhookAction = await actions.api.createConnector({ + name: webhookConnectorName, + config: {}, + secrets: { webhookUrl: 'https://test.com' }, + connectorTypeId: '.slack', + }); + + objectRemover.add(webhookAction.id, 'action', 'actions'); + objectRemover.add(webApiAction.id, 'action', 'actions'); + await pageObjects.common.navigateToApp('triggersActions'); + }); + + it('should save webhook type slack connectors', async () => { + const ruleName = await setupRule(); + + await selectSlackConnectorInRuleAction({ + connectorId: webhookAction.id, + }); + await testSubjects.click('saveRuleButton'); + await pageObjects.triggersActionsUI.searchAlerts(ruleName); + + const ruleId = await getRuleIdByName(ruleName); + objectRemover.add(ruleId, 'rule', 'alerting'); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + duration: '00:00', + interval: '1 min', + name: `${ruleName}Index threshold`, + tags: '', + }, + ]); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created rule "${ruleName}"`); + }); + + it('should save webapi type slack connectors', async () => { + await setupRule(); + await selectSlackConnectorInRuleAction({ + connectorId: webApiAction.id, + }); + + await testSubjects.click('saveRuleButton'); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql('Failed to retrieve Slack channels list'); + + // We are not saving the rule yet as we currently have no way + // to mock the internal request that loads the channels list + // uncomment once we have a way to mock the request + + // const ruleName = await setupRule(); + // await selectSlackConnectorInRuleAction({ + // connectorId: webApiAction.id, + // }); + + // await testSubjects.click('saveRuleButton'); + // await pageObjects.triggersActionsUI.searchAlerts(ruleName); + + // const ruleId = await getRuleIdByName(ruleName); + // objectRemover.add(ruleId, 'rule', 'alerting'); + + // const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + // expect(searchResults).to.eql([ + // { + // duration: '00:00', + // interval: '1 min', + // name: `${ruleName}Index threshold`, + // tags: '', + // }, + // ]); + // const toastTitle = await pageObjects.common.closeToast(); + // expect(toastTitle).to.eql(`Created rule "${ruleName}"`); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.base.ts b/x-pack/test/functional_with_es_ssl/config.base.ts index 71039b211f5db..533fec1944b67 100644 --- a/x-pack/test/functional_with_es_ssl/config.base.ts +++ b/x-pack/test/functional_with_es_ssl/config.base.ts @@ -24,6 +24,7 @@ const enabledActionTypes = [ '.servicenow', '.servicenow-sir', '.slack', + '.slack_api', '.tines', '.webhook', 'test.authorization', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 67478db2c8c00..6bc6b02013ed0 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -57,6 +57,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.servicenow-itom', 'actions:.servicenow-sir', 'actions:.slack', + 'actions:.slack_api', 'actions:.swimlane', 'actions:.teams', 'actions:.tines', diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap b/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap index e76a791652f93..a66368fe5a8ec 100644 --- a/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap @@ -21,10 +21,10 @@ Object { false, ], "kibana.alert.instance.id": Array [ - "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", + "opbeans-go_ENVIRONMENT_NOT_DEFINED_request", ], "kibana.alert.reason": Array [ - "Failed transactions is 50% in the last 5 mins for opbeans-go. Alert when > 30%.", + "Failed transactions is 50% in the last 5 mins for service: opbeans-go, env: Not defined, type: request. Alert when > 30%.", ], "kibana.alert.rule.category": Array [ "Failed transaction rate threshold", @@ -70,6 +70,9 @@ Object { "processor.event": Array [ "transaction", ], + "service.environment": Array [ + "ENVIRONMENT_NOT_DEFINED", + ], "service.name": Array [ "opbeans-go", ], @@ -101,10 +104,10 @@ Object { false, ], "kibana.alert.instance.id": Array [ - "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", + "opbeans-go_ENVIRONMENT_NOT_DEFINED_request", ], "kibana.alert.reason": Array [ - "Failed transactions is 50% in the last 5 mins for opbeans-go. Alert when > 30%.", + "Failed transactions is 50% in the last 5 mins for service: opbeans-go, env: Not defined, type: request. Alert when > 30%.", ], "kibana.alert.rule.category": Array [ "Failed transaction rate threshold", @@ -150,6 +153,9 @@ Object { "processor.event": Array [ "transaction", ], + "service.environment": Array [ + "ENVIRONMENT_NOT_DEFINED", + ], "service.name": Array [ "opbeans-go", ], diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index f1722d291fc1b..407f0fd182fe7 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -73,7 +73,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", @@ -226,7 +225,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index a787729a16c35..340a1003d50a9 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -73,7 +73,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", @@ -213,7 +212,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 2e92bd5f38c3d..a11346427456f 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -122,5 +122,6 @@ "@kbn/securitysolution-io-ts-alerting-types", "@kbn/alerting-state-types", "@kbn/assetManager-plugin", + "@kbn/field-formats-plugin", ] }