diff --git a/docs/user/alerting/defining-alerts.asciidoc b/docs/user/alerting/defining-alerts.asciidoc index ed0e2966b2288..acfb26d2e11b3 100644 --- a/docs/user/alerting/defining-alerts.asciidoc +++ b/docs/user/alerting/defining-alerts.asciidoc @@ -22,12 +22,16 @@ image::images/alert-flyout-sections.png[The three sections of an alert definitio All alert share the following four properties in common: [role="screenshot"] -image::images/alert-flyout-general-details.png[alt='All alerts have name, tags, check every, and notify every properties in common'] +image::images/alert-flyout-general-details.png[alt='All alerts have name, tags, check every, and notify properties in common'] Name:: The name of the alert. While this name does not have to be unique, the name can be referenced in actions and also appears in the searchable alert listing in the management UI. A distinctive name can help identify and find an alert. Tags:: A list of tag names that can be applied to an alert. Tags can help you organize and find alerts, because tags appear in the alert listing in the management UI which is searchable by tag. Check every:: This value determines how frequently the alert conditions below are checked. Note that the timing of background alert checks are not guaranteed, particularly for intervals of less than 10 seconds. See <> for more information. -Notify every:: This value limits how often actions are repeated when an alert instance remains active across alert checks. See <> for more information. +Notify:: This value limits how often actions are repeated when an alert instance remains active across alert checks. See <> for more information. + +- **Only on status change**: Actions are not repeated when an alert instance remains active across checks. Actions run only when the alert status changes. +- **Every time alert is active**: Actions are repeated when an alert instance remains active across checks. +- **On a custom action interval**: Actions are suppressed for the throttle interval, but repeat when an alert instance remains active across checks for a duration longer than the throttle interval. + [float] [[defining-alerts-type-conditions]] diff --git a/docs/user/alerting/images/alert-flyout-alert-conditions.png b/docs/user/alerting/images/alert-flyout-alert-conditions.png index 8e0eff0224363..2065cbd117b75 100644 Binary files a/docs/user/alerting/images/alert-flyout-alert-conditions.png and b/docs/user/alerting/images/alert-flyout-alert-conditions.png differ diff --git a/docs/user/alerting/images/alert-flyout-alert-type-selection.png b/docs/user/alerting/images/alert-flyout-alert-type-selection.png index ccd3f07f07c94..4e5c49975160f 100644 Binary files a/docs/user/alerting/images/alert-flyout-alert-type-selection.png and b/docs/user/alerting/images/alert-flyout-alert-type-selection.png differ diff --git a/docs/user/alerting/images/alert-flyout-general-details.png b/docs/user/alerting/images/alert-flyout-general-details.png index 883c2348ecc8a..27b5144efbbea 100644 Binary files a/docs/user/alerting/images/alert-flyout-general-details.png and b/docs/user/alerting/images/alert-flyout-general-details.png differ diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 38c2caedd251e..47a2fa19e7a8e 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,6 +9,7 @@ "kbn:watch": "node scripts/build --dev --watch" }, "dependencies": { + "@kbn/analytics": "link:../kbn-analytics", "@kbn/i18n": "link:../kbn-i18n", "@kbn/monaco": "link:../kbn-monaco" }, diff --git a/src/core/server/logging/README.mdx b/src/core/server/logging/README.mdx index 35a4549c24796..6fd730f47dc07 100644 --- a/src/core/server/logging/README.mdx +++ b/src/core/server/logging/README.mdx @@ -22,6 +22,7 @@ tags: ['kibana','dev', 'contributor', 'api docs'] - [Configuration](#configuration) - [Usage](#usage) - [Logging config migration](#logging-config-migration) +- [Logging configuration via CLI](#logging-configuration-via-CLI) - [Log record format changes](#log-record-format-changes) The way logging works in Kibana is inspired by `log4j 2` logging framework used by [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html#logging). @@ -540,6 +541,16 @@ and you can enable them by adjusting the minimum required [logging level](#log-l #### logging.filter TBD +### Logging configuration via CLI + +| legacy logging | Kibana Platform logging| +|-|-| +|--verbose| --logging.root.level=debug --logging.root.appenders[0]=default --logging.root.appenders[1]=console| +|--quiet| --logging.root.level=error --logging.root.appenders[0]=default --logging.root.appenders[1]=console| +|--silent| --logging.root.level=off| + +*note that you have to pass the `default` appender until the legacy logging system is removed in v8.0 + ### Log record format changes | Parameter | Platform log record in **pattern** format | Legacy Platform log record **text** format | diff --git a/src/core/server/ui_settings/integration_tests/routes.test.ts b/src/core/server/ui_settings/integration_tests/routes.test.ts index 9c38b9e21f2c4..89b08f35ca364 100644 --- a/src/core/server/ui_settings/integration_tests/routes.test.ts +++ b/src/core/server/ui_settings/integration_tests/routes.test.ts @@ -9,8 +9,7 @@ import { schema } from '@kbn/config-schema'; import * as kbnTestServer from '../../../test_helpers/kbn_server'; -// FLAKY: https://github.com/elastic/kibana/issues/89191 -describe.skip('ui settings service', () => { +describe('ui settings service', () => { describe('routes', () => { let root: ReturnType; beforeAll(async () => { diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index 611b9bbc51f14..3a4ec83e28963 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -67,6 +67,8 @@ export async function mountManagementSection( chrome.setBadge(readOnlyBadge); } + chrome.docTitle.change(title); + ReactDOM.render( @@ -90,6 +92,7 @@ export async function mountManagementSection( params.element ); return () => { + chrome.docTitle.reset(); ReactDOM.unmountComponentAtNode(params.element); }; } diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index b855850ed185d..a21ad6b7a440a 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -103,6 +103,7 @@ export const mountManagementSection = async ({ ); return () => { + coreStart.chrome.docTitle.reset(); ReactDOM.unmountComponentAtNode(element); }; }; diff --git a/test/scripts/jenkins_security_solution_cypress.sh b/test/scripts/jenkins_security_solution_cypress_chrome.sh similarity index 96% rename from test/scripts/jenkins_security_solution_cypress.sh rename to test/scripts/jenkins_security_solution_cypress_chrome.sh index c018b632706b1..f29d9536f1502 100755 --- a/test/scripts/jenkins_security_solution_cypress.sh +++ b/test/scripts/jenkins_security_solution_cypress_chrome.sh @@ -5,7 +5,7 @@ source test/scripts/jenkins_test_setup_xpack.sh echo " -> Running security solution cypress tests" cd "$XPACK_DIR" -checks-reporter-with-killswitch "Security Solution Cypress Tests" \ +checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome)" \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$KIBANA_INSTALL_DIR" \ diff --git a/test/scripts/jenkins_security_solution_cypress_firefox.sh b/test/scripts/jenkins_security_solution_cypress_firefox.sh new file mode 100755 index 0000000000000..af8f51d5796f7 --- /dev/null +++ b/test/scripts/jenkins_security_solution_cypress_firefox.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup_xpack.sh + +echo " -> Running security solution cypress tests" +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "Security Solution Cypress Tests (Firefox)" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --config test/security_solution_cypress/config.firefox.ts + +echo "" +echo "" diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 846eed85fb076..68e3b05f36a0b 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -118,7 +118,8 @@ def functionalXpack(Map params = [:]) { 'x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx', ]) { if (githubPr.isPr()) { - task(kibanaPipeline.functionalTestProcess('xpack-securitySolutionCypress', './test/scripts/jenkins_security_solution_cypress.sh')) + task(kibanaPipeline.functionalTestProcess('xpack-securitySolutionCypressChrome', './test/scripts/jenkins_security_solution_cypress_chrome.sh')) + task(kibanaPipeline.functionalTestProcess('xpack-securitySolutionCypressFirefox', './test/scripts/jenkins_security_solution_cypress_firefox.sh')) } } } diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index acbd0398b59ba..cc1b6688daa46 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -121,6 +121,28 @@ exports[`Error POD_NAME 1`] = `undefined`; exports[`Error PROCESSOR_EVENT 1`] = `"error"`; +exports[`Error PROFILE_ALLOC_OBJECTS 1`] = `undefined`; + +exports[`Error PROFILE_ALLOC_SPACE 1`] = `undefined`; + +exports[`Error PROFILE_CPU_NS 1`] = `undefined`; + +exports[`Error PROFILE_DURATION 1`] = `undefined`; + +exports[`Error PROFILE_ID 1`] = `undefined`; + +exports[`Error PROFILE_INUSE_OBJECTS 1`] = `undefined`; + +exports[`Error PROFILE_INUSE_SPACE 1`] = `undefined`; + +exports[`Error PROFILE_SAMPLES_COUNT 1`] = `undefined`; + +exports[`Error PROFILE_STACK 1`] = `undefined`; + +exports[`Error PROFILE_TOP_ID 1`] = `undefined`; + +exports[`Error PROFILE_WALL_US 1`] = `undefined`; + exports[`Error SERVICE 1`] = ` Object { "language": Object { @@ -330,6 +352,28 @@ exports[`Span POD_NAME 1`] = `undefined`; exports[`Span PROCESSOR_EVENT 1`] = `"span"`; +exports[`Span PROFILE_ALLOC_OBJECTS 1`] = `undefined`; + +exports[`Span PROFILE_ALLOC_SPACE 1`] = `undefined`; + +exports[`Span PROFILE_CPU_NS 1`] = `undefined`; + +exports[`Span PROFILE_DURATION 1`] = `undefined`; + +exports[`Span PROFILE_ID 1`] = `undefined`; + +exports[`Span PROFILE_INUSE_OBJECTS 1`] = `undefined`; + +exports[`Span PROFILE_INUSE_SPACE 1`] = `undefined`; + +exports[`Span PROFILE_SAMPLES_COUNT 1`] = `undefined`; + +exports[`Span PROFILE_STACK 1`] = `undefined`; + +exports[`Span PROFILE_TOP_ID 1`] = `undefined`; + +exports[`Span PROFILE_WALL_US 1`] = `undefined`; + exports[`Span SERVICE 1`] = ` Object { "name": "service name", @@ -545,6 +589,28 @@ exports[`Transaction POD_NAME 1`] = `undefined`; exports[`Transaction PROCESSOR_EVENT 1`] = `"transaction"`; +exports[`Transaction PROFILE_ALLOC_OBJECTS 1`] = `undefined`; + +exports[`Transaction PROFILE_ALLOC_SPACE 1`] = `undefined`; + +exports[`Transaction PROFILE_CPU_NS 1`] = `undefined`; + +exports[`Transaction PROFILE_DURATION 1`] = `undefined`; + +exports[`Transaction PROFILE_ID 1`] = `undefined`; + +exports[`Transaction PROFILE_INUSE_OBJECTS 1`] = `undefined`; + +exports[`Transaction PROFILE_INUSE_SPACE 1`] = `undefined`; + +exports[`Transaction PROFILE_SAMPLES_COUNT 1`] = `undefined`; + +exports[`Transaction PROFILE_STACK 1`] = `undefined`; + +exports[`Transaction PROFILE_TOP_ID 1`] = `undefined`; + +exports[`Transaction PROFILE_WALL_US 1`] = `undefined`; + exports[`Transaction SERVICE 1`] = ` Object { "language": Object { diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 6ecb1b0a7b097..ffd05b281208d 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -132,3 +132,17 @@ export const LCP_FIELD = 'transaction.marks.agent.largestContentfulPaint'; export const TBT_FIELD = 'transaction.experience.tbt'; export const FID_FIELD = 'transaction.experience.fid'; export const CLS_FIELD = 'transaction.experience.cls'; + +export const PROFILE_ID = 'profile.id'; +export const PROFILE_DURATION = 'profile.duration'; +export const PROFILE_TOP_ID = 'profile.top.id'; +export const PROFILE_STACK = 'profile.stack'; + +export const PROFILE_SAMPLES_COUNT = 'profile.samples.count'; +export const PROFILE_CPU_NS = 'profile.cpu.ns'; +export const PROFILE_WALL_US = 'profile.wall.us'; + +export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count'; +export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes'; +export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count'; +export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes'; diff --git a/x-pack/plugins/apm/common/processor_event.ts b/x-pack/plugins/apm/common/processor_event.ts index 9eb9ee60c1998..57705e7ed4ce0 100644 --- a/x-pack/plugins/apm/common/processor_event.ts +++ b/x-pack/plugins/apm/common/processor_event.ts @@ -10,6 +10,7 @@ export enum ProcessorEvent { error = 'error', metric = 'metric', span = 'span', + profile = 'profile', } /** * Processor events that are searchable in the UI via the query bar. diff --git a/x-pack/plugins/apm/common/profiling.ts b/x-pack/plugins/apm/common/profiling.ts new file mode 100644 index 0000000000000..e185ef704aed0 --- /dev/null +++ b/x-pack/plugins/apm/common/profiling.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { + PROFILE_ALLOC_OBJECTS, + PROFILE_ALLOC_SPACE, + PROFILE_CPU_NS, + PROFILE_INUSE_OBJECTS, + PROFILE_INUSE_SPACE, + PROFILE_SAMPLES_COUNT, + PROFILE_WALL_US, +} from './elasticsearch_fieldnames'; + +export enum ProfilingValueType { + wallTime = 'wall_time', + cpuTime = 'cpu_time', + samples = 'samples', + allocObjects = 'alloc_objects', + allocSpace = 'alloc_space', + inuseObjects = 'inuse_objects', + inuseSpace = 'inuse_space', +} + +export enum ProfilingValueTypeUnit { + ns = 'ns', + us = 'us', + count = 'count', + bytes = 'bytes', +} + +export interface ProfileNode { + id: string; + label: string; + fqn: string; + value: number; + children: string[]; +} + +const config = { + [ProfilingValueType.wallTime]: { + unit: ProfilingValueTypeUnit.us, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.wallTime', + { + defaultMessage: 'Wall', + } + ), + field: PROFILE_WALL_US, + }, + [ProfilingValueType.cpuTime]: { + unit: ProfilingValueTypeUnit.ns, + label: i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.cpuTime', { + defaultMessage: 'On-CPU', + }), + field: PROFILE_CPU_NS, + }, + [ProfilingValueType.samples]: { + unit: ProfilingValueTypeUnit.count, + label: i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.samples', { + defaultMessage: 'Samples', + }), + field: PROFILE_SAMPLES_COUNT, + }, + [ProfilingValueType.allocObjects]: { + unit: ProfilingValueTypeUnit.count, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.allocObjects', + { + defaultMessage: 'Alloc. objects', + } + ), + field: PROFILE_ALLOC_OBJECTS, + }, + [ProfilingValueType.allocSpace]: { + unit: ProfilingValueTypeUnit.bytes, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.allocSpace', + { + defaultMessage: 'Alloc. space', + } + ), + field: PROFILE_ALLOC_SPACE, + }, + [ProfilingValueType.inuseObjects]: { + unit: ProfilingValueTypeUnit.count, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.inuseObjects', + { + defaultMessage: 'In-use objects', + } + ), + field: PROFILE_INUSE_OBJECTS, + }, + [ProfilingValueType.inuseSpace]: { + unit: ProfilingValueTypeUnit.bytes, + label: i18n.translate( + 'xpack.apm.serviceProfiling.valueTypeLabel.inuseSpace', + { + defaultMessage: 'In-use space', + } + ), + field: PROFILE_INUSE_SPACE, + }, +}; + +export const getValueTypeConfig = (type: ProfilingValueType) => { + return config[type]; +}; diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 82fabff610191..5094287a402ea 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -8,6 +8,7 @@ exports[`Home component should render services 1`] = ` "setHeaderActionMenu": [Function], }, "config": Object { + "profilingEnabled": false, "serviceMapEnabled": true, "ui": Object { "enabled": false, @@ -95,6 +96,7 @@ exports[`Home component should render traces 1`] = ` "setHeaderActionMenu": [Function], }, "config": Object { + "profilingEnabled": false, "serviceMapEnabled": true, "ui": Object { "enabled": false, diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index 08d95aca24714..a7cbd7a79b4a7 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -114,6 +114,12 @@ function ServiceDetailsTransactions( return ; } +function ServiceDetailsProfiling( + props: RouteComponentProps<{ serviceName: string }> +) { + return ; +} + function SettingsAgentConfiguration(props: RouteComponentProps<{}>) { return ( @@ -307,6 +313,14 @@ export const routes: APMRouteDefinition[] = [ return query.transactionName as string; }, }, + { + exact: true, + path: '/services/:serviceName/profiling', + component: withApmServiceContext(ServiceDetailsProfiling), + breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceProfilingTitle', { + defaultMessage: 'Profiling', + }), + }, { exact: true, path: '/services/:serviceName/service-map', diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index d2d5c9f6f3a9a..5c9d79f37cc57 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -8,6 +8,9 @@ import { EuiTab } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactNode } from 'react'; +import { EuiBetaBadge } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; import { enableServiceOverview } from '../../../../common/ui_settings_keys'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; @@ -19,6 +22,7 @@ import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; +import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; import { MainTabs } from '../../shared/main_tabs'; import { ErrorGroupOverview } from '../error_group_overview'; import { ServiceMap } from '../ServiceMap'; @@ -26,12 +30,13 @@ import { ServiceNodeOverview } from '../service_node_overview'; import { ServiceMetrics } from '../service_metrics'; import { ServiceOverview } from '../service_overview'; import { TransactionOverview } from '../transaction_overview'; +import { ServiceProfiling } from '../service_profiling'; import { Correlations } from '../correlations'; interface Tab { key: string; href: string; - text: string; + text: ReactNode; render: () => ReactNode; } @@ -43,12 +48,16 @@ interface Props { | 'nodes' | 'overview' | 'service-map' + | 'profiling' | 'transactions'; } export function ServiceDetailTabs({ serviceName, tab }: Props) { const { agentName } = useApmServiceContext(); - const { uiSettings } = useApmPluginContext().core; + const { + core: { uiSettings }, + config, + } = useApmPluginContext(); const { urlParams: { latencyAggregationType }, } = useUrlParams(); @@ -114,6 +123,38 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { ) : null, }; + const profilingTab = { + key: 'profiling', + href: useServiceProfilingHref({ serviceName }), + text: ( + + + {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { + defaultMessage: 'Profiling', + })} + + + + + + ), + render: () => , + }; + const tabs: Tab[] = [transactionsTab, errorsTab]; if (uiSettings.get(enableServiceOverview)) { @@ -128,6 +169,10 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { tabs.push(serviceMapTab); + if (config.profilingEnabled) { + tabs.push(profilingTab); + } + const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); return ( diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx new file mode 100644 index 0000000000000..09a42f9b2df90 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -0,0 +1,138 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { SearchBar } from '../../shared/search_bar'; +import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; +import { ServiceProfilingTimeline } from './service_profiling_timeline'; + +interface ServiceProfilingProps { + serviceName: string; + environment?: string; +} + +export function ServiceProfiling({ + serviceName, + environment, +}: ServiceProfilingProps) { + const { + urlParams: { start, end }, + uiFilters, + } = useUrlParams(); + + const { data = [] } = useFetcher( + (callApmApi) => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', + params: { + path: { serviceName }, + query: { + start, + end, + environment, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + }, + [start, end, serviceName, environment, uiFilters] + ); + + const [valueType, setValueType] = useState(); + + useEffect(() => { + if (!data.length) { + return; + } + + const availableValueTypes = data.reduce((set, point) => { + (Object.keys(point.valueTypes).filter( + (type) => type !== 'unknown' + ) as ProfilingValueType[]) + .filter((type) => point.valueTypes[type] > 0) + .forEach((type) => { + set.add(type); + }); + + return set; + }, new Set()); + + if (!valueType || !availableValueTypes.has(valueType)) { + setValueType(Array.from(availableValueTypes)[0]); + } + }, [data, valueType]); + + return ( + <> + + + + + +

+ {i18n.translate('xpack.apm.profilingOverviewTitle', { + defaultMessage: 'Profiling', + })} +

+
+
+ + + + + { + setValueType(type); + }} + selectedValueType={valueType} + /> + + {valueType ? ( + + +

{getValueTypeConfig(valueType).label}

+
+
+ ) : null} + + + +
+
+
+
+
+ + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx new file mode 100644 index 0000000000000..03248d2836674 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx @@ -0,0 +1,419 @@ +/* + * 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 { + Chart, + Datum, + Partition, + PartitionLayout, + PrimitiveValue, + Settings, + TooltipInfo, +} from '@elastic/charts'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + euiPaletteForTemperature, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { find, sumBy } from 'lodash'; +import { rgba } from 'polished'; +import React, { useMemo, useState } from 'react'; +import seedrandom from 'seedrandom'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; +import { useChartTheme } from '../../../../../observability/public'; +import { + getValueTypeConfig, + ProfileNode, + ProfilingValueType, + ProfilingValueTypeUnit, +} from '../../../../common/profiling'; +import { + asDuration, + asDynamicBytes, + asInteger, +} from '../../../../common/utils/formatters'; +import { UIFilters } from '../../../../typings/ui_filters'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { useTheme } from '../../../hooks/use_theme'; +import { px, unit } from '../../../style/variables'; + +const colors = euiPaletteForTemperature(100).slice(50, 85); + +interface ProfileDataPoint { + id: string; + value: number; + depth: number; + layers: Record; +} + +const TooltipContainer = euiStyled.div` + background-color: ${(props) => props.theme.eui.euiColorDarkestShade}; + border-radius: ${(props) => props.theme.eui.euiBorderRadius}; + color: ${(props) => props.theme.eui.euiColorLightestShade}; + padding: ${(props) => props.theme.eui.paddingSizes.s}; +`; + +const formatValue = ( + value: number, + valueUnit: ProfilingValueTypeUnit +): string => { + switch (valueUnit) { + case ProfilingValueTypeUnit.ns: + return asDuration(value / 1000); + + case ProfilingValueTypeUnit.us: + return asDuration(value); + + case ProfilingValueTypeUnit.count: + return asInteger(value); + + case ProfilingValueTypeUnit.bytes: + return asDynamicBytes(value); + } +}; + +function CustomTooltip({ + values, + nodes, + valueUnit, +}: TooltipInfo & { + nodes: Record; + valueUnit: ProfilingValueTypeUnit; +}) { + const first = values[0]; + + const foundNode = find(nodes, (node) => node.label === first.label); + + const label = foundNode?.fqn ?? first.label; + const value = formatValue(first.value, valueUnit) + first.formattedValue; + + return ( + + + + + + + {label} + + + + {value} + + + + + ); +} + +export function ServiceProfilingFlamegraph({ + serviceName, + environment, + valueType, + start, + end, + uiFilters, +}: { + serviceName: string; + environment?: string; + valueType?: ProfilingValueType; + start?: string; + end?: string; + uiFilters: UIFilters; +}) { + const theme = useTheme(); + + const [collapseSimilarFrames, setCollapseSimilarFrames] = useState(true); + const [highlightFilter, setHighlightFilter] = useState(''); + + const { data } = useFetcher( + (callApmApi) => { + if (!start || !end || !valueType) { + return undefined; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + valueType, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + }, + [start, end, environment, serviceName, valueType, uiFilters] + ); + + const points = useMemo(() => { + if (!data) { + return []; + } + + const { rootNodes, nodes } = data; + + const getDataPoints = ( + node: ProfileNode, + depth: number + ): ProfileDataPoint[] => { + const { children } = node; + + if (!children.length) { + // edge + return [ + { + id: node.id, + value: node.value, + depth, + layers: { + [depth]: node.id, + }, + }, + ]; + } + + const directChildNodes = children.map((childId) => nodes[childId]); + + const shouldCollapse = + collapseSimilarFrames && + node.value === 0 && + directChildNodes.length === 1 && + directChildNodes[0].value === 0; + + const nextDepth = shouldCollapse ? depth : depth + 1; + + const childDataPoints = children.flatMap((childId) => + getDataPoints(nodes[childId], nextDepth) + ); + + if (!shouldCollapse) { + childDataPoints.forEach((point) => { + point.layers[depth] = node.id; + }); + } + + const totalTime = sumBy(childDataPoints, 'value'); + const selfTime = node.value - totalTime; + + if (selfTime === 0) { + return childDataPoints; + } + + return [ + ...childDataPoints, + { + id: '', + value: selfTime, + layers: { [nextDepth]: '' }, + depth, + }, + ]; + }; + + const root = { + id: 'root', + label: 'root', + fqn: 'root', + children: rootNodes, + value: 0, + }; + + nodes.root = root; + + return getDataPoints(root, 0); + }, [data, collapseSimilarFrames]); + + const layers = useMemo(() => { + if (!data || !points.length) { + return []; + } + + const { nodes } = data; + + const maxDepth = Math.max(...points.map((point) => point.depth)); + + return [...new Array(maxDepth)].map((_, depth) => { + return { + groupByRollup: (d: Datum) => d.layers[depth], + nodeLabel: (id: PrimitiveValue) => { + if (nodes[id!]) { + return nodes[id!].label; + } + return ''; + }, + showAccessor: (id: PrimitiveValue) => !!id, + shape: { + fillColor: (d: { dataName: string }) => { + const node = nodes[d.dataName]; + + if ( + !node || + // TODO: apply highlight to entire stack, not just node + (highlightFilter && !node.fqn.includes(highlightFilter)) + ) { + return rgba(0, 0, 0, 0.25); + } + + const integer = + Math.abs(seedrandom(d.dataName).int32()) % colors.length; + return colors[integer]; + }, + }, + }; + }); + }, [points, highlightFilter, data]); + + const chartTheme = useChartTheme(); + + const chartSize = { + height: layers.length * 20, + width: '100%', + }; + + const items = Object.values(data?.nodes ?? {}).filter((node) => + highlightFilter ? node.fqn.includes(highlightFilter) : true + ); + + const valueUnit = valueType + ? getValueTypeConfig(valueType).unit + : ProfilingValueTypeUnit.count; + + return ( + + + + ( + + ), + }} + /> + d.value as number} + valueFormatter={() => ''} + config={{ + fillLabel: { + fontFamily: theme.eui.euiCodeFontFamily, + // @ts-expect-error (coming soon in Elastic charts) + clipText: true, + }, + drilldown: true, + fontFamily: theme.eui.euiCodeFontFamily, + minFontSize: 9, + maxFontSize: 9, + maxRowCount: 1, + partitionLayout: PartitionLayout.icicle, + }} + /> + + + + + + { + setCollapseSimilarFrames((state) => !state); + }} + label={i18n.translate( + 'xpack.apm.profiling.collapseSimilarFrames', + { + defaultMessage: 'Collapse similar', + } + )} + /> + + + { + if (!e.target.value) { + setHighlightFilter(''); + } + }} + onKeyPress={(e) => { + if (e.charCode === 13) { + setHighlightFilter(() => (e.target as any).value); + } + }} + /> + + + { + return ( + + {item.label} + + ); + }, + }, + { + field: 'value', + name: i18n.translate('xpack.apm.profiling.table.value', { + defaultMessage: 'Self', + }), + render: (_, item) => formatValue(item.value, valueUnit), + width: px(unit * 6), + }, + ]} + /> + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx new file mode 100644 index 0000000000000..d5dc2f5d56afc --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + Axis, + BarSeries, + Chart, + niceTimeFormatter, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, + euiPaletteColorBlind, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useChartTheme } from '../../../../../observability/public'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; + +type ProfilingTimelineItem = { + x: number; +} & { valueTypes: Record }; + +const palette = euiPaletteColorBlind(); + +export function ServiceProfilingTimeline({ + start, + end, + series, + onValueTypeSelect, + selectedValueType, +}: { + series: ProfilingTimelineItem[]; + start: string; + end: string; + onValueTypeSelect: (valueType: ProfilingValueType) => void; + selectedValueType: ProfilingValueType | undefined; +}) { + const chartTheme = useChartTheme(); + + const xFormat = niceTimeFormatter([Date.parse(start), Date.parse(end)]); + + function getSeriesForValueType(type: ProfilingValueType | 'unknown') { + const label = + type === 'unknown' + ? i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.unknown', { + defaultMessage: 'Other', + }) + : getValueTypeConfig(type).label; + + return { + name: label, + id: type, + data: series.map((coord) => ({ + x: coord.x, + y: coord.valueTypes[type], + })), + }; + } + + const specs = [ + getSeriesForValueType('unknown'), + ...Object.values(ProfilingValueType).map((type) => + getSeriesForValueType(type) + ), + ] + .filter((spec) => spec.data.some((coord) => coord.y > 0)) + .map((spec, index) => { + return { + ...spec, + color: palette[index], + }; + }); + + return ( + + + + + + + {specs.map((spec) => ( + + ))} + + + + + {specs.map((spec) => ( + + + + + + + { + if (spec.id !== 'unknown') { + onValueTypeSelect(spec.id); + } + }} + > + + {spec.name} + + + + + + ))} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx new file mode 100644 index 0000000000000..ab3b085e4e255 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx @@ -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 { EuiLink } from '@elastic/eui'; +import React from 'react'; +import { APMLinkExtendProps, useAPMHref } from './APMLink'; + +interface ServiceProfilingLinkProps extends APMLinkExtendProps { + serviceName: string; + environment?: string; +} + +export function useServiceProfilingHref({ + serviceName, + environment, +}: ServiceProfilingLinkProps) { + const query = environment + ? { + environment, + } + : {}; + return useAPMHref({ + path: `/services/${serviceName}/profiling`, + query, + }); +} + +export function ServiceProfilingLink({ + serviceName, + environment, + ...rest +}: ServiceProfilingLinkProps) { + const href = useServiceProfilingHref({ serviceName, environment }); + return ; +} diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index d17a4a27b646c..024deca558497 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -81,6 +81,7 @@ const mockConfig: ConfigSchema = { ui: { enabled: false, }, + profilingEnabled: false, }; const mockPlugin = { diff --git a/x-pack/plugins/apm/public/index.ts b/x-pack/plugins/apm/public/index.ts index 5460c6dc625a6..2734269b9cff9 100644 --- a/x-pack/plugins/apm/public/index.ts +++ b/x-pack/plugins/apm/public/index.ts @@ -13,6 +13,7 @@ import { ApmPlugin, ApmPluginSetup, ApmPluginStart } from './plugin'; export interface ConfigSchema { serviceMapEnabled: boolean; + profilingEnabled: boolean; ui: { enabled: boolean; }; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 52b5765a984d5..00910353ac278 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -16,6 +16,7 @@ export const config = { exposeToBrowser: { serviceMapEnabled: true, ui: true, + profilingEnabled: true, }, schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -47,6 +48,7 @@ export const config = { metricsInterval: schema.number({ defaultValue: 30 }), maxServiceEnvironments: schema.number({ defaultValue: 100 }), maxServiceSelection: schema.number({ defaultValue: 50 }), + profilingEnabled: schema.boolean({ defaultValue: false }), }), }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index c47d511ca565c..368c0eb305f21 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -6,6 +6,7 @@ */ import { ValuesType } from 'utility-types'; +import { Profile } from '../../../../../typings/es_schemas/ui/profile'; import { ElasticsearchClient, KibanaRequest, @@ -43,6 +44,7 @@ type TypeOfProcessorEvent = { transaction: Transaction; span: Span; metric: Metric; + profile: Profile; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts index eef9aff946ea7..38989d172a73f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts @@ -23,6 +23,8 @@ const processorEventIndexMap: Record = { [ProcessorEvent.span]: 'apm_oss.spanIndices', [ProcessorEvent.metric]: 'apm_oss.metricsIndices', [ProcessorEvent.error]: 'apm_oss.errorIndices', + // TODO: should have its own config setting + [ProcessorEvent.profile]: 'apm_oss.transactionIndices', }; export function unpackProcessorEvents( diff --git a/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts new file mode 100644 index 0000000000000..0c9bbb35be631 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts @@ -0,0 +1,279 @@ +/* + * 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 { keyBy, last } from 'lodash'; +import { Logger } from 'kibana/server'; +import util from 'util'; +import { maybe } from '../../../../common/utils/maybe'; +import { ProfileStackFrame } from '../../../../typings/es_schemas/ui/profile'; +import { + ProfilingValueType, + ProfileNode, + getValueTypeConfig, +} from '../../../../common/profiling'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { ESFilter } from '../../../../../../typings/elasticsearch'; +import { + PROFILE_STACK, + PROFILE_TOP_ID, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { rangeQuery, environmentQuery } from '../../../../common/utils/queries'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +const MAX_STACK_IDS = 10000; +const MAX_STACKS_PER_REQUEST = 1000; + +const maybeAdd = (to: any[], value: any) => { + if (to.includes(value)) { + return; + } + + to.push(value); +}; + +function getProfilingStats({ + apmEventClient, + filter, + valueTypeField, +}: { + apmEventClient: APMEventClient; + filter: ESFilter[]; + valueTypeField: string; +}) { + return withApmSpan('get_profiling_stats', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + stacks: { + terms: { + field: PROFILE_TOP_ID, + size: MAX_STACK_IDS, + order: { + value: 'desc', + }, + }, + aggs: { + value: { + sum: { + field: valueTypeField, + }, + }, + }, + }, + }, + }, + }); + + const stacks = + response.aggregations?.stacks.buckets.map((stack) => { + return { + id: stack.key as string, + value: stack.value.value!, + }; + }) ?? []; + + return stacks; + }); +} + +function getProfilesWithStacks({ + apmEventClient, + filter, +}: { + apmEventClient: APMEventClient; + filter: ESFilter[]; +}) { + return withApmSpan('get_profiles_with_stacks', async () => { + const cardinalityResponse = await withApmSpan('get_top_cardinality', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { filter }, + }, + aggs: { + top: { + cardinality: { + field: PROFILE_TOP_ID, + }, + }, + }, + }, + }) + ); + + const cardinality = cardinalityResponse.aggregations?.top.value ?? 0; + + const numStacksToFetch = Math.min( + Math.ceil(cardinality * 1.1), + MAX_STACK_IDS + ); + + const partitions = Math.ceil(numStacksToFetch / MAX_STACKS_PER_REQUEST); + + if (partitions === 0) { + return []; + } + + const allResponses = await withApmSpan('get_all_stacks', async () => { + return Promise.all( + [...new Array(partitions)].map(async (_, num) => { + const response = await withApmSpan('get_partition', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + query: { + bool: { + filter, + }, + }, + aggs: { + top: { + terms: { + field: PROFILE_TOP_ID, + size: Math.max(MAX_STACKS_PER_REQUEST), + include: { + num_partitions: partitions, + partition: num, + }, + }, + aggs: { + latest: { + top_hits: { + _source: [PROFILE_TOP_ID, PROFILE_STACK], + }, + }, + }, + }, + }, + }, + }) + ); + + return ( + response.aggregations?.top.buckets.flatMap((bucket) => { + return bucket.latest.hits.hits[0]._source; + }) ?? [] + ); + }) + ); + }); + + return allResponses.flat(); + }); +} + +export async function getServiceProfilingStatistics({ + serviceName, + setup, + environment, + valueType, + logger, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment?: string; + valueType: ProfilingValueType; + logger: Logger; +}) { + return withApmSpan('get_service_profiling_statistics', async () => { + const { apmEventClient, start, end } = setup; + + const valueTypeField = getValueTypeConfig(valueType).field; + + const filter: ESFilter[] = [ + ...rangeQuery(start, end), + { term: { [SERVICE_NAME]: serviceName } }, + ...environmentQuery(environment), + { exists: { field: valueTypeField } }, + ...setup.esFilter, + ]; + + const [profileStats, profileStacks] = await Promise.all([ + getProfilingStats({ apmEventClient, filter, valueTypeField }), + getProfilesWithStacks({ apmEventClient, filter }), + ]); + + const nodes: Record = {}; + const rootNodes: string[] = []; + + function getNode(frame: ProfileStackFrame) { + const { id, filename, function: functionName, line } = frame; + const location = [functionName, line].filter(Boolean).join(':'); + const fqn = [filename, location].filter(Boolean).join('/'); + const label = last(location.split('/'))!; + let node = nodes[id]; + if (!node) { + node = { id, label, fqn, value: 0, children: [] }; + nodes[id] = node; + } + return node; + } + + const stackStatsById = keyBy(profileStats, 'id'); + + const missingStacks: string[] = []; + + profileStacks.forEach((profile) => { + const stats = maybe(stackStatsById[profile.profile.top.id]); + + if (!stats) { + missingStacks.push(profile.profile.top.id); + return; + } + + const frames = profile.profile.stack.concat().reverse(); + + frames.forEach((frame, index) => { + const node = getNode(frame); + + if (index === frames.length - 1 && stats) { + node.value += stats.value; + } + + if (index === 0) { + // root node + maybeAdd(rootNodes, node.id); + } else { + const parent = nodes[frames[index - 1].id]; + maybeAdd(parent.children, node.id); + } + }); + }); + + if (missingStacks.length > 0) { + logger.warn( + `Could not find stats for all stacks: ${util.inspect({ + numProfileStats: profileStats.length, + numStacks: profileStacks.length, + missing: missingStacks, + })}` + ); + } + + return { + nodes, + rootNodes, + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts new file mode 100644 index 0000000000000..dc29d6a43d82d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts @@ -0,0 +1,121 @@ +/* + * 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 { mapKeys, mapValues } from 'lodash'; +import { rangeQuery, environmentQuery } from '../../../../common/utils/queries'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { + PROFILE_ID, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +const configMap = mapValues( + mapKeys(ProfilingValueType, (val, key) => val), + (value) => getValueTypeConfig(value) +) as Record>; + +const allFields = Object.values(configMap).map((config) => config.field); + +export async function getServiceProfilingTimeline({ + serviceName, + environment, + setup, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment?: string; +}) { + return withApmSpan('get_service_profiling_timeline', async () => { + const { apmEventClient, start, end, esFilter } = setup; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end }).intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + value_type: { + filters: { + filters: { + unknown: { + bool: { + must_not: allFields.map((field) => ({ + exists: { field }, + })), + }, + }, + ...mapValues(configMap, ({ field }) => ({ + exists: { field }, + })), + }, + }, + aggs: { + num_profiles: { + cardinality: { + field: PROFILE_ID, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const { aggregations } = response; + + if (!aggregations) { + return []; + } + + return aggregations.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + valueTypes: { + unknown: bucket.value_type.buckets.unknown.num_profiles.value, + // TODO: use enum as object key. not possible right now + // because of https://github.com/microsoft/TypeScript/issues/37888 + ...mapValues(configMap, (_, key) => { + return ( + bucket.value_type.buckets[key as ProfilingValueType]?.num_profiles + .value ?? 0 + ); + }), + }, + }; + }); + }); +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index c96e02f6c1821..2bd7e25e848c8 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -31,6 +31,8 @@ import { serviceMetadataDetailsRoute, serviceMetadataIconsRoute, serviceInstancesRoute, + serviceProfilingStatisticsRoute, + serviceProfilingTimelineRoute, } from './services'; import { agentConfigurationRoute, @@ -134,6 +136,8 @@ const createApmApi = () => { .add(serviceMetadataIconsRoute) .add(serviceInstancesRoute) .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceProfilingTimelineRoute) + .add(serviceProfilingStatisticsRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2ce41f3d1e1a0..86f7853647894 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -34,6 +34,9 @@ import { uiFiltersRt, } from './default_api_types'; import { withApmSpan } from '../utils/with_apm_span'; +import { getServiceProfilingStatistics } from '../lib/services/profiling/get_service_profiling_statistics'; +import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; +import { ProfilingValueType } from '../../common/profiling'; import { latencyAggregationTypeRt, LatencyAggregationType, @@ -179,12 +182,7 @@ export const serviceAnnotationsRoute = createRoute({ path: t.type({ serviceName: t.string, }), - query: t.intersection([ - rangeRt, - t.partial({ - environment: t.string, - }), - ]), + query: t.intersection([rangeRt, environmentRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { @@ -488,3 +486,82 @@ export const serviceDependenciesRoute = createRoute({ }); }, }); + +export const serviceProfilingTimelineRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.partial({ + environment: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + path: { serviceName }, + query: { environment }, + } = context.params; + + return getServiceProfilingTimeline({ + setup, + serviceName, + environment, + }); + }, +}); + +export const serviceProfilingStatisticsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.partial({ + environment: t.string, + }), + t.type({ + valueType: t.union([ + t.literal(ProfilingValueType.wallTime), + t.literal(ProfilingValueType.cpuTime), + t.literal(ProfilingValueType.samples), + t.literal(ProfilingValueType.allocObjects), + t.literal(ProfilingValueType.allocSpace), + t.literal(ProfilingValueType.inuseObjects), + t.literal(ProfilingValueType.inuseSpace), + ]), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + path: { serviceName }, + query: { environment, valueType }, + } = context.params; + + return getServiceProfilingStatistics({ + serviceName, + environment, + valueType, + setup, + logger: context.logger, + }); + }, +}); diff --git a/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts b/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts new file mode 100644 index 0000000000000..e8fbe8805fd0f --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/ui/profile.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 { Observer } from '@elastic/eui/src/components/observer/observer'; +import { Agent } from './fields/agent'; + +export interface ProfileStackFrame { + filename?: string; + line?: string; + function: string; + id: string; +} + +export interface Profile { + agent: Agent; + '@timestamp': string; + labels?: { + [key: string]: string | number | boolean; + }; + observer?: Observer; + profile: { + top: ProfileStackFrame; + duration: number; + stack: ProfileStackFrame[]; + id: string; + wall?: { + us: number; + }; + cpu?: { + ns: number; + }; + samples: { + count: number; + }; + }; +} diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts index 7d71819de5366..f13537892875d 100644 --- a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -29,4 +29,6 @@ export const JOB_MAP_NODE_TYPES = { TRAINED_MODEL: 'trainedModel', } as const; +export const BUILT_IN_MODEL_TAG = 'prepackaged'; + export type JobMapNodeTypes = typeof JOB_MAP_NODE_TYPES[keyof typeof JOB_MAP_NODE_TYPES]; diff --git a/x-pack/plugins/ml/common/types/trained_models.ts b/x-pack/plugins/ml/common/types/trained_models.ts index 057ec5cc79d98..6b320d503b4c0 100644 --- a/x-pack/plugins/ml/common/types/trained_models.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -46,6 +46,7 @@ export interface TrainedModelStat { } export interface TrainedModelConfigResponse { + description: string; created_by: string; create_time: string; default_field_map: Record; @@ -61,7 +62,7 @@ export interface TrainedModelConfigResponse { } | Record; model_id: string; - tags: string; + tags: string[]; version: string; inference_config?: Record; pipelines?: Record | null; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/search_bar_filters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/common/search_bar_filters.tsx index df935cfdf3825..11754007da775 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/search_bar_filters.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/search_bar_filters.tsx @@ -12,68 +12,6 @@ import { Value, DataFrameAnalyticsListRow, } from '../pages/analytics_management/components/analytics_list/common'; -import { ModelItem } from '../pages/analytics_management/components/models_management/models_list'; - -export function filterAnalyticsModels( - items: ModelItem[], - clauses: Array -) { - if (clauses.length === 0) { - return items; - } - - // keep count of the number of matches we make as we're looping over the clauses - // we only want to return items which match all clauses, i.e. each search term is ANDed - const matches: Record = items.reduce((p: Record, c) => { - p[c.model_id] = { - model: c, - count: 0, - }; - return p; - }, {}); - - clauses.forEach((c) => { - // the search term could be negated with a minus, e.g. -bananas - const bool = c.match === 'must'; - let ms = []; - - if (c.type === 'term') { - // filter term based clauses, e.g. bananas - // match on model_id and type - // if the term has been negated, AND the matches - if (bool === true) { - ms = items.filter( - (item) => - stringMatch(item.model_id, c.value) === bool || stringMatch(item.type, c.value) === bool - ); - } else { - ms = items.filter( - (item) => - stringMatch(item.model_id, c.value) === bool && stringMatch(item.type, c.value) === bool - ); - } - } else { - // filter other clauses, i.e. the filters for type - if (Array.isArray(c.value)) { - // type value is an array of string(s) e.g. c.value => ['classification'] - ms = items.filter((item) => { - return item.type !== undefined && (c.value as Value[]).includes(item.type); - }); - } else { - ms = items.filter((item) => item[c.field as keyof typeof item] === c.value); - } - } - - ms.forEach((j) => matches[j.model_id].count++); - }); - - // loop through the matches and return only those items which have match all the clauses - const filtered = Object.values(matches) - .filter((m) => (m && m.count) >= clauses.length) - .map((m) => m.model); - - return filtered; -} export function filterAnalytics( items: DataFrameAnalyticsListRow[], diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx index 626ba3dbc6baa..5562fcbe093a1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx @@ -66,9 +66,11 @@ export const ExpandedRow: FC = ({ item }) => { // eslint-disable-next-line @typescript-eslint/naming-convention license_level, pipelines, + description, } = item; const details = { + description, tags, version, estimated_operations, @@ -104,8 +106,8 @@ export const ExpandedRow: FC = ({ item }) => { ), }; }) - .filter(({ description }) => { - return description !== undefined; + .filter(({ description: d }) => { + return d !== undefined; }); } @@ -365,62 +367,64 @@ export const ExpandedRow: FC = ({ item }) => { <> - {Object.entries(pipelines).map(([pipelineName, { processors, description }]) => { - return ( - - - - - -
{pipelineName}
-
-
- - { - const ingestPipelinesAppUrlGenerator = share.urlGenerators.getUrlGenerator( - 'INGEST_PIPELINES_APP_URL_GENERATOR' - ); - await navigateToUrl( - await ingestPipelinesAppUrlGenerator.createUrl({ - page: 'pipeline_edit', - pipelineId: pipelineName, - absolute: true, - }) - ); - }} - > + {Object.entries(pipelines).map( + ([pipelineName, { processors, description: pipelineDescription }]) => { + return ( + + + + + +
{pipelineName}
+
+
+ + { + const ingestPipelinesAppUrlGenerator = share.urlGenerators.getUrlGenerator( + 'INGEST_PIPELINES_APP_URL_GENERATOR' + ); + await navigateToUrl( + await ingestPipelinesAppUrlGenerator.createUrl({ + page: 'pipeline_edit', + pipelineId: pipelineName, + absolute: true, + }) + ); + }} + > + + + +
+ + {pipelineDescription && {pipelineDescription}} + + +
- - - - - {description && {description}} - - -
- -
-
- - {JSON.stringify(processors, null, 2)} - - - - ); - })} +
+
+ + {JSON.stringify(processors, null, 2)} + +
+
+ ); + } + )}
), diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts index ce67d265d7471..27c378aaed25b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/index.ts @@ -9,6 +9,7 @@ export * from './models_list'; export const ModelsTableToConfigMapping = { id: 'model_id', + description: 'description', createdAt: 'create_time', type: 'type', } as const; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index 0f32e4596f1c5..4c3da00ed7cad 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useState, useCallback, useEffect, useMemo } from 'react'; +import React, { FC, useState, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -14,7 +14,6 @@ import { EuiFlexItem, EuiTitle, EuiButton, - EuiSearchBar, EuiSpacer, EuiButtonIcon, EuiBadge, @@ -48,18 +47,17 @@ import { refreshAnalyticsList$, useRefreshAnalyticsList, } from '../../../../common'; -import { useTableSettings } from '../analytics_list/use_table_settings'; -import { filterAnalyticsModels } from '../../../../common/search_bar_filters'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; import { timeFormatter } from '../../../../../../../common/util/date_utils'; import { ListingPageUrlState } from '../../../../../../../common/types/common'; import { usePageUrlState } from '../../../../../util/url_state'; +import { BUILT_IN_MODEL_TAG } from '../../../../../../../common/constants/data_frame_analytics'; type Stats = Omit; export type ModelItem = TrainedModelConfigResponse & { - type?: string; + type?: string[]; stats?: Stats; pipelines?: ModelPipelines['pipelines'] | null; }; @@ -73,6 +71,11 @@ export const getDefaultModelsListState = (): ListingPageUrlState => ({ sortDirection: 'asc', }); +export const BUILT_IN_MODEL_TYPE = i18n.translate( + 'xpack.ml.trainedModels.modelsList.builtInModelLabel', + { defaultMessage: 'built-in' } +); + export const ModelsList: FC = () => { const { services: { @@ -99,7 +102,6 @@ export const ModelsList: FC = () => { const trainedModelsApiService = useTrainedModelsApiService(); const { toasts } = useNotifications(); - const [filteredModels, setFilteredModels] = useState([]); const [isLoading, setIsLoading] = useState(false); const [items, setItems] = useState([]); const [selectedModels, setSelectedModels] = useState([]); @@ -111,36 +113,15 @@ export const ModelsList: FC = () => { const mlUrlGenerator = useMlUrlGenerator(); const navigateToPath = useNavigateToPath(); - const updateFilteredItems = (queryClauses: any) => { - if (queryClauses.length) { - const filtered = filterAnalyticsModels(items, queryClauses); - setFilteredModels(filtered); - } else { - setFilteredModels(items); - } - }; - - const filterList = () => { - if (searchQueryText !== '') { - const query = EuiSearchBar.Query.parse(searchQueryText); - let clauses: any = []; - if (query && query.ast !== undefined && query.ast.clauses !== undefined) { - clauses = query.ast.clauses; - } - updateFilteredItems(clauses); - } else { - updateFilteredItems([]); - } - }; - - useEffect(() => { - filterList(); - }, [searchQueryText, items]); + const isBuiltInModel = useCallback( + (item: ModelItem) => item.tags.includes(BUILT_IN_MODEL_TAG), + [] + ); /** - * Fetches inference trained models. + * Fetches trained models. */ - const fetchData = useCallback(async () => { + const fetchModelsData = useCallback(async () => { try { const response = await trainedModelsApiService.getTrainedModels(undefined, { with_pipelines: true, @@ -151,10 +132,16 @@ export const ModelsList: FC = () => { const expandedItemsToRefresh = []; for (const model of response) { - const tableItem = { + const tableItem: ModelItem = { ...model, + // Extract model types ...(typeof model.inference_config === 'object' - ? { type: Object.keys(model.inference_config)[0] } + ? { + type: [ + ...Object.keys(model.inference_config), + ...(isBuiltInModel(model) ? [BUILT_IN_MODEL_TYPE] : []), + ], + } : {}), }; newItems.push(tableItem); @@ -190,7 +177,7 @@ export const ModelsList: FC = () => { // Subscribe to the refresh observable to trigger reloading the model list. useRefreshAnalyticsList({ isLoading: setIsLoading, - onRefresh: fetchData, + onRefresh: fetchModelsData, }); const modelsStats: ModelsBarStats = useMemo(() => { @@ -369,7 +356,7 @@ export const ModelsList: FC = () => { onClick: async (model) => { await prepareModelsForDeletion([model]); }, - available: (item) => canDeleteDataFrameAnalytics, + available: (item) => canDeleteDataFrameAnalytics && !isBuiltInModel(item), enabled: (item) => { // TODO check for permissions to delete ingest pipelines. // ATM undefined means pipelines fetch failed server-side. @@ -418,6 +405,15 @@ export const ModelsList: FC = () => { sortable: true, truncateText: true, }, + { + field: ModelsTableToConfigMapping.description, + width: '350px', + name: i18n.translate('xpack.ml.trainedModels.modelsList.modelDescriptionHeader', { + defaultMessage: 'Description', + }), + sortable: false, + truncateText: true, + }, { field: ModelsTableToConfigMapping.type, name: i18n.translate('xpack.ml.trainedModels.modelsList.typeHeader', { @@ -425,7 +421,15 @@ export const ModelsList: FC = () => { }), sortable: true, align: 'left', - render: (type: string) => {type}, + render: (types: string[]) => ( + + {types.map((type) => ( + + {type} + + ))} + + ), }, { field: ModelsTableToConfigMapping.createdAt, @@ -459,12 +463,6 @@ export const ModelsList: FC = () => { ] : []; - const { onTableChange, pagination, sorting } = useTableSettings( - filteredModels, - pageState, - updatePageState - ); - const toolsLeft = ( @@ -496,15 +494,27 @@ export const ModelsList: FC = () => { const selection: EuiTableSelectionType | undefined = isSelectionAllowed ? { selectableMessage: (selectable, item) => { - return selectable - ? i18n.translate('xpack.ml.trainedModels.modelsList.selectableMessage', { - defaultMessage: 'Select a model', - }) - : i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', { - defaultMessage: 'Model has associated pipelines', - }); + if (selectable) { + return i18n.translate('xpack.ml.trainedModels.modelsList.selectableMessage', { + defaultMessage: 'Select a model', + }); + } + + if (Array.isArray(item.pipelines) && item.pipelines.length > 0) { + return i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', { + defaultMessage: 'Model has associated pipelines', + }); + } + + if (isBuiltInModel(item)) { + return i18n.translate('xpack.ml.trainedModels.modelsList.builtInModelMessage', { + defaultMessage: 'Built-in model', + }); + } + + return ''; }, - selectable: (item) => !item.pipelines, + selectable: (item) => !item.pipelines && !isBuiltInModel(item), onSelectionChange: (selectedItems) => { setSelectedModels(selectedItems); }, @@ -534,6 +544,7 @@ export const ModelsList: FC = () => { } : {}), }; + return ( <> @@ -556,9 +567,6 @@ export const ModelsList: FC = () => { items={items} itemId={ModelsTableToConfigMapping.id} loading={isLoading} - onTableChange={onTableChange} - pagination={pagination} - sorting={sorting} search={search} selection={selection} rowProps={(item) => ({ diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts index 293eec12adc40..66af9e2e00227 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from 'src/core/public'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { IndexPattern, KBN_FIELD_TYPES } from '../../../../../../../../src/plugins/data/public'; import { SavedSearchQuery } from '../../../contexts/ml'; import { OMIT_FIELDS } from '../../../../../common/constants/field_types'; @@ -48,7 +48,7 @@ export class DataLoader { this._indexPattern.fields.forEach((field) => { const fieldName = field.displayName !== undefined ? field.displayName : field.name; if (this.isDisplayField(fieldName) === true) { - if (field.aggregatable === true) { + if (field.aggregatable === true && field.type !== KBN_FIELD_TYPES.GEO_SHAPE) { aggregatableFields.push(fieldName); } else { nonAggregatableFields.push(fieldName); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx index a9390f59517e6..2cd412593f492 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx @@ -21,6 +21,7 @@ interface MountSectionParams { assignmentService: ITagAssignmentService; core: CoreSetup<{}, SavedObjectTaggingPluginStart>; mountParams: ManagementAppMountParams; + title: string; } const RedirectToHomeIfUnauthorized: FC<{ @@ -40,11 +41,13 @@ export const mountSection = async ({ assignmentService, core, mountParams, + title, }: MountSectionParams) => { const [coreStart] = await core.getStartServices(); const { element, setBreadcrumbs } = mountParams; const capabilities = getTagsCapabilities(coreStart.application.capabilities); const assignableTypes = await assignmentService.getAssignableTypes(); + coreStart.chrome.docTitle.change(title); ReactDOM.render( @@ -64,6 +67,7 @@ export const mountSection = async ({ ); return () => { + coreStart.chrome.docTitle.reset(); ReactDOM.unmountComponentAtNode(element); }; }; diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index d4e3f8678fe1f..243ef686eed1e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -39,11 +39,12 @@ export class SavedObjectTaggingPlugin { management, savedObjectsTaggingOss }: SetupDeps ) { const kibanaSection = management.sections.section.kibana; + const title = i18n.translate('xpack.savedObjectsTagging.management.sectionLabel', { + defaultMessage: 'Tags', + }); kibanaSection.registerApp({ id: tagManagementSectionId, - title: i18n.translate('xpack.savedObjectsTagging.management.sectionLabel', { - defaultMessage: 'Tags', - }), + title, order: 1.5, mount: async (mountParams) => { const { mountSection } = await import('./management'); @@ -53,6 +54,7 @@ export class SavedObjectTaggingPlugin assignmentService: this.assignmentService!, core, mountParams, + title, }); }, }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index ee69fa745f40c..1606dc7af7c5b 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -28,11 +28,15 @@ describe('apiKeysManagementApp', () => { it('mount() works for the `grid` page', async () => { const { getStartServices } = coreMock.createSetup(); + + const startServices = await getStartServices(); + const docTitle = startServices[0].chrome.docTitle; + const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); const unmount = await apiKeysManagementApp - .create({ getStartServices: getStartServices as any }) + .create({ getStartServices: () => Promise.resolve(startServices) as any }) .mount({ basePath: '/some-base-path', element: container, @@ -42,6 +46,8 @@ describe('apiKeysManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API Keys' }]); + expect(docTitle.change).toHaveBeenCalledWith('API Keys'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Page: {"notifications":{"toasts":{}},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}}} @@ -49,6 +55,7 @@ describe('apiKeysManagementApp', () => { `); unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); expect(container).toMatchInlineSnapshot(`
`); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index ca9f208f20c34..45d00ecec124d 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -20,18 +20,17 @@ interface CreateParams { export const apiKeysManagementApp = Object.freeze({ id: 'api_keys', create({ getStartServices }: CreateParams) { + const title = i18n.translate('xpack.security.management.apiKeysTitle', { + defaultMessage: 'API Keys', + }); return { id: this.id, order: 30, - title: i18n.translate('xpack.security.management.apiKeysTitle', { - defaultMessage: 'API Keys', - }), + title, async mount({ element, setBreadcrumbs }) { setBreadcrumbs([ { - text: i18n.translate('xpack.security.apiKeys.breadcrumb', { - defaultMessage: 'API Keys', - }), + text: title, href: `/`, }, ]); @@ -42,6 +41,8 @@ export const apiKeysManagementApp = Object.freeze({ import('./api_keys_api_client'), ]); + core.chrome.docTitle.change(title); + render( @@ -55,6 +56,7 @@ export const apiKeysManagementApp = Object.freeze({ ); return () => { + core.chrome.docTitle.reset(); unmountComponentAtNode(element); }; }, diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index eea505dafef69..934601a6f4b48 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -30,8 +30,10 @@ async function mountApp(basePath: string, pathname: string) { const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); + const startServices = await coreMock.createSetup().getStartServices(); + const unmount = await roleMappingsManagementApp - .create({ getStartServices: coreMock.createSetup().getStartServices as any }) + .create({ getStartServices: () => Promise.resolve(startServices) as any }) .mount({ basePath, element: container, @@ -39,7 +41,7 @@ async function mountApp(basePath: string, pathname: string) { history: scopedHistoryMock.create({ pathname }), }); - return { unmount, container, setBreadcrumbs }; + return { unmount, container, setBreadcrumbs, docTitle: startServices[0].chrome.docTitle }; } describe('roleMappingsManagementApp', () => { @@ -59,10 +61,12 @@ describe('roleMappingsManagementApp', () => { }); it('mount() works for the `grid` page', async () => { - const { setBreadcrumbs, container, unmount } = await mountApp('/', '/'); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp('/', '/'); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Role Mappings' }]); + expect(docTitle.change).toHaveBeenCalledWith('Role Mappings'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} @@ -71,17 +75,21 @@ describe('roleMappingsManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); it('mount() works for the `create role mapping` page', async () => { - const { setBreadcrumbs, container, unmount } = await mountApp('/', '/edit'); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp('/', '/edit'); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Role Mappings' }, { text: 'Create' }, ]); + expect(docTitle.change).toHaveBeenCalledWith('Role Mappings'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} @@ -90,19 +98,26 @@ describe('roleMappingsManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); it('mount() works for the `edit role mapping` page', async () => { const roleMappingName = 'role@mapping'; - const { setBreadcrumbs, container, unmount } = await mountApp('/', `/edit/${roleMappingName}`); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp( + '/', + `/edit/${roleMappingName}` + ); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Role Mappings' }, { href: `/edit/${encodeURIComponent(roleMappingName)}`, text: roleMappingName }, ]); + expect(docTitle.change).toHaveBeenCalledWith('Role Mappings'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}} @@ -111,6 +126,8 @@ describe('roleMappingsManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx index f6f49c8563cb5..24d197d1c64e7 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx @@ -22,23 +22,24 @@ interface CreateParams { export const roleMappingsManagementApp = Object.freeze({ id: 'role_mappings', create({ getStartServices }: CreateParams) { + const title = i18n.translate('xpack.security.management.roleMappingsTitle', { + defaultMessage: 'Role Mappings', + }); return { id: this.id, order: 40, - title: i18n.translate('xpack.security.management.roleMappingsTitle', { - defaultMessage: 'Role Mappings', - }), + title, async mount({ element, setBreadcrumbs, history }) { const [coreStart] = await getStartServices(); const roleMappingsBreadcrumbs = [ { - text: i18n.translate('xpack.security.roleMapping.breadcrumb', { - defaultMessage: 'Role Mappings', - }), + text: title, href: `/`, }, ]; + coreStart.chrome.docTitle.change(title); + const [ [core], { RoleMappingsGridPage }, @@ -117,6 +118,7 @@ export const roleMappingsManagementApp = Object.freeze({ ); return () => { + coreStart.chrome.docTitle.reset(); unmountComponentAtNode(element); }; }, diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index 19726720b5ea2..c576aac7a8169 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -28,6 +28,7 @@ async function mountApp(basePath: string, pathname: string) { const setBreadcrumbs = jest.fn(); const featuresStart = featuresPluginMock.createStart(); + const coreStart = coreMock.createStart(); const unmount = await rolesManagementApp .create({ @@ -35,7 +36,7 @@ async function mountApp(basePath: string, pathname: string) { fatalErrors, getStartServices: jest .fn() - .mockResolvedValue([coreMock.createStart(), { data: {}, features: featuresStart }]), + .mockResolvedValue([coreStart, { data: {}, features: featuresStart }]), }) .mount({ basePath, @@ -44,7 +45,7 @@ async function mountApp(basePath: string, pathname: string) { history: scopedHistoryMock.create({ pathname }), }); - return { unmount, container, setBreadcrumbs }; + return { unmount, container, setBreadcrumbs, docTitle: coreStart.chrome.docTitle }; } describe('rolesManagementApp', () => { @@ -68,10 +69,12 @@ describe('rolesManagementApp', () => { }); it('mount() works for the `grid` page', async () => { - const { setBreadcrumbs, container, unmount } = await mountApp('/', '/'); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp('/', '/'); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }]); + expect(docTitle.change).toHaveBeenCalledWith('Roles'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} @@ -80,14 +83,18 @@ describe('rolesManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); it('mount() works for the `create role` page', async () => { - const { setBreadcrumbs, container, unmount } = await mountApp('/', '/edit'); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp('/', '/edit'); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); + expect(docTitle.change).toHaveBeenCalledWith('Roles'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} @@ -96,19 +103,26 @@ describe('rolesManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); it('mount() works for the `edit role` page', async () => { const roleName = 'role@name'; - const { setBreadcrumbs, container, unmount } = await mountApp('/', `/edit/${roleName}`); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp( + '/', + `/edit/${roleName}` + ); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Roles' }, { href: `/edit/${encodeURIComponent(roleName)}`, text: roleName }, ]); + expect(docTitle.change).toHaveBeenCalledWith('Roles'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Role Edit Page: {"action":"edit","roleName":"role@name","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@name","search":"","hash":""}}} @@ -117,16 +131,23 @@ describe('rolesManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); it('mount() works for the `clone role` page', async () => { const roleName = 'someRoleName'; - const { setBreadcrumbs, container, unmount } = await mountApp('/', `/clone/${roleName}`); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp( + '/', + `/clone/${roleName}` + ); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); + expect(docTitle.change).toHaveBeenCalledWith('Roles'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}} @@ -135,6 +156,8 @@ describe('rolesManagementApp', () => { unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); + expect(container).toMatchInlineSnapshot(`
`); }); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx index c4dc4fe607cad..dd2198d10f53c 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx @@ -24,20 +24,26 @@ interface CreateParams { export const rolesManagementApp = Object.freeze({ id: 'roles', create({ license, fatalErrors, getStartServices }: CreateParams) { + const title = i18n.translate('xpack.security.management.rolesTitle', { + defaultMessage: 'Roles', + }); return { id: this.id, order: 20, - title: i18n.translate('xpack.security.management.rolesTitle', { defaultMessage: 'Roles' }), + title, async mount({ element, setBreadcrumbs, history }) { const rolesBreadcrumbs = [ { - text: i18n.translate('xpack.security.roles.breadcrumb', { defaultMessage: 'Roles' }), + text: title, href: `/`, }, ]; const [ - [{ application, docLinks, http, i18n: i18nStart, notifications }, { data, features }], + [ + { application, docLinks, http, i18n: i18nStart, notifications, chrome }, + { data, features }, + ], { RolesGridPage }, { EditRolePage }, { RolesAPIClient }, @@ -54,6 +60,8 @@ export const rolesManagementApp = Object.freeze({ import('../users'), ]); + chrome.docTitle.change(title); + const rolesAPIClient = new RolesAPIClient(http); const RolesGridPageWithBreadcrumbs = () => { setBreadcrumbs(rolesBreadcrumbs); @@ -125,6 +133,7 @@ export const rolesManagementApp = Object.freeze({ ); return () => { + chrome.docTitle.reset(); unmountComponentAtNode(element); }; }, diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 136b13fa4610f..0fc2e90e44c46 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -37,10 +37,13 @@ interface EditUserParams { export const usersManagementApp = Object.freeze({ id: 'users', create({ authc, getStartServices }: CreateParams) { + const title = i18n.translate('xpack.security.management.usersTitle', { + defaultMessage: 'Users', + }); return { id: this.id, order: 10, - title: i18n.translate('xpack.security.management.usersTitle', { defaultMessage: 'Users' }), + title, async mount({ element, setBreadcrumbs, history }) { const [ [coreStart], @@ -49,7 +52,7 @@ export const usersManagementApp = Object.freeze({ { UserAPIClient }, { RolesAPIClient }, ] = await Promise.all([ - getStartServices(), + getStartServices(), // TODO: remove this and write test. import('./users_grid'), import('./edit_user'), import('./user_api_client'), @@ -115,6 +118,7 @@ export const usersManagementApp = Object.freeze({ ); return () => { + coreStart.chrome.docTitle.reset(); unmountComponentAtNode(element); }; }, diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index feb11f55590ad..f4cddfe4d8da9 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -29,7 +29,7 @@ This configuration runs cypress tests against an arbitrary host. ### Test Execution: Examples -#### FTR + Headless +#### FTR + Headless (Chrome) Since this is how tests are run on CI, this will likely be the configuration you want to reproduce failures locally, etc. @@ -45,6 +45,22 @@ cd x-pack/plugins/security_solution yarn cypress:run-as-ci ``` +#### FTR + Headless (Firefox) + +Since this is how tests are run on CI, this will likely be the configuration you want to reproduce failures locally, etc. + +```shell +# bootstrap kibana from the project root +yarn kbn bootstrap + +# build the plugins/assets that cypress will execute against +node scripts/build_kibana_platform_plugins + +# launch the cypress test runner +cd x-pack/plugins/security_solution +yarn cypress:run-as-ci:firefox +``` + #### FTR + Interactive This is the preferred mode for developing new tests. @@ -61,7 +77,9 @@ cd x-pack/plugins/security_solution yarn cypress:open-as-ci ``` -#### Custom Target + Headless +Note that you can select the browser you want to use on the top right side of the interactive runner. + +#### Custom Target + Headless (Chrome) This mode may be useful for testing a release, e.g. spin up a build candidate and point cypress at it to catch regressions. @@ -79,6 +97,24 @@ cd x-pack/plugins/security_solution CYPRESS_BASE_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_USERNAME= CYPRESS_ELASTICSEARCH_PASSWORD=password yarn cypress:run ``` +#### Custom Target + Headless (Firefox) + +This mode may be useful for testing a release, e.g. spin up a build candidate +and point cypress at it to catch regressions. + +```shell +# bootstrap kibana from the project root +yarn kbn bootstrap + +# load auditbeat data needed for test execution (which FTR normally does for us) +cd x-pack/plugins/security_solution +node ../../../scripts/es_archiver load auditbeat --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url http(s)://:@ --kibana-url http(s)://:@ + +# launch the cypress test runner with overridden environment variables +cd x-pack/plugins/security_solution +CYPRESS_BASE_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_USERNAME= CYPRESS_ELASTICSEARCH_PASSWORD=password yarn cypress:run:firefox +``` + ## Folder Structure ### integration/ diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts index 11ad01807d83b..3505a9b6a7918 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/data_providers.spec.ts @@ -69,28 +69,40 @@ describe('timeline data providers', () => { it('sets the background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers', () => { dragFirstHostToTimeline(); - cy.get(TIMELINE_DATA_PROVIDERS) - .filter(':visible') - .should( - 'have.css', - 'background', - 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' - ); + if (Cypress.browser.name === 'firefox') { + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should('have.css', 'background-color', 'rgba(1, 125, 115, 0.1)'); + } else { + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should( + 'have.css', + 'background', + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + ); + } }); it('sets the background to euiColorSuccess with a 20% alpha channel and renders the dashed border color as euiColorSuccess when the user starts dragging a host AND is hovering over the data providers', () => { dragFirstHostToEmptyTimelineDataProviders(); - cy.get(TIMELINE_DATA_PROVIDERS_EMPTY) - .filter(':visible') - .should( - 'have.css', - 'background', - 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' - ); + if (Cypress.browser.name === 'firefox') { + cy.get(TIMELINE_DATA_PROVIDERS_EMPTY) + .filter(':visible') + .should('have.css', 'background-color', 'rgba(1, 125, 115, 0.2)'); + } else { + cy.get(TIMELINE_DATA_PROVIDERS_EMPTY) + .filter(':visible') + .should( + 'have.css', + 'background', + 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' + ); - cy.get(TIMELINE_DATA_PROVIDERS) - .filter(':visible') - .should('have.css', 'border', '3.1875px dashed rgb(1, 125, 115)'); + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should('have.css', 'border', '3.1875px dashed rgb(1, 125, 115)'); + } }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts index bac3679f88d76..c1fc46bc28683 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts @@ -68,12 +68,18 @@ describe('timeline flyout button', () => { it('sets the data providers background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers area', () => { dragFirstHostToTimeline(); - cy.get(TIMELINE_DATA_PROVIDERS) - .filter(':visible') - .should( - 'have.css', - 'background', - 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' - ); + if (Cypress.browser.name === 'firefox') { + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should('have.css', 'background-color', 'rgba(1, 125, 115, 0.1)'); + } else { + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should( + 'have.css', + 'background', + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + ); + } }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts index 725c854656534..016197f0c7470 100644 --- a/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts @@ -48,6 +48,8 @@ const ABSOLUTE_DATE = { newStartTimeTyped: 'Aug 01, 2019 @ 14:33:29.186', startTime: '2019-08-01T20:03:29.186Z', startTimeTimeline: '2019-08-02T20:03:29.186Z', + firefoxEndTimeTyped: '2019-08-01T15:03:29', + firefoxStartTimeTyped: '2019-08-01T14:33:29', }; describe('url state', () => { @@ -73,13 +75,20 @@ describe('url state', () => { setEndDate(ABSOLUTE_DATE.newEndTimeTyped); updateDates(); + let startDate: string; + let endDate: string; + + if (Cypress.browser.name === 'firefox') { + startDate = new Date(ABSOLUTE_DATE.firefoxStartTimeTyped).toISOString().replace('000', '186'); + endDate = new Date(ABSOLUTE_DATE.firefoxEndTimeTyped).toISOString().replace('000', '186'); + } else { + startDate = new Date(ABSOLUTE_DATE.newStartTimeTyped).toISOString(); + endDate = new Date(ABSOLUTE_DATE.newEndTimeTyped).toISOString(); + } + cy.url().should( 'include', - `(global:(linkTo:!(timeline),timerange:(from:%27${new Date( - ABSOLUTE_DATE.newStartTimeTyped - ).toISOString()}%27,kind:absolute,to:%27${new Date( - ABSOLUTE_DATE.newEndTimeTyped - ).toISOString()}%27))` + `(global:(linkTo:!(timeline),timerange:(from:%27${startDate}%27,kind:absolute,to:%27${endDate}%27))` ); }); @@ -130,13 +139,20 @@ describe('url state', () => { setTimelineEndDate(ABSOLUTE_DATE.newEndTimeTyped); updateTimelineDates(); + let startDate: string; + let endDate: string; + + if (Cypress.browser.name === 'firefox') { + startDate = new Date(ABSOLUTE_DATE.firefoxStartTimeTyped).toISOString().replace('000', '186'); + endDate = new Date(ABSOLUTE_DATE.firefoxEndTimeTyped).toISOString().replace('000', '186'); + } else { + startDate = new Date(ABSOLUTE_DATE.newStartTimeTyped).toISOString(); + endDate = new Date(ABSOLUTE_DATE.newEndTimeTyped).toISOString(); + } + cy.url().should( 'include', - `timeline:(linkTo:!(),timerange:(from:%27${new Date( - ABSOLUTE_DATE.newStartTimeTyped - ).toISOString()}%27,kind:absolute,to:%27${new Date( - ABSOLUTE_DATE.newEndTimeTyped - ).toISOString()}%27))` + `timeline:(linkTo:!(),timerange:(from:%27${startDate}%27,kind:absolute,to:%27${endDate}%27))` ); }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts index 5b9cff5ec158e..838af066abd60 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts @@ -26,7 +26,11 @@ export const waitForExceptionsTableToBeLoaded = () => { }; export const searchForExceptionList = (searchText: string) => { - cy.get(EXCEPTIONS_TABLE_SEARCH).type(searchText, { force: true }).trigger('search'); + if (Cypress.browser.name === 'firefox') { + cy.get(EXCEPTIONS_TABLE_SEARCH).type(`${searchText}{enter}`, { force: true }); + } else { + cy.get(EXCEPTIONS_TABLE_SEARCH).type(searchText, { force: true }).trigger('search'); + } }; export const deleteExceptionListWithoutRuleReference = () => { diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 048f3846cc322..211e6986e19a7 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -12,6 +12,8 @@ "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:run": "../../../node_modules/.bin/cypress run --browser chrome --headless --spec ./cypress/integration/**/*.spec.ts --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json; status=$?; ../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json; ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results; mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/ && exit $status;", "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", + "cypress:run:firefox": "../../../node_modules/.bin/cypress run --browser firefox --headless --spec ./cypress/integration/**/*.spec.ts --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json; status=$?; ../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json; ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results; mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/ && exit $status;", + "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "test:generate": "node scripts/endpoint/resolver_generator" } } \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 3ee18a84e1133..e153c6d42225f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -411,13 +411,13 @@ const allowlistEventFields: AllowlistFields = { subject_name: true, trusted: true, }, - company: true, - description: true, - file_version: true, - imphash: true, - original_file_name: true, - product: true, }, + company: true, + description: true, + file_version: true, + imphash: true, + original_file_name: true, + product: true, }, pe_detected: true, region_protection: true, diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index c0175b4aeda57..cd6befac300c9 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -53,7 +53,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { history: scopedHistoryMock.create({ pathname }), }); - return { unmount, container, setBreadcrumbs }; + return { unmount, container, setBreadcrumbs, docTitle: coreStart.chrome.docTitle }; } describe('spacesManagementApp', () => { @@ -74,10 +74,12 @@ describe('spacesManagementApp', () => { }); it('mount() works for the `grid` page', async () => { - const { setBreadcrumbs, container, unmount } = await mountApp('/', '/'); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp('/', '/'); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Spaces' }]); + expect(docTitle.change).toHaveBeenCalledWith('Spaces'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
{ unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); expect(container).toMatchInlineSnapshot(`
`); }); it('mount() works for the `create space` page', async () => { - const { setBreadcrumbs, container, unmount } = await mountApp('/', '/create'); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp('/', '/create'); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Spaces' }, { text: 'Create' }, ]); + expect(docTitle.change).toHaveBeenCalledWith('Spaces'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
{ `); unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); expect(container).toMatchInlineSnapshot(`
`); }); @@ -119,13 +125,19 @@ describe('spacesManagementApp', () => { it('mount() works for the `edit space` page', async () => { const spaceId = 'some-space'; - const { setBreadcrumbs, container, unmount } = await mountApp('/', `/edit/${spaceId}`, spaceId); + const { setBreadcrumbs, container, unmount, docTitle } = await mountApp( + '/', + `/edit/${spaceId}`, + spaceId + ); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Spaces' }, { href: `/edit/${spaceId}`, text: `space with id some-space` }, ]); + expect(docTitle.change).toHaveBeenCalledWith('Spaces'); + expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
{ `); unmount(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); expect(container).toMatchInlineSnapshot(`
`); }); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 8a7582e4da447..c94baface8058 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -26,27 +26,29 @@ interface CreateParams { export const spacesManagementApp = Object.freeze({ id: 'spaces', create({ getStartServices, spacesManager }: CreateParams) { + const title = i18n.translate('xpack.spaces.displayName', { + defaultMessage: 'Spaces', + }); + return { id: this.id, order: 2, - title: i18n.translate('xpack.spaces.displayName', { - defaultMessage: 'Spaces', - }), + title, async mount({ element, setBreadcrumbs, history }) { const [ - { notifications, i18n: i18nStart, application }, + { notifications, i18n: i18nStart, application, chrome }, { features }, ] = await getStartServices(); const spacesBreadcrumbs = [ { - text: i18n.translate('xpack.spaces.management.breadcrumb', { - defaultMessage: 'Spaces', - }), + text: title, href: `/`, }, ]; + chrome.docTitle.change(title); + const SpacesGridPageWithBreadcrumbs = () => { setBreadcrumbs(spacesBreadcrumbs); return ( @@ -132,6 +134,7 @@ export const spacesManagementApp = Object.freeze({ ); return () => { + chrome.docTitle.reset(); unmountComponentAtNode(element); }; }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9da5181a776a4..de21881a4bade 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16500,7 +16500,6 @@ "xpack.security.account.passwordsDoNotMatch": "パスワードが一致していません。", "xpack.security.account.usernameGroupDescription": "この情報は変更できません。", "xpack.security.account.usernameGroupTitle": "ユーザー名とメールアドレス", - "xpack.security.apiKeys.breadcrumb": "API キー", "xpack.security.authentication.login.validateLogin.requiredPasswordErrorMessage": "パスワードが必要です", "xpack.security.authentication.login.validateLogin.requiredUsernameErrorMessage": "ユーザー名が必要です", "xpack.security.checkup.dismissButtonText": "閉じる", @@ -16903,9 +16902,7 @@ "xpack.security.role_mappings.validation.invalidRoleRule": "1つ以上のルールが必要です。", "xpack.security.role_mappings.validation.invalidRoles": "1つ以上のロールが必要です。", "xpack.security.role_mappings.validation.invalidRoleTemplates": "1つ以上のロールテンプレートが必要です。", - "xpack.security.roleMapping.breadcrumb": "ロールマッピング", "xpack.security.roleMappings.createBreadcrumb": "作成", - "xpack.security.roles.breadcrumb": "ロール", "xpack.security.roles.createBreadcrumb": "作成", "xpack.security.users.breadcrumb": "ユーザー", "xpack.securitySolution.accessibility.tooltipWithKeyboardShortcut.pressTooltipLabel": "プレス", @@ -20396,7 +20393,6 @@ "xpack.spaces.featureDescription": "ダッシュボードやその他の保存済みオブジェクトを、わかりやすいカテゴリー別に整理することができます。", "xpack.spaces.management.advancedSettingsSubtitle.applyingSettingsOnPageToSpaceDescription": "このページの設定は、別途指定されていない限り {spaceName}’スペースに適用されます。’", "xpack.spaces.management.advancedSettingsTitle.settingsTitle": "設定", - "xpack.spaces.management.breadcrumb": "スペース", "xpack.spaces.management.confirmAlterActiveSpaceModal.cancelButton": "キャンセル", "xpack.spaces.management.confirmAlterActiveSpaceModal.reloadWarningMessage": "このスペースで表示される機能を更新しました。保存後にページが更新されます。", "xpack.spaces.management.confirmAlterActiveSpaceModal.title": "スペースの更新の確認", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index eaef6eaae30bf..e7abb69b25a22 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16541,7 +16541,6 @@ "xpack.security.account.passwordsDoNotMatch": "密码不匹配。", "xpack.security.account.usernameGroupDescription": "不能更改此信息。", "xpack.security.account.usernameGroupTitle": "用户名和电子邮件", - "xpack.security.apiKeys.breadcrumb": "API 密钥", "xpack.security.authentication.login.validateLogin.requiredPasswordErrorMessage": "“密码”必填", "xpack.security.authentication.login.validateLogin.requiredUsernameErrorMessage": "“用户名”必填", "xpack.security.checkup.dismissButtonText": "关闭", @@ -16946,9 +16945,7 @@ "xpack.security.role_mappings.validation.invalidRoleRule": "至少需要一个规则。", "xpack.security.role_mappings.validation.invalidRoles": "至少需要一个角色。", "xpack.security.role_mappings.validation.invalidRoleTemplates": "至少需要一个角色模板。", - "xpack.security.roleMapping.breadcrumb": "角色映射", "xpack.security.roleMappings.createBreadcrumb": "创建", - "xpack.security.roles.breadcrumb": "角色", "xpack.security.roles.createBreadcrumb": "创建", "xpack.security.users.breadcrumb": "用户", "xpack.securitySolution.accessibility.tooltipWithKeyboardShortcut.pressTooltipLabel": "按", @@ -20442,7 +20439,6 @@ "xpack.spaces.featureDescription": "将仪表板和其他已保存的对象管理为有意义的类别。", "xpack.spaces.management.advancedSettingsSubtitle.applyingSettingsOnPageToSpaceDescription": "除非已指定,否则此页面上的设置适用于 {spaceName} 空间。", "xpack.spaces.management.advancedSettingsTitle.settingsTitle": "设置", - "xpack.spaces.management.breadcrumb": "工作区", "xpack.spaces.management.confirmAlterActiveSpaceModal.cancelButton": "取消", "xpack.spaces.management.confirmAlterActiveSpaceModal.reloadWarningMessage": "您已更新此工作区中的可见功能。保存后,您的页面将重新加载。", "xpack.spaces.management.confirmAlterActiveSpaceModal.title": "确认更新工作区", diff --git a/x-pack/test/security_solution_cypress/config.firefox.ts b/x-pack/test/security_solution_cypress/config.firefox.ts new file mode 100644 index 0000000000000..5d11c6d9364a1 --- /dev/null +++ b/x-pack/test/security_solution_cypress/config.firefox.ts @@ -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 { resolve } from 'path'; + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +import { CA_CERT_PATH } from '@kbn/dev-utils'; + +import { SecuritySolutionCypressCliFirefoxTestRunner } from './runner'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaCommonTestsConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const xpackFunctionalTestsConfig = await readConfigFile( + require.resolve('../functional/config.js') + ); + + return { + ...kibanaCommonTestsConfig.getAll(), + + esArchiver: { + directory: resolve(__dirname, 'es_archives'), + }, + + browser: { + type: 'firefox', + acceptInsecureCerts: true, + }, + + esTestCluster: { + ...xpackFunctionalTestsConfig.get('esTestCluster'), + serverArgs: [ + ...xpackFunctionalTestsConfig.get('esTestCluster.serverArgs'), + // define custom es server here + // API Keys is enabled at the top level + 'xpack.security.enabled=true', + ], + }, + + kbnTestServer: { + ...xpackFunctionalTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--csp.strict=false', + // define custom kibana server args here + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + ], + }, + + testRunner: SecuritySolutionCypressCliFirefoxTestRunner, + }; +} diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index 81e2f704b22e6..0144228fd8d9d 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -49,6 +49,45 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr }); } +export async function SecuritySolutionCypressCliFirefoxTestRunner({ + getService, +}: FtrProviderContext) { + const log = getService('log'); + const config = getService('config'); + const esArchiver = getService('esArchiver'); + + await esArchiver.load('auditbeat'); + + await withProcRunner(log, async (procs) => { + await procs.run('cypress', { + cmd: 'yarn', + args: ['cypress:run:firefox'], + cwd: resolve(__dirname, '../../plugins/security_solution'), + env: { + FORCE_COLOR: '1', + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_baseUrl: Url.format(config.get('servers.kibana')), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_protocol: config.get('servers.kibana.protocol'), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_hostname: config.get('servers.kibana.hostname'), + // eslint-disable-next-line @typescript-eslint/naming-convention + CYPRESS_configport: config.get('servers.kibana.port'), + CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), + CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), + CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), + CYPRESS_KIBANA_URL: Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: config.get('servers.kibana.port'), + }), + ...process.env, + }, + wait: true, + }); + }); +} + export async function SecuritySolutionCypressVisualTestRunner({ getService }: FtrProviderContext) { const log = getService('log'); const config = getService('config'); diff --git a/x-pack/typings/elasticsearch/index.d.ts b/x-pack/typings/elasticsearch/index.d.ts index b174db739b030..41630e81f13e4 100644 --- a/x-pack/typings/elasticsearch/index.d.ts +++ b/x-pack/typings/elasticsearch/index.d.ts @@ -25,7 +25,7 @@ export type MaybeReadonlyArray = T[] | readonly T[]; interface CollapseQuery { field: string; - inner_hits: { + inner_hits?: { name: string; size?: number; sort?: SortOptions;