diff --git a/.eslintrc.js b/.eslintrc.js index 8d979dc0f8645e..4425ad3a12659d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -906,6 +906,18 @@ module.exports = { }, }, + /** + * Enterprise Search overrides + */ + { + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + excludedFiles: ['x-pack/plugins/enterprise_search/**/*.{test,mock}.{ts,tsx}'], + rules: { + 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + /** * disable jsx-a11y for kbn-ui-framework */ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4aab9943022d4c..f053c6da9c29bd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -201,6 +201,11 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Design **/*.scss @elastic/kibana-design +# Enterprise Search +/x-pack/plugins/enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend +/x-pack/test/functional_enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend +/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design + # Elasticsearch UI /src/plugins/dev_tools/ @elastic/es-ui /src/plugins/console/ @elastic/es-ui diff --git a/docs/management/images/management-license.png b/docs/management/images/management-license.png index 3347aec8632e4f..8df9402939b2e9 100644 Binary files a/docs/management/images/management-license.png and b/docs/management/images/management-license.png differ diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 6cd6657a0aaeb8..99cfd12eeade9c 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,28 +1,27 @@ [[managing-licenses]] == License Management -When you install the default distribution of {kib}, you receive a basic license -with no expiration date. For the full list of free features that are included in -the basic license, refer to https://www.elastic.co/subscriptions[the subscription page]. +When you install the default distribution of {kib}, you receive free features +with no expiration date. For the full list of features, refer to +{subscriptions}. -If you want to try out the full set of platinum features, you can activate a -30-day trial license. To view the -status of your license, start a trial, or install a new license, open the menu, then go to *Stack Management > {es} > License Management*. +If you want to try out the full set of features, you can activate a free 30-day +trial. To view the status of your license, start a trial, or install a new +license, open the menu, then go to *Stack Management > {es} > License Management*. NOTE: You can start a trial only if your cluster has not already activated a trial license for the current major product version. For example, if you have already activated a trial for 6.0, you cannot start a new trial until -7.0. You can, however, contact `info@elastic.co` to request an extended trial -license. +7.0. You can, however, request an extended trial at {extendtrial}. When you activate a new license level, new features appear in *Stack Management*. [role="screenshot"] image::images/management-license.png[] -At the end of the trial period, the platinum features operate in a -<>. You can revert to a basic license, -extend the trial, or purchase a subscription. +At the end of the trial period, some features operate in a +<>. You can revert to Basic, extend the trial, +or purchase a subscription. TIP: If {security-features} are enabled, unless you have a trial license, you must configure Transport Layer Security (TLS) in {es}. diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index 6cbdc5ec7fc208..e1d3bf1a8d9016 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -148,7 +148,7 @@ export const schema = Joi.object() browser: Joi.object() .keys({ - type: Joi.string().valid('chrome', 'firefox', 'ie', 'msedge').default('chrome'), + type: Joi.string().valid('chrome', 'firefox', 'msedge').default('chrome'), logPollingMs: Joi.number().default(100), acceptInsecureCerts: Joi.boolean().default(false), diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 9fee7b50f371b2..1cfded4dc7b8f4 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -149,7 +149,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoSecurity", "id": "security", "label": "Security", - "order": 3000, + "order": 4000, }, "data-test-subj": "siem", "href": "siem", @@ -164,7 +164,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "metrics", "href": "metrics", @@ -233,7 +233,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "logs", "href": "logs", diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 86e281a49b744a..40fc3f977006f4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -582,6 +582,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index efeafc9e68d359..95912c3af63e51 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -566,6 +566,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 5708bcfeac31a7..cc9bfb1db04d5e 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -29,20 +29,28 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ euiIconType: 'logoKibana', order: 1000, }, + enterpriseSearch: { + id: 'enterpriseSearch', + label: i18n.translate('core.ui.enterpriseSearchNavList.label', { + defaultMessage: 'Enterprise Search', + }), + order: 2000, + euiIconType: 'logoEnterpriseSearch', + }, observability: { id: 'observability', label: i18n.translate('core.ui.observabilityNavList.label', { defaultMessage: 'Observability', }), euiIconType: 'logoObservability', - order: 2000, + order: 3000, }, security: { id: 'security', label: i18n.translate('core.ui.securityNavList.label', { defaultMessage: 'Security', }), - order: 3000, + order: 4000, euiIconType: 'logoSecurity', }, management: { diff --git a/src/plugins/apm_oss/server/tutorial/index.ts b/src/plugins/apm_oss/server/tutorial/index.ts index aa775d007de309..42609f7d759174 100644 --- a/src/plugins/apm_oss/server/tutorial/index.ts +++ b/src/plugins/apm_oss/server/tutorial/index.ts @@ -26,6 +26,7 @@ import { APM_STATIC_INDEX_PATTERN_ID } from '../../common/index_pattern_constant const apmIntro = i18n.translate('apmOss.tutorial.introduction', { defaultMessage: 'Collect in-depth performance metrics and errors from inside your applications.', }); +const moduleName = 'apm'; export const tutorialProvider = ({ indexPatternTitle, @@ -68,6 +69,7 @@ export const tutorialProvider = ({ name: i18n.translate('apmOss.tutorial.specProvider.name', { defaultMessage: 'APM', }), + moduleName, category: TutorialsCategory.OTHER, shortDescription: apmIntro, longDescription: i18n.translate('apmOss.tutorial.specProvider.longDescription', { diff --git a/src/plugins/home/public/application/application.tsx b/src/plugins/home/public/application/application.tsx index 3729e4e2aa089d..627bd10d7c2c83 100644 --- a/src/plugins/home/public/application/application.tsx +++ b/src/plugins/home/public/application/application.tsx @@ -20,14 +20,19 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { ScopedHistory } from 'kibana/public'; +import { ScopedHistory, CoreStart } from 'kibana/public'; +import { KibanaContextProvider } from '../../../kibana_react/public'; // @ts-ignore import { HomeApp } from './components/home_app'; import { getServices } from './kibana_services'; import './index.scss'; -export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { +export const renderApp = async ( + element: HTMLElement, + coreStart: CoreStart, + history: ScopedHistory +) => { const homeTitle = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); const { featureCatalogue, chrome } = getServices(); @@ -36,7 +41,12 @@ export const renderApp = async (element: HTMLElement, history: ScopedHistory) => chrome.setBreadcrumbs([{ text: homeTitle }]); - render(, element); + render( + + + , + element + ); // dispatch synthetic hash change event to update hash history objects // this is necessary because hash updates triggered by using popState won't trigger this event naturally. diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index 576f732278b8e4..8139bc6d38ab1d 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -334,6 +334,23 @@ class TutorialUi extends React.Component { } }; + renderModuleNotices() { + const notices = getServices().tutorialService.getModuleNotices(); + if (notices.length && this.state.tutorial.moduleName) { + return ( + + {notices.map((ModuleNotice, index) => ( + + + + ))} + + ); + } else { + return null; + } + } + render() { let content; if (this.state.notFound) { @@ -382,6 +399,7 @@ class TutorialUi extends React.Component { isBeta={this.state.tutorial.isBeta} /> + {this.renderModuleNotices()}
{this.renderInstructionSetsToggle()}
diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.test.js b/src/plugins/home/public/application/components/tutorial/tutorial.test.js index 23b0dc50018c18..9944ac4848bc61 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.test.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.test.js @@ -28,6 +28,9 @@ jest.mock('../../kibana_services', () => ({ chrome: { setBreadcrumbs: () => {}, }, + tutorialService: { + getModuleNotices: () => [], + }, }), })); jest.mock('../../../../../kibana_react/public', () => { diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 774b23af11ac85..948024ae85dda4 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -30,6 +30,7 @@ import { EuiTab, EuiFlexItem, EuiFlexGrid, + EuiFlexGroup, EuiSpacer, EuiTitle, EuiPageBody, @@ -102,6 +103,7 @@ class TutorialDirectoryUi extends React.Component { this.state = { selectedTabId: openTab, tutorialCards: [], + notices: getServices().tutorialService.getDirectoryNotices(), }; } @@ -227,18 +229,62 @@ class TutorialDirectoryUi extends React.Component { ); }; + renderNotices = () => { + const notices = getServices().tutorialService.getDirectoryNotices(); + return notices.length ? ( + + {notices.map((DirectoryNotice, index) => ( + + + + ))} + + ) : null; + }; + + renderHeaderLinks = () => { + const headerLinks = getServices().tutorialService.getDirectoryHeaderLinks(); + return headerLinks.length ? ( + + {headerLinks.map((HeaderLink, index) => ( + + + + ))} + + ) : null; + }; + + renderHeader = () => { + const notices = this.renderNotices(); + const headerLinks = this.renderHeaderLinks(); + + return ( + <> + + + +

+ +

+
+
+ {headerLinks ? {headerLinks} : null} +
+ {notices} + + ); + }; + render() { return ( - -

- -

-
- + {this.renderHeader()} - {this.renderTabs()} {this.renderTabContent()} diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index 587dbe886d505b..dc48332e052de0 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -30,6 +30,9 @@ export { FeatureCatalogueCategory, Environment, TutorialVariables, + TutorialDirectoryNoticeComponent, + TutorialDirectoryHeaderLinkComponent, + TutorialModuleNoticeComponent, } from './services'; export * from '../common/instruction_variant'; import { HomePublicPlugin } from './plugin'; diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index d05fce652bd406..6859d916a61afd 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -104,7 +104,7 @@ export class HomePublicPlugin i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) ); const { renderApp } = await import('./application'); - return await renderApp(params.element, params.history); + return await renderApp(params.element, coreStart, params.history); }, }); kibanaLegacy.forwardApp('home', 'home'); diff --git a/src/plugins/home/public/services/tutorials/index.ts b/src/plugins/home/public/services/tutorials/index.ts index 3de1e67204d966..44f0badd531b7c 100644 --- a/src/plugins/home/public/services/tutorials/index.ts +++ b/src/plugins/home/public/services/tutorials/index.ts @@ -17,4 +17,11 @@ * under the License. */ -export { TutorialService, TutorialVariables, TutorialServiceSetup } from './tutorial_service'; +export { + TutorialService, + TutorialVariables, + TutorialServiceSetup, + TutorialDirectoryNoticeComponent, + TutorialDirectoryHeaderLinkComponent, + TutorialModuleNoticeComponent, +} from './tutorial_service'; diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts b/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts index bd604fb231dee2..667730e25a2e3e 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts +++ b/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts @@ -22,6 +22,9 @@ import { TutorialService, TutorialServiceSetup } from './tutorial_service'; const createSetupMock = (): jest.Mocked => { const setup = { setVariable: jest.fn(), + registerDirectoryNotice: jest.fn(), + registerDirectoryHeaderLink: jest.fn(), + registerModuleNotice: jest.fn(), }; return setup; }; @@ -30,6 +33,9 @@ const createMock = (): jest.Mocked> => { const service = { setup: jest.fn(), getVariables: jest.fn(() => ({})), + getDirectoryNotices: jest.fn(() => []), + getDirectoryHeaderLinks: jest.fn(() => []), + getModuleNotices: jest.fn(() => []), }; service.setup.mockImplementation(createSetupMock); return service; diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.test.ts b/src/plugins/home/public/services/tutorials/tutorial_service.test.ts deleted file mode 100644 index f4bcd71a39e8ae..00000000000000 --- a/src/plugins/home/public/services/tutorials/tutorial_service.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { TutorialService } from './tutorial_service'; - -describe('TutorialService', () => { - describe('setup', () => { - test('allows multiple set calls', () => { - const setup = new TutorialService().setup(); - expect(() => { - setup.setVariable('abc', 123); - setup.setVariable('def', 456); - }).not.toThrow(); - }); - - test('throws when same variable is set twice', () => { - const setup = new TutorialService().setup(); - expect(() => { - setup.setVariable('abc', 123); - setup.setVariable('abc', 456); - }).toThrow(); - }); - }); - - describe('getVariables', () => { - test('returns empty object', () => { - const service = new TutorialService(); - expect(service.getVariables()).toEqual({}); - }); - - test('returns last state of update calls', () => { - const service = new TutorialService(); - const setup = service.setup(); - setup.setVariable('abc', 123); - setup.setVariable('def', { subKey: 456 }); - expect(service.getVariables()).toEqual({ abc: 123, def: { subKey: 456 } }); - }); - }); -}); diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx b/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx new file mode 100644 index 00000000000000..2a60550e39d90b --- /dev/null +++ b/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx @@ -0,0 +1,151 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { TutorialService } from './tutorial_service'; + +describe('TutorialService', () => { + describe('setup', () => { + test('allows multiple set variable calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.setVariable('abc', 123); + setup.setVariable('def', 456); + }).not.toThrow(); + }); + + test('throws when same variable is set twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.setVariable('abc', 123); + setup.setVariable('abc', 456); + }).toThrow(); + }); + + test('allows multiple register directory notice calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryNotice('abc', () =>
); + setup.registerDirectoryNotice('def', () => ); + }).not.toThrow(); + }); + + test('throws when same directory notice is registered twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryNotice('abc', () =>
); + setup.registerDirectoryNotice('abc', () => ); + }).toThrow(); + }); + + test('allows multiple register directory header link calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryHeaderLink('abc', () => 123); + setup.registerDirectoryHeaderLink('def', () => 456); + }).not.toThrow(); + }); + + test('throws when same directory header link is registered twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryHeaderLink('abc', () => 123); + setup.registerDirectoryHeaderLink('abc', () => 456); + }).toThrow(); + }); + + test('allows multiple register module notice calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerModuleNotice('abc', () =>
); + setup.registerModuleNotice('def', () => ); + }).not.toThrow(); + }); + + test('throws when same module notice is registered twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerModuleNotice('abc', () =>
); + setup.registerModuleNotice('abc', () => ); + }).toThrow(); + }); + }); + + describe('getVariables', () => { + test('returns empty object', () => { + const service = new TutorialService(); + expect(service.getVariables()).toEqual({}); + }); + + test('returns last state of update calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + setup.setVariable('abc', 123); + setup.setVariable('def', { subKey: 456 }); + expect(service.getVariables()).toEqual({ abc: 123, def: { subKey: 456 } }); + }); + }); + + describe('getDirectoryNotices', () => { + test('returns empty array', () => { + const service = new TutorialService(); + expect(service.getDirectoryNotices()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + const notices = [() =>
, () => ]; + setup.registerDirectoryNotice('abc', notices[0]); + setup.registerDirectoryNotice('def', notices[1]); + expect(service.getDirectoryNotices()).toEqual(notices); + }); + }); + + describe('getDirectoryHeaderLinks', () => { + test('returns empty array', () => { + const service = new TutorialService(); + expect(service.getDirectoryHeaderLinks()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + const links = [() => 123, () => 456]; + setup.registerDirectoryHeaderLink('abc', links[0]); + setup.registerDirectoryHeaderLink('def', links[1]); + expect(service.getDirectoryHeaderLinks()).toEqual(links); + }); + }); + + describe('getModuleNotices', () => { + test('returns empty array', () => { + const service = new TutorialService(); + expect(service.getModuleNotices()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + const notices = [() =>
, () => ]; + setup.registerModuleNotice('abc', notices[0]); + setup.registerModuleNotice('def', notices[1]); + expect(service.getModuleNotices()).toEqual(notices); + }); + }); +}); diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.ts b/src/plugins/home/public/services/tutorials/tutorial_service.ts index 38297a64373152..538cea1c704581 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.ts +++ b/src/plugins/home/public/services/tutorials/tutorial_service.ts @@ -16,12 +16,29 @@ * specific language governing permissions and limitations * under the License. */ +import React from 'react'; /** @public */ export type TutorialVariables = Partial>; +/** @public */ +export type TutorialDirectoryNoticeComponent = React.FC; + +/** @public */ +export type TutorialDirectoryHeaderLinkComponent = React.FC; + +/** @public */ +export type TutorialModuleNoticeComponent = React.FC<{ + moduleName: string; +}>; + export class TutorialService { private tutorialVariables: TutorialVariables = {}; + private tutorialDirectoryNotices: { [key: string]: TutorialDirectoryNoticeComponent } = {}; + private tutorialDirectoryHeaderLinks: { + [key: string]: TutorialDirectoryHeaderLinkComponent; + } = {}; + private tutorialModuleNotices: { [key: string]: TutorialModuleNoticeComponent } = {}; public setup() { return { @@ -34,12 +51,57 @@ export class TutorialService { } this.tutorialVariables[key] = value; }, + + /** + * Registers a component that will be rendered at the top of tutorial directory page. + */ + registerDirectoryNotice: (id: string, component: TutorialDirectoryNoticeComponent) => { + if (this.tutorialDirectoryNotices[id]) { + throw new Error(`directory notice ${id} already set`); + } + this.tutorialDirectoryNotices[id] = component; + }, + + /** + * Registers a component that will be rendered next to tutorial directory title/header area. + */ + registerDirectoryHeaderLink: ( + id: string, + component: TutorialDirectoryHeaderLinkComponent + ) => { + if (this.tutorialDirectoryHeaderLinks[id]) { + throw new Error(`directory header link ${id} already set`); + } + this.tutorialDirectoryHeaderLinks[id] = component; + }, + + /** + * Registers a component that will be rendered in the description of a tutorial that is associated with a module. + */ + registerModuleNotice: (id: string, component: TutorialModuleNoticeComponent) => { + if (this.tutorialModuleNotices[id]) { + throw new Error(`module notice ${id} already set`); + } + this.tutorialModuleNotices[id] = component; + }, }; } public getVariables() { return this.tutorialVariables; } + + public getDirectoryNotices() { + return Object.values(this.tutorialDirectoryNotices); + } + + public getDirectoryHeaderLinks() { + return Object.values(this.tutorialDirectoryHeaderLinks); + } + + public getModuleNotices() { + return Object.values(this.tutorialModuleNotices); + } } export type TutorialServiceSetup = ReturnType; diff --git a/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts index 32e5483b8b0703..bf28212624a4d7 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts @@ -110,6 +110,7 @@ export const tutorialSchema = { .required(), category: Joi.string().valid(Object.values(TUTORIAL_CATEGORY)).required(), name: Joi.string().required(), + moduleName: Joi.string(), isBeta: Joi.boolean().default(false), shortDescription: Joi.string().required(), euiIconType: Joi.string(), // EUI icon type string, one of https://elastic.github.io/eui/#/icons diff --git a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts index 210d563696667b..a6b70cd70c02d7 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts @@ -80,6 +80,7 @@ export interface TutorialSchema { id: string; category: TutorialsCategory; name: string; + moduleName?: string; isBeta?: boolean; shortDescription: string; euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/display/icons; diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts index 8144fef2d92e46..b91a265da7d18f 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts @@ -54,6 +54,7 @@ const VALID_TUTORIAL: TutorialSchema = { id: 'test', category: 'logging' as TutorialsCategory, name: 'new tutorial provider', + moduleName: 'test', isBeta: false, shortDescription: 'short description', euiIconType: 'alert', diff --git a/src/plugins/home/server/tutorials/activemq_logs/index.ts b/src/plugins/home/server/tutorials/activemq_logs/index.ts index e85100996d4a16..c11c070637ae1b 100644 --- a/src/plugins/home/server/tutorials/activemq_logs/index.ts +++ b/src/plugins/home/server/tutorials/activemq_logs/index.ts @@ -37,6 +37,7 @@ export function activemqLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.activemqLogs.nameTitle', { defaultMessage: 'ActiveMQ logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.activemqLogs.shortDescription', { defaultMessage: 'Collect ActiveMQ logs with Filebeat.', diff --git a/src/plugins/home/server/tutorials/activemq_metrics/index.ts b/src/plugins/home/server/tutorials/activemq_metrics/index.ts index 088c5db4c6137a..e00ffb4773bea8 100644 --- a/src/plugins/home/server/tutorials/activemq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/activemq_metrics/index.ts @@ -36,6 +36,7 @@ export function activemqMetricsSpecProvider(context: TutorialContext): TutorialS name: i18n.translate('home.tutorials.activemqMetrics.nameTitle', { defaultMessage: 'ActiveMQ metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.activemqMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from ActiveMQ instances.', diff --git a/src/plugins/home/server/tutorials/aerospike_metrics/index.ts b/src/plugins/home/server/tutorials/aerospike_metrics/index.ts index 58ab2dcf0986f9..c65022c1875c4c 100644 --- a/src/plugins/home/server/tutorials/aerospike_metrics/index.ts +++ b/src/plugins/home/server/tutorials/aerospike_metrics/index.ts @@ -36,6 +36,7 @@ export function aerospikeMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.aerospikeMetrics.nameTitle', { defaultMessage: 'Aerospike metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.aerospikeMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/apache_logs/index.ts b/src/plugins/home/server/tutorials/apache_logs/index.ts index 434f0b0b83f98b..94fa9ad1258ec0 100644 --- a/src/plugins/home/server/tutorials/apache_logs/index.ts +++ b/src/plugins/home/server/tutorials/apache_logs/index.ts @@ -37,6 +37,7 @@ export function apacheLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.apacheLogs.nameTitle', { defaultMessage: 'Apache logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.apacheLogs.shortDescription', { defaultMessage: 'Collect and parse access and error logs created by the Apache HTTP server.', diff --git a/src/plugins/home/server/tutorials/apache_metrics/index.ts b/src/plugins/home/server/tutorials/apache_metrics/index.ts index 1521c9820c400e..91de90b9f6c6b2 100644 --- a/src/plugins/home/server/tutorials/apache_metrics/index.ts +++ b/src/plugins/home/server/tutorials/apache_metrics/index.ts @@ -36,6 +36,7 @@ export function apacheMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.apacheMetrics.nameTitle', { defaultMessage: 'Apache metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.apacheMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the Apache 2 HTTP server.', diff --git a/src/plugins/home/server/tutorials/auditbeat/index.ts b/src/plugins/home/server/tutorials/auditbeat/index.ts index 214fda5a7cc538..44a97bfce6cef5 100644 --- a/src/plugins/home/server/tutorials/auditbeat/index.ts +++ b/src/plugins/home/server/tutorials/auditbeat/index.ts @@ -31,11 +31,13 @@ import { export function auditbeatSpecProvider(context: TutorialContext): TutorialSchema { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS'] as const; + const moduleName = 'auditbeat'; return { id: 'auditbeat', name: i18n.translate('home.tutorials.auditbeat.nameTitle', { defaultMessage: 'Auditbeat', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.auditbeat.shortDescription', { defaultMessage: 'Collect audit data from your hosts.', diff --git a/src/plugins/home/server/tutorials/aws_logs/index.ts b/src/plugins/home/server/tutorials/aws_logs/index.ts index 2fa22fa2c2d700..b875d93952c7a4 100644 --- a/src/plugins/home/server/tutorials/aws_logs/index.ts +++ b/src/plugins/home/server/tutorials/aws_logs/index.ts @@ -37,6 +37,7 @@ export function awsLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.awsLogs.nameTitle', { defaultMessage: 'AWS S3 based logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.awsLogs.shortDescription', { defaultMessage: 'Collect AWS logs from S3 bucket with Filebeat.', diff --git a/src/plugins/home/server/tutorials/aws_metrics/index.ts b/src/plugins/home/server/tutorials/aws_metrics/index.ts index c52620e150b5fe..549e98280bef22 100644 --- a/src/plugins/home/server/tutorials/aws_metrics/index.ts +++ b/src/plugins/home/server/tutorials/aws_metrics/index.ts @@ -36,6 +36,7 @@ export function awsMetricsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.awsMetrics.nameTitle', { defaultMessage: 'AWS metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.awsMetrics.shortDescription', { defaultMessage: diff --git a/src/plugins/home/server/tutorials/azure_logs/index.ts b/src/plugins/home/server/tutorials/azure_logs/index.ts index 06aef411775f1a..3624bea96b465c 100644 --- a/src/plugins/home/server/tutorials/azure_logs/index.ts +++ b/src/plugins/home/server/tutorials/azure_logs/index.ts @@ -37,6 +37,7 @@ export function azureLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.azureLogs.nameTitle', { defaultMessage: 'Azure logs', }), + moduleName, isBeta: true, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.azureLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/azure_metrics/index.ts b/src/plugins/home/server/tutorials/azure_metrics/index.ts index c11b3ac0139bae..ac92d70fc64f5f 100644 --- a/src/plugins/home/server/tutorials/azure_metrics/index.ts +++ b/src/plugins/home/server/tutorials/azure_metrics/index.ts @@ -36,6 +36,7 @@ export function azureMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.azureMetrics.nameTitle', { defaultMessage: 'Azure metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.azureMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/ceph_metrics/index.ts b/src/plugins/home/server/tutorials/ceph_metrics/index.ts index 968a0a3f66b0a3..71e540454bc3a7 100644 --- a/src/plugins/home/server/tutorials/ceph_metrics/index.ts +++ b/src/plugins/home/server/tutorials/ceph_metrics/index.ts @@ -36,6 +36,7 @@ export function cephMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.cephMetrics.nameTitle', { defaultMessage: 'Ceph metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.cephMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/cisco_logs/index.ts b/src/plugins/home/server/tutorials/cisco_logs/index.ts index 2322f503b80ce5..b771744a069c3d 100644 --- a/src/plugins/home/server/tutorials/cisco_logs/index.ts +++ b/src/plugins/home/server/tutorials/cisco_logs/index.ts @@ -37,6 +37,7 @@ export function ciscoLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.ciscoLogs.nameTitle', { defaultMessage: 'Cisco', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.ciscoLogs.shortDescription', { defaultMessage: 'Collect and parse logs received from Cisco ASA firewalls.', diff --git a/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts b/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts index 9d33d9bf786d01..fb7b07c5dc1af2 100644 --- a/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts +++ b/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts @@ -30,11 +30,13 @@ import { } from '../../services/tutorials/lib/tutorials_registry_types'; export function cloudwatchLogsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'aws'; return { id: 'cloudwatchLogs', name: i18n.translate('home.tutorials.cloudwatchLogs.nameTitle', { defaultMessage: 'AWS Cloudwatch logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.cloudwatchLogs.shortDescription', { defaultMessage: 'Collect Cloudwatch logs with Functionbeat.', diff --git a/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts b/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts index 96c02f24e347ae..1cb318c83bd34b 100644 --- a/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts @@ -36,6 +36,7 @@ export function cockroachdbMetricsSpecProvider(context: TutorialContext): Tutori name: i18n.translate('home.tutorials.cockroachdbMetrics.nameTitle', { defaultMessage: 'CockroachDB metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.cockroachdbMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the CockroachDB server.', diff --git a/src/plugins/home/server/tutorials/consul_metrics/index.ts b/src/plugins/home/server/tutorials/consul_metrics/index.ts index 8bf4333cb018f5..e389db502a769f 100644 --- a/src/plugins/home/server/tutorials/consul_metrics/index.ts +++ b/src/plugins/home/server/tutorials/consul_metrics/index.ts @@ -36,6 +36,7 @@ export function consulMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.consulMetrics.nameTitle', { defaultMessage: 'Consul metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.consulMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the Consul server.', diff --git a/src/plugins/home/server/tutorials/coredns_logs/index.ts b/src/plugins/home/server/tutorials/coredns_logs/index.ts index 4304fb7acb9079..7fc8a2402d2167 100644 --- a/src/plugins/home/server/tutorials/coredns_logs/index.ts +++ b/src/plugins/home/server/tutorials/coredns_logs/index.ts @@ -37,6 +37,7 @@ export function corednsLogsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.corednsLogs.nameTitle', { defaultMessage: 'CoreDNS logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.corednsLogs.shortDescription', { defaultMessage: 'Collect the logs created by Coredns.', diff --git a/src/plugins/home/server/tutorials/coredns_metrics/index.ts b/src/plugins/home/server/tutorials/coredns_metrics/index.ts index 44bd0cb3999f65..c6589715ba9ce4 100644 --- a/src/plugins/home/server/tutorials/coredns_metrics/index.ts +++ b/src/plugins/home/server/tutorials/coredns_metrics/index.ts @@ -36,6 +36,7 @@ export function corednsMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.corednsMetrics.nameTitle', { defaultMessage: 'CoreDNS metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.corednsMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the CoreDNS server.', diff --git a/src/plugins/home/server/tutorials/couchbase_metrics/index.ts b/src/plugins/home/server/tutorials/couchbase_metrics/index.ts index efd59029c9c502..370541c9324d8e 100644 --- a/src/plugins/home/server/tutorials/couchbase_metrics/index.ts +++ b/src/plugins/home/server/tutorials/couchbase_metrics/index.ts @@ -36,6 +36,7 @@ export function couchbaseMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.couchbaseMetrics.nameTitle', { defaultMessage: 'Couchbase metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.couchbaseMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/couchdb_metrics/index.ts b/src/plugins/home/server/tutorials/couchdb_metrics/index.ts index 1fbaa448172262..8d70fcf2a6cd72 100644 --- a/src/plugins/home/server/tutorials/couchdb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/couchdb_metrics/index.ts @@ -36,6 +36,7 @@ export function couchdbMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.couchdbMetrics.nameTitle', { defaultMessage: 'CouchDB metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.couchdbMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the CouchdB server.', diff --git a/src/plugins/home/server/tutorials/docker_metrics/index.ts b/src/plugins/home/server/tutorials/docker_metrics/index.ts index 8c603697c47136..2e0c3ccb642dd8 100644 --- a/src/plugins/home/server/tutorials/docker_metrics/index.ts +++ b/src/plugins/home/server/tutorials/docker_metrics/index.ts @@ -36,6 +36,7 @@ export function dockerMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.dockerMetrics.nameTitle', { defaultMessage: 'Docker metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.dockerMetrics.shortDescription', { defaultMessage: 'Fetch metrics about your Docker containers.', diff --git a/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts b/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts index 008a7a9b3a6970..d74db4b2ad9580 100644 --- a/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts +++ b/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts @@ -36,6 +36,7 @@ export function dropwizardMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.dropwizardMetrics.nameTitle', { defaultMessage: 'Dropwizard metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.dropwizardMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts b/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts index 515b06ea82a5ef..f6c280d29f67fc 100644 --- a/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts +++ b/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts @@ -37,6 +37,7 @@ export function elasticsearchLogsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.elasticsearchLogs.nameTitle', { defaultMessage: 'Elasticsearch logs', }), + moduleName, category: TutorialsCategory.LOGGING, isBeta: true, shortDescription: i18n.translate('home.tutorials.elasticsearchLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts b/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts index ea6dcf86d23e21..38713056e0640a 100644 --- a/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts +++ b/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts @@ -36,6 +36,7 @@ export function elasticsearchMetricsSpecProvider(context: TutorialContext): Tuto name: i18n.translate('home.tutorials.elasticsearchMetrics.nameTitle', { defaultMessage: 'Elasticsearch metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.elasticsearchMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts b/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts index a9b9c33d61bdf1..0cf032e6b90c15 100644 --- a/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts +++ b/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts @@ -37,6 +37,7 @@ export function envoyproxyLogsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.envoyproxyLogs.nameTitle', { defaultMessage: 'Envoyproxy', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.envoyproxyLogs.shortDescription', { defaultMessage: 'Collect and parse logs received from the Envoy proxy.', diff --git a/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts b/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts index adc7a494200c17..9b453370fb8026 100644 --- a/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts +++ b/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts @@ -36,6 +36,7 @@ export function envoyproxyMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.envoyproxyMetrics.nameTitle', { defaultMessage: 'Envoy Proxy metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.envoyproxyMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from Envoy Proxy.', diff --git a/src/plugins/home/server/tutorials/etcd_metrics/index.ts b/src/plugins/home/server/tutorials/etcd_metrics/index.ts index 2956473b6643bf..48bdba5abb4b34 100644 --- a/src/plugins/home/server/tutorials/etcd_metrics/index.ts +++ b/src/plugins/home/server/tutorials/etcd_metrics/index.ts @@ -36,6 +36,7 @@ export function etcdMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.etcdMetrics.nameTitle', { defaultMessage: 'Etcd metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.etcdMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/golang_metrics/index.ts b/src/plugins/home/server/tutorials/golang_metrics/index.ts index c53f8b2bba2817..e5ecbb9eb583b3 100644 --- a/src/plugins/home/server/tutorials/golang_metrics/index.ts +++ b/src/plugins/home/server/tutorials/golang_metrics/index.ts @@ -36,6 +36,7 @@ export function golangMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.golangMetrics.nameTitle', { defaultMessage: 'Golang metrics', }), + moduleName, isBeta: true, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.golangMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts index 504ede04c12d8e..42dc0720c10e0d 100644 --- a/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts +++ b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts @@ -36,6 +36,7 @@ export function googlecloudMetricsSpecProvider(context: TutorialContext): Tutori name: i18n.translate('home.tutorials.googlecloudMetrics.nameTitle', { defaultMessage: 'Google Cloud metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.googlecloudMetrics.shortDescription', { defaultMessage: diff --git a/src/plugins/home/server/tutorials/haproxy_metrics/index.ts b/src/plugins/home/server/tutorials/haproxy_metrics/index.ts index f06dfaa93063c0..49e2ec4390db9f 100644 --- a/src/plugins/home/server/tutorials/haproxy_metrics/index.ts +++ b/src/plugins/home/server/tutorials/haproxy_metrics/index.ts @@ -36,6 +36,7 @@ export function haproxyMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.haproxyMetrics.nameTitle', { defaultMessage: 'HAProxy metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.haproxyMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/ibmmq_logs/index.ts b/src/plugins/home/server/tutorials/ibmmq_logs/index.ts index 5739c03954def0..8f67b88c3fcf2b 100644 --- a/src/plugins/home/server/tutorials/ibmmq_logs/index.ts +++ b/src/plugins/home/server/tutorials/ibmmq_logs/index.ts @@ -37,6 +37,7 @@ export function ibmmqLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.ibmmqLogs.nameTitle', { defaultMessage: 'IBM MQ logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.ibmmqLogs.shortDescription', { defaultMessage: 'Collect IBM MQ logs with Filebeat.', diff --git a/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts b/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts index 4f20b2d0684fc9..dc941233b02333 100644 --- a/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts @@ -36,6 +36,7 @@ export function ibmmqMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.ibmmqMetrics.nameTitle', { defaultMessage: 'IBM MQ metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.ibmmqMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from IBM MQ instances.', diff --git a/src/plugins/home/server/tutorials/iis_logs/index.ts b/src/plugins/home/server/tutorials/iis_logs/index.ts index fee8d036db757e..12411fc792e64e 100644 --- a/src/plugins/home/server/tutorials/iis_logs/index.ts +++ b/src/plugins/home/server/tutorials/iis_logs/index.ts @@ -37,6 +37,7 @@ export function iisLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.iisLogs.nameTitle', { defaultMessage: 'IIS logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.iisLogs.shortDescription', { defaultMessage: 'Collect and parse access and error logs created by the IIS HTTP server.', diff --git a/src/plugins/home/server/tutorials/iis_metrics/index.ts b/src/plugins/home/server/tutorials/iis_metrics/index.ts index 46621677a67ceb..d6dc5a2e33704d 100644 --- a/src/plugins/home/server/tutorials/iis_metrics/index.ts +++ b/src/plugins/home/server/tutorials/iis_metrics/index.ts @@ -36,6 +36,7 @@ export function iisMetricsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.iisMetrics.nameTitle', { defaultMessage: 'IIS Metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.iisMetrics.shortDescription', { defaultMessage: 'Collect IIS server related metrics.', diff --git a/src/plugins/home/server/tutorials/iptables_logs/index.ts b/src/plugins/home/server/tutorials/iptables_logs/index.ts index fd84894dae8508..b3be1337674476 100644 --- a/src/plugins/home/server/tutorials/iptables_logs/index.ts +++ b/src/plugins/home/server/tutorials/iptables_logs/index.ts @@ -37,6 +37,7 @@ export function iptablesLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.iptablesLogs.nameTitle', { defaultMessage: 'Iptables / Ubiquiti', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.iptablesLogs.shortDescription', { defaultMessage: 'Collect and parse iptables and ip6tables logs or from Ubiqiti firewalls.', diff --git a/src/plugins/home/server/tutorials/kafka_logs/index.ts b/src/plugins/home/server/tutorials/kafka_logs/index.ts index 746e65b71008c1..aac172520829c3 100644 --- a/src/plugins/home/server/tutorials/kafka_logs/index.ts +++ b/src/plugins/home/server/tutorials/kafka_logs/index.ts @@ -37,6 +37,7 @@ export function kafkaLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.kafkaLogs.nameTitle', { defaultMessage: 'Kafka logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.kafkaLogs.shortDescription', { defaultMessage: 'Collect and parse logs created by Kafka.', diff --git a/src/plugins/home/server/tutorials/kafka_metrics/index.ts b/src/plugins/home/server/tutorials/kafka_metrics/index.ts index 55860a3ab649a4..1b0ce44db65503 100644 --- a/src/plugins/home/server/tutorials/kafka_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kafka_metrics/index.ts @@ -36,6 +36,7 @@ export function kafkaMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.kafkaMetrics.nameTitle', { defaultMessage: 'Kafka metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.kafkaMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/kibana_metrics/index.ts b/src/plugins/home/server/tutorials/kibana_metrics/index.ts index fa966ac724a734..d595859959aca3 100644 --- a/src/plugins/home/server/tutorials/kibana_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kibana_metrics/index.ts @@ -36,6 +36,7 @@ export function kibanaMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.kibanaMetrics.nameTitle', { defaultMessage: 'Kibana metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.kibanaMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts b/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts index bcea7f1221e1f1..a4ce9cfab5f627 100644 --- a/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts @@ -36,6 +36,7 @@ export function kubernetesMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.kubernetesMetrics.nameTitle', { defaultMessage: 'Kubernetes metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.kubernetesMetrics.shortDescription', { defaultMessage: 'Fetch metrics from your Kubernetes installation.', diff --git a/src/plugins/home/server/tutorials/logstash_logs/index.ts b/src/plugins/home/server/tutorials/logstash_logs/index.ts index 69e498ac59459d..32982cd1055a4c 100644 --- a/src/plugins/home/server/tutorials/logstash_logs/index.ts +++ b/src/plugins/home/server/tutorials/logstash_logs/index.ts @@ -37,6 +37,7 @@ export function logstashLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.logstashLogs.nameTitle', { defaultMessage: 'Logstash logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.logstashLogs.shortDescription', { defaultMessage: 'Collect and parse debug and slow logs created by Logstash itself.', diff --git a/src/plugins/home/server/tutorials/logstash_metrics/index.ts b/src/plugins/home/server/tutorials/logstash_metrics/index.ts index 383273a8c365cc..11272b7ceef6bd 100644 --- a/src/plugins/home/server/tutorials/logstash_metrics/index.ts +++ b/src/plugins/home/server/tutorials/logstash_metrics/index.ts @@ -36,6 +36,7 @@ export function logstashMetricsSpecProvider(context: TutorialContext): TutorialS name: i18n.translate('home.tutorials.logstashMetrics.nameTitle', { defaultMessage: 'Logstash metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.logstashMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/memcached_metrics/index.ts b/src/plugins/home/server/tutorials/memcached_metrics/index.ts index 94451556ad34c6..c724b790f84a6c 100644 --- a/src/plugins/home/server/tutorials/memcached_metrics/index.ts +++ b/src/plugins/home/server/tutorials/memcached_metrics/index.ts @@ -36,6 +36,7 @@ export function memcachedMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.memcachedMetrics.nameTitle', { defaultMessage: 'Memcached metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.memcachedMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/mongodb_metrics/index.ts b/src/plugins/home/server/tutorials/mongodb_metrics/index.ts index f02695e207dd32..2f39a048f2f154 100644 --- a/src/plugins/home/server/tutorials/mongodb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mongodb_metrics/index.ts @@ -36,6 +36,7 @@ export function mongodbMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.mongodbMetrics.nameTitle', { defaultMessage: 'MongoDB metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.mongodbMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from MongoDB.', diff --git a/src/plugins/home/server/tutorials/mssql_metrics/index.ts b/src/plugins/home/server/tutorials/mssql_metrics/index.ts index 4b418587f78b2c..1a1f047a128481 100644 --- a/src/plugins/home/server/tutorials/mssql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mssql_metrics/index.ts @@ -36,6 +36,7 @@ export function mssqlMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.mssqlMetrics.nameTitle', { defaultMessage: 'Microsoft SQL Server Metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.mssqlMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from a Microsoft SQL Server instance', diff --git a/src/plugins/home/server/tutorials/munin_metrics/index.ts b/src/plugins/home/server/tutorials/munin_metrics/index.ts index 3dbb34cb22031e..8434d916daa1f6 100644 --- a/src/plugins/home/server/tutorials/munin_metrics/index.ts +++ b/src/plugins/home/server/tutorials/munin_metrics/index.ts @@ -36,6 +36,7 @@ export function muninMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.muninMetrics.nameTitle', { defaultMessage: 'Munin metrics', }), + moduleName, euiIconType: '/plugins/home/assets/logos/munin.svg', isBeta: true, category: TutorialsCategory.METRICS, diff --git a/src/plugins/home/server/tutorials/mysql_logs/index.ts b/src/plugins/home/server/tutorials/mysql_logs/index.ts index 178a371f9212ed..37bbf409b91c5b 100644 --- a/src/plugins/home/server/tutorials/mysql_logs/index.ts +++ b/src/plugins/home/server/tutorials/mysql_logs/index.ts @@ -37,6 +37,7 @@ export function mysqlLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.mysqlLogs.nameTitle', { defaultMessage: 'MySQL logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.mysqlLogs.shortDescription', { defaultMessage: 'Collect and parse error and slow logs created by MySQL.', diff --git a/src/plugins/home/server/tutorials/mysql_metrics/index.ts b/src/plugins/home/server/tutorials/mysql_metrics/index.ts index 1148caeb441f88..89f5edf22a7b65 100644 --- a/src/plugins/home/server/tutorials/mysql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mysql_metrics/index.ts @@ -36,6 +36,7 @@ export function mysqlMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.mysqlMetrics.nameTitle', { defaultMessage: 'MySQL metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.mysqlMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from MySQL.', diff --git a/src/plugins/home/server/tutorials/nats_logs/index.ts b/src/plugins/home/server/tutorials/nats_logs/index.ts index 17c37755b6bc37..f00ddd6ca88793 100644 --- a/src/plugins/home/server/tutorials/nats_logs/index.ts +++ b/src/plugins/home/server/tutorials/nats_logs/index.ts @@ -37,6 +37,7 @@ export function natsLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.natsLogs.nameTitle', { defaultMessage: 'NATS logs', }), + moduleName, category: TutorialsCategory.LOGGING, isBeta: true, shortDescription: i18n.translate('home.tutorials.natsLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/nats_metrics/index.ts b/src/plugins/home/server/tutorials/nats_metrics/index.ts index bce08e85c6977e..cda011297d2c67 100644 --- a/src/plugins/home/server/tutorials/nats_metrics/index.ts +++ b/src/plugins/home/server/tutorials/nats_metrics/index.ts @@ -36,6 +36,7 @@ export function natsMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.natsMetrics.nameTitle', { defaultMessage: 'NATS metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.natsMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the Nats server.', diff --git a/src/plugins/home/server/tutorials/netflow/index.ts b/src/plugins/home/server/tutorials/netflow/index.ts index ec0aa8953b146f..5be30bbb152b73 100644 --- a/src/plugins/home/server/tutorials/netflow/index.ts +++ b/src/plugins/home/server/tutorials/netflow/index.ts @@ -25,9 +25,11 @@ import { createElasticCloudInstructions } from './elastic_cloud'; import { createOnPremElasticCloudInstructions } from './on_prem_elastic_cloud'; export function netflowSpecProvider() { + const moduleName = 'netflow'; return { id: 'netflow', name: 'Netflow', + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.netflow.tutorialShortDescription', { defaultMessage: 'Collect Netflow records sent by a Netflow exporter.', diff --git a/src/plugins/home/server/tutorials/nginx_logs/index.ts b/src/plugins/home/server/tutorials/nginx_logs/index.ts index 37d0cc106bfe58..f357e77fc25ca3 100644 --- a/src/plugins/home/server/tutorials/nginx_logs/index.ts +++ b/src/plugins/home/server/tutorials/nginx_logs/index.ts @@ -37,6 +37,7 @@ export function nginxLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.nginxLogs.nameTitle', { defaultMessage: 'Nginx logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.nginxLogs.shortDescription', { defaultMessage: 'Collect and parse access and error logs created by the Nginx HTTP server.', diff --git a/src/plugins/home/server/tutorials/nginx_metrics/index.ts b/src/plugins/home/server/tutorials/nginx_metrics/index.ts index 8671f7218ffc8d..09031883cef1c4 100644 --- a/src/plugins/home/server/tutorials/nginx_metrics/index.ts +++ b/src/plugins/home/server/tutorials/nginx_metrics/index.ts @@ -36,6 +36,7 @@ export function nginxMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.nginxMetrics.nameTitle', { defaultMessage: 'Nginx metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.nginxMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the Nginx HTTP server.', diff --git a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts index eb539e15c1bcd9..197821f24dddb2 100644 --- a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts +++ b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts @@ -36,6 +36,7 @@ export function openmetricsMetricsSpecProvider(context: TutorialContext): Tutori name: i18n.translate('home.tutorials.openmetricsMetrics.nameTitle', { defaultMessage: 'OpenMetrics metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.openmetricsMetrics.shortDescription', { defaultMessage: 'Fetch metrics from an endpoint that serves metrics in OpenMetrics format.', diff --git a/src/plugins/home/server/tutorials/oracle_metrics/index.ts b/src/plugins/home/server/tutorials/oracle_metrics/index.ts index 3144b0a21aab5f..d2ddd19b930a22 100644 --- a/src/plugins/home/server/tutorials/oracle_metrics/index.ts +++ b/src/plugins/home/server/tutorials/oracle_metrics/index.ts @@ -36,6 +36,7 @@ export function oracleMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.oracleMetrics.nameTitle', { defaultMessage: 'oracle metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.oracleMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/osquery_logs/index.ts b/src/plugins/home/server/tutorials/osquery_logs/index.ts index 8781d6201a7710..c4869a889a085a 100644 --- a/src/plugins/home/server/tutorials/osquery_logs/index.ts +++ b/src/plugins/home/server/tutorials/osquery_logs/index.ts @@ -37,6 +37,7 @@ export function osqueryLogsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.osqueryLogs.nameTitle', { defaultMessage: 'Osquery logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.osqueryLogs.shortDescription', { defaultMessage: 'Collect the result logs created by osqueryd.', diff --git a/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts b/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts index 975b549c9520b3..470cfed2176fd0 100644 --- a/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts +++ b/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts @@ -36,6 +36,7 @@ export function phpfpmMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.phpFpmMetrics.nameTitle', { defaultMessage: 'PHP-FPM metrics', }), + moduleName, category: TutorialsCategory.METRICS, isBeta: false, shortDescription: i18n.translate('home.tutorials.phpFpmMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/postgresql_logs/index.ts b/src/plugins/home/server/tutorials/postgresql_logs/index.ts index 0c280619858196..e158dedcb03e04 100644 --- a/src/plugins/home/server/tutorials/postgresql_logs/index.ts +++ b/src/plugins/home/server/tutorials/postgresql_logs/index.ts @@ -37,6 +37,7 @@ export function postgresqlLogsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.postgresqlLogs.nameTitle', { defaultMessage: 'PostgreSQL logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.postgresqlLogs.shortDescription', { defaultMessage: 'Collect and parse error and slow logs created by PostgreSQL.', diff --git a/src/plugins/home/server/tutorials/postgresql_metrics/index.ts b/src/plugins/home/server/tutorials/postgresql_metrics/index.ts index f9bb9d249e755d..1add49c10c2a75 100644 --- a/src/plugins/home/server/tutorials/postgresql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/postgresql_metrics/index.ts @@ -36,6 +36,7 @@ export function postgresqlMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.postgresqlMetrics.nameTitle', { defaultMessage: 'PostgreSQL metrics', }), + moduleName, category: TutorialsCategory.METRICS, isBeta: false, shortDescription: i18n.translate('home.tutorials.postgresqlMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/prometheus_metrics/index.ts b/src/plugins/home/server/tutorials/prometheus_metrics/index.ts index 06e8a138049d53..900c5da7cdbe34 100644 --- a/src/plugins/home/server/tutorials/prometheus_metrics/index.ts +++ b/src/plugins/home/server/tutorials/prometheus_metrics/index.ts @@ -36,6 +36,7 @@ export function prometheusMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.prometheusMetrics.nameTitle', { defaultMessage: 'Prometheus metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.prometheusMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts b/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts index a646068e4ff341..df0aa57d9feac9 100644 --- a/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts @@ -36,6 +36,7 @@ export function rabbitmqMetricsSpecProvider(context: TutorialContext): TutorialS name: i18n.translate('home.tutorials.rabbitmqMetrics.nameTitle', { defaultMessage: 'RabbitMQ metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.rabbitmqMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the RabbitMQ server.', diff --git a/src/plugins/home/server/tutorials/redis_logs/index.ts b/src/plugins/home/server/tutorials/redis_logs/index.ts index e017fae0499a3a..785118b9e5d098 100644 --- a/src/plugins/home/server/tutorials/redis_logs/index.ts +++ b/src/plugins/home/server/tutorials/redis_logs/index.ts @@ -37,6 +37,7 @@ export function redisLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.redisLogs.nameTitle', { defaultMessage: 'Redis logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.redisLogs.shortDescription', { defaultMessage: 'Collect and parse error and slow logs created by Redis.', diff --git a/src/plugins/home/server/tutorials/redis_metrics/index.ts b/src/plugins/home/server/tutorials/redis_metrics/index.ts index bcc4d9bb0b67b9..11d05029844b20 100644 --- a/src/plugins/home/server/tutorials/redis_metrics/index.ts +++ b/src/plugins/home/server/tutorials/redis_metrics/index.ts @@ -36,6 +36,7 @@ export function redisMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.redisMetrics.nameTitle', { defaultMessage: 'Redis metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.redisMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from Redis.', diff --git a/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts b/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts index ffbb5ab75da879..0bc7769f950ede 100644 --- a/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts +++ b/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts @@ -36,6 +36,7 @@ export function redisenterpriseMetricsSpecProvider(context: TutorialContext): Tu name: i18n.translate('home.tutorials.redisenterpriseMetrics.nameTitle', { defaultMessage: 'Redis Enterprise metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.redisenterpriseMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from Redis Enterprise Server.', diff --git a/src/plugins/home/server/tutorials/stan_metrics/index.ts b/src/plugins/home/server/tutorials/stan_metrics/index.ts index 616bc7450249e4..b1ad3e9c1404ac 100644 --- a/src/plugins/home/server/tutorials/stan_metrics/index.ts +++ b/src/plugins/home/server/tutorials/stan_metrics/index.ts @@ -36,6 +36,7 @@ export function stanMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.stanMetrics.nameTitle', { defaultMessage: 'STAN metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.stanMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the STAN server.', diff --git a/src/plugins/home/server/tutorials/statsd_metrics/index.ts b/src/plugins/home/server/tutorials/statsd_metrics/index.ts index 1dc297e78c791f..9e9d7d6fd3e236 100644 --- a/src/plugins/home/server/tutorials/statsd_metrics/index.ts +++ b/src/plugins/home/server/tutorials/statsd_metrics/index.ts @@ -33,6 +33,7 @@ export function statsdMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.statsdMetrics.nameTitle', { defaultMessage: 'Statsd metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.statsdMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from statsd.', diff --git a/src/plugins/home/server/tutorials/suricata_logs/index.ts b/src/plugins/home/server/tutorials/suricata_logs/index.ts index 6bcfc1d43a2502..eec81b94966479 100644 --- a/src/plugins/home/server/tutorials/suricata_logs/index.ts +++ b/src/plugins/home/server/tutorials/suricata_logs/index.ts @@ -37,6 +37,7 @@ export function suricataLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.suricataLogs.nameTitle', { defaultMessage: 'Suricata logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.suricataLogs.shortDescription', { defaultMessage: 'Collect the result logs created by Suricata IDS/IPS/NSM.', diff --git a/src/plugins/home/server/tutorials/system_logs/index.ts b/src/plugins/home/server/tutorials/system_logs/index.ts index 9bad70699a6ed8..f39df25461a5fe 100644 --- a/src/plugins/home/server/tutorials/system_logs/index.ts +++ b/src/plugins/home/server/tutorials/system_logs/index.ts @@ -37,6 +37,7 @@ export function systemLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.systemLogs.nameTitle', { defaultMessage: 'System logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.systemLogs.shortDescription', { defaultMessage: 'Collect and parse logs written by the local Syslog server.', diff --git a/src/plugins/home/server/tutorials/system_metrics/index.ts b/src/plugins/home/server/tutorials/system_metrics/index.ts index ef1a84ecdbf10b..6bdaaa34a9b2cc 100644 --- a/src/plugins/home/server/tutorials/system_metrics/index.ts +++ b/src/plugins/home/server/tutorials/system_metrics/index.ts @@ -36,6 +36,7 @@ export function systemMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.systemMetrics.nameTitle', { defaultMessage: 'System metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.systemMetrics.shortDescription', { defaultMessage: 'Collect CPU, memory, network, and disk statistics from the host.', diff --git a/src/plugins/home/server/tutorials/traefik_logs/index.ts b/src/plugins/home/server/tutorials/traefik_logs/index.ts index 1876edd6c0bf74..0a84dcb0818835 100644 --- a/src/plugins/home/server/tutorials/traefik_logs/index.ts +++ b/src/plugins/home/server/tutorials/traefik_logs/index.ts @@ -37,6 +37,7 @@ export function traefikLogsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.traefikLogs.nameTitle', { defaultMessage: 'Traefik logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.traefikLogs.shortDescription', { defaultMessage: 'Collect and parse access logs created by the Traefik Proxy.', diff --git a/src/plugins/home/server/tutorials/traefik_metrics/index.ts b/src/plugins/home/server/tutorials/traefik_metrics/index.ts index a97ee3ab9758a5..4048719239a10c 100644 --- a/src/plugins/home/server/tutorials/traefik_metrics/index.ts +++ b/src/plugins/home/server/tutorials/traefik_metrics/index.ts @@ -33,6 +33,7 @@ export function traefikMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.traefikMetrics.nameTitle', { defaultMessage: 'Traefik metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.traefikMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from Traefik.', diff --git a/src/plugins/home/server/tutorials/uptime_monitors/index.ts b/src/plugins/home/server/tutorials/uptime_monitors/index.ts index fa854a1c235053..7366583e597781 100644 --- a/src/plugins/home/server/tutorials/uptime_monitors/index.ts +++ b/src/plugins/home/server/tutorials/uptime_monitors/index.ts @@ -30,11 +30,13 @@ import { } from '../../services/tutorials/lib/tutorials_registry_types'; export function uptimeMonitorsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'uptime'; return { id: 'uptimeMonitors', name: i18n.translate('home.tutorials.uptimeMonitors.nameTitle', { defaultMessage: 'Uptime Monitors', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.uptimeMonitors.shortDescription', { defaultMessage: 'Monitor services for their availability', diff --git a/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts b/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts index bbe4ea78ee87c1..f6398be3550fd3 100644 --- a/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts +++ b/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts @@ -36,6 +36,7 @@ export function uwsgiMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.uwsgiMetrics.nameTitle', { defaultMessage: 'uWSGI metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.uwsgiMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the uWSGI server.', diff --git a/src/plugins/home/server/tutorials/vsphere_metrics/index.ts b/src/plugins/home/server/tutorials/vsphere_metrics/index.ts index 4450ab30407505..5e1191ffdf8ce5 100644 --- a/src/plugins/home/server/tutorials/vsphere_metrics/index.ts +++ b/src/plugins/home/server/tutorials/vsphere_metrics/index.ts @@ -36,6 +36,7 @@ export function vSphereMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.vsphereMetrics.nameTitle', { defaultMessage: 'vSphere metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.vsphereMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from vSphere.', diff --git a/src/plugins/home/server/tutorials/windows_event_logs/index.ts b/src/plugins/home/server/tutorials/windows_event_logs/index.ts index c2ea9ff3015e43..80f7a58ae14be4 100644 --- a/src/plugins/home/server/tutorials/windows_event_logs/index.ts +++ b/src/plugins/home/server/tutorials/windows_event_logs/index.ts @@ -30,11 +30,13 @@ import { } from '../../services/tutorials/lib/tutorials_registry_types'; export function windowsEventLogsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'windows'; return { id: 'windowsEventLogs', name: i18n.translate('home.tutorials.windowsEventLogs.nameTitle', { defaultMessage: 'Windows Event Log', }), + moduleName, isBeta: false, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.windowsEventLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/windows_metrics/index.ts b/src/plugins/home/server/tutorials/windows_metrics/index.ts index 5333a7b1badf6b..18cdcdc985e544 100644 --- a/src/plugins/home/server/tutorials/windows_metrics/index.ts +++ b/src/plugins/home/server/tutorials/windows_metrics/index.ts @@ -36,6 +36,7 @@ export function windowsMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.windowsMetrics.nameTitle', { defaultMessage: 'Windows metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.windowsMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/zeek_logs/index.ts b/src/plugins/home/server/tutorials/zeek_logs/index.ts index c273a93b1b0d50..e39dcd3409490b 100644 --- a/src/plugins/home/server/tutorials/zeek_logs/index.ts +++ b/src/plugins/home/server/tutorials/zeek_logs/index.ts @@ -37,6 +37,7 @@ export function zeekLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.zeekLogs.nameTitle', { defaultMessage: 'Zeek logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.zeekLogs.shortDescription', { defaultMessage: 'Collect the logs created by Zeek/Bro.', diff --git a/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts b/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts index ae146d192432bc..a39540b7399e58 100644 --- a/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts +++ b/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts @@ -36,6 +36,7 @@ export function zookeeperMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.zookeeperMetrics.nameTitle', { defaultMessage: 'Zookeeper metrics', }), + moduleName, euiIconType: '/plugins/home/assets/logos/zookeeper.svg', isBeta: false, category: TutorialsCategory.METRICS, diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index cfe4f9cc3e014b..b8fa5b184cd1f4 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -26,21 +26,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); const appsMenu = getService('appsMenu'); const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); describe('Kibana browser back navigation should work', function describeIndexTests() { before(async () => { await esArchiver.loadIfNeeded('discover'); await esArchiver.loadIfNeeded('logstash_functional'); - if (browser.isInternetExplorer) { - await kibanaServer.uiSettings.replace({ 'state:storeInSessionStorage': false }); - } - }); - - after(async () => { - if (browser.isInternetExplorer) { - await kibanaServer.uiSettings.replace({ 'state:storeInSessionStorage': true }); - } }); it('detect navigate back issues', async () => { diff --git a/test/functional/config.ie.js b/test/functional/config.ie.js deleted file mode 100644 index bc47ce707003eb..00000000000000 --- a/test/functional/config.ie.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export default async function ({ readConfigFile }) { - const defaultConfig = await readConfigFile(require.resolve('./config')); - - return { - ...defaultConfig.getAll(), - - browser: { - type: 'ie', - }, - - junit: { - reportName: 'Internet Explorer UI Functional Tests', - }, - - uiSettings: { - defaults: { - 'accessibility:disableAnimations': true, - 'dateFormat:tz': 'UTC', - 'state:storeInSessionStorage': true, - 'notifications:lifetime:info': 10000, - }, - }, - - kbnTestServer: { - ...defaultConfig.get('kbnTestServer'), - serverArgs: [ - ...defaultConfig.get('kbnTestServer.serverArgs'), - '--csp.strict=false', - '--telemetry.optIn=false', - ], - }, - }; -} diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 7ef291c8c7005f..8a726cee444c16 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -98,13 +98,6 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo const input = await testSubjects.find(dataTestSubj); await input.clearValue(); await input.type(value); - } else if (browser.isInternetExplorer) { - const input = await testSubjects.find(dataTestSubj); - const currentValue = await input.getAttribute('value'); - await input.type(browser.keys.ARROW_RIGHT.repeat(currentValue.length)); - await input.type(browser.keys.BACK_SPACE.repeat(currentValue.length)); - await input.type(value); - await input.click(); } else { await testSubjects.setValue(dataTestSubj, value); } diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index 2d35551b04808f..c38ac771e41625 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -34,8 +34,6 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { const log = getService('log'); const { driver, browserType } = await getService('__webdriver__').init(); - const isW3CEnabled = (driver as any).executor_.w3c === true; - return new (class BrowserService { /** * Keyboard events @@ -53,19 +51,12 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { public readonly isFirefox: boolean = browserType === Browsers.Firefox; - public readonly isInternetExplorer: boolean = browserType === Browsers.InternetExplorer; - - /** - * Is WebDriver instance W3C compatible - */ - isW3CEnabled = isW3CEnabled; - /** * Returns instance of Actions API based on driver w3c flag * https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_WebDriver.html#actions */ public getActions() { - return this.isW3CEnabled ? driver.actions() : driver.actions({ bridge: true }); + return driver.actions(); } /** @@ -164,12 +155,7 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { */ public async getCurrentUrl() { // strip _t=Date query param when url is read - let current: string; - if (this.isInternetExplorer) { - current = await driver.executeScript('return window.document.location.href'); - } else { - current = await driver.getCurrentUrl(); - } + const current = await driver.getCurrentUrl(); const currentWithoutTime = modifyUrl(current, (parsed) => { delete (parsed.query as any)._t; return void 0; @@ -214,15 +200,8 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { * @return {Promise} */ public async moveMouseTo(point: { x: number; y: number }): Promise { - if (this.isW3CEnabled) { - await this.getActions().move({ x: 0, y: 0 }).perform(); - await this.getActions().move({ x: point.x, y: point.y, origin: Origin.POINTER }).perform(); - } else { - await this.getActions() - .pause(this.getActions().mouse) - .move({ x: point.x, y: point.y, origin: Origin.POINTER }) - .perform(); - } + await this.getActions().move({ x: 0, y: 0 }).perform(); + await this.getActions().move({ x: point.x, y: point.y, origin: Origin.POINTER }).perform(); } /** @@ -237,44 +216,20 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { from: { offset?: { x: any; y: any }; location: any }, to: { offset?: { x: any; y: any }; location: any } ) { - if (this.isW3CEnabled) { - // The offset should be specified in pixels relative to the center of the element's bounding box - const getW3CPoint = (data: any) => { - if (!data.offset) { - data.offset = {}; - } - return data.location instanceof WebElementWrapper - ? { x: data.offset.x || 0, y: data.offset.y || 0, origin: data.location._webElement } - : { x: data.location.x, y: data.location.y, origin: Origin.POINTER }; - }; - - const startPoint = getW3CPoint(from); - const endPoint = getW3CPoint(to); - await this.getActions().move({ x: 0, y: 0 }).perform(); - return await this.getActions().move(startPoint).press().move(endPoint).release().perform(); - } else { - // The offset should be specified in pixels relative to the top-left corner of the element's bounding box - const getOffset: any = (offset: { x: number; y: number }) => - offset ? { x: offset.x || 0, y: offset.y || 0 } : { x: 0, y: 0 }; - - if (from.location instanceof WebElementWrapper === false) { - throw new Error('Dragging point should be WebElementWrapper instance'); - } else if (typeof to.location.x === 'number') { - return await this.getActions() - .move({ origin: from.location._webElement }) - .press() - .move({ x: to.location.x, y: to.location.y, origin: Origin.POINTER }) - .release() - .perform(); - } else { - return await new LegacyActionSequence(driver) - .mouseMove(from.location._webElement, getOffset(from.offset)) - .mouseDown() - .mouseMove(to.location._webElement, getOffset(to.offset)) - .mouseUp() - .perform(); + // The offset should be specified in pixels relative to the center of the element's bounding box + const getW3CPoint = (data: any) => { + if (!data.offset) { + data.offset = {}; } - } + return data.location instanceof WebElementWrapper + ? { x: data.offset.x || 0, y: data.offset.y || 0, origin: data.location._webElement } + : { x: data.location.x, y: data.location.y, origin: Origin.POINTER }; + }; + + const startPoint = getW3CPoint(from); + const endPoint = getW3CPoint(to); + await this.getActions().move({ x: 0, y: 0 }).perform(); + return await this.getActions().move(startPoint).press().move(endPoint).release().perform(); } /** @@ -341,19 +296,11 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { * @return {Promise} */ public async clickMouseButton(point: { x: number; y: number }) { - if (this.isW3CEnabled) { - await this.getActions().move({ x: 0, y: 0 }).perform(); - await this.getActions() - .move({ x: point.x, y: point.y, origin: Origin.POINTER }) - .click() - .perform(); - } else { - await this.getActions() - .pause(this.getActions().mouse) - .move({ x: point.x, y: point.y, origin: Origin.POINTER }) - .click() - .perform(); - } + await this.getActions().move({ x: 0, y: 0 }).perform(); + await this.getActions() + .move({ x: point.x, y: point.y, origin: Origin.POINTER }) + .click() + .perform(); } /** diff --git a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts index 281a412653bd0a..5011235551bd82 100644 --- a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts +++ b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts @@ -47,7 +47,6 @@ const RETRY_CLICK_RETRY_ON_ERRORS = [ export class WebElementWrapper { private By = By; private Keys = Key; - public isW3CEnabled: boolean = (this.driver as any).executor_.w3c === true; public isChromium: boolean = [Browsers.Chrome, Browsers.ChromiumEdge].includes(this.browserType); public static create( @@ -141,7 +140,7 @@ export class WebElementWrapper { } private getActions() { - return this.isW3CEnabled ? this.driver.actions() : this.driver.actions({ bridge: true }); + return this.driver.actions(); } /** @@ -233,9 +232,6 @@ export class WebElementWrapper { * @default { withJS: false } */ async clearValue(options: ClearOptions = { withJS: false }) { - if (this.browserType === Browsers.InternetExplorer) { - return this.clearValueWithKeyboard(); - } await this.retryCall(async function clearValue(wrapper) { if (wrapper.isChromium || options.withJS) { // https://bugs.chromium.org/p/chromedriver/issues/detail?id=2702 @@ -252,16 +248,6 @@ export class WebElementWrapper { * @default { charByChar: false } */ async clearValueWithKeyboard(options: TypeOptions = { charByChar: false }) { - if (this.browserType === Browsers.InternetExplorer) { - const value = await this.getAttribute('value'); - // For IE testing, the text field gets clicked in the middle so - // first go HOME and then DELETE all chars - await this.pressKeys(this.Keys.HOME); - for (let i = 0; i <= value.length; i++) { - await this.pressKeys(this.Keys.DELETE); - } - return; - } if (options.charByChar === true) { const value = await this.getAttribute('value'); for (let i = 0; i <= value.length; i++) { @@ -429,19 +415,11 @@ export class WebElementWrapper { public async moveMouseTo(options = { xOffset: 0, yOffset: 0 }) { await this.retryCall(async function moveMouseTo(wrapper) { await wrapper.scrollIntoViewIfNecessary(); - if (wrapper.isW3CEnabled) { - await wrapper.getActions().move({ x: 0, y: 0 }).perform(); - await wrapper - .getActions() - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .perform(); - } else { - await wrapper - .getActions() - .pause(wrapper.getActions().mouse) - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .perform(); - } + await wrapper.getActions().move({ x: 0, y: 0 }).perform(); + await wrapper + .getActions() + .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) + .perform(); }); } @@ -456,21 +434,12 @@ export class WebElementWrapper { public async clickMouseButton(options = { xOffset: 0, yOffset: 0 }) { await this.retryCall(async function clickMouseButton(wrapper) { await wrapper.scrollIntoViewIfNecessary(); - if (wrapper.isW3CEnabled) { - await wrapper.getActions().move({ x: 0, y: 0 }).perform(); - await wrapper - .getActions() - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .click() - .perform(); - } else { - await wrapper - .getActions() - .pause(wrapper.getActions().mouse) - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .click() - .perform(); - } + await wrapper.getActions().move({ x: 0, y: 0 }).perform(); + await wrapper + .getActions() + .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) + .click() + .perform(); }); } diff --git a/test/functional/services/remote/browsers.ts b/test/functional/services/remote/browsers.ts index aa6e364d0a09d0..f7942e708a3bb1 100644 --- a/test/functional/services/remote/browsers.ts +++ b/test/functional/services/remote/browsers.ts @@ -20,6 +20,5 @@ export enum Browsers { Chrome = 'chrome', Firefox = 'firefox', - InternetExplorer = 'ie', ChromiumEdge = 'msedge', } diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index 99643929c4682c..a45403e31095c7 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -64,15 +64,12 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { }; const { driver, consoleLog$ } = await initWebDriver(log, browserType, lifecycle, browserConfig); - const isW3CEnabled = (driver as any).executor_.w3c; - const caps = await driver.getCapabilities(); - const browserVersion = caps.get(isW3CEnabled ? 'browserVersion' : 'version'); log.info( - `Remote initialized: ${caps.get( - 'browserName' - )} ${browserVersion}, w3c compliance=${isW3CEnabled}, collectingCoverage=${collectCoverage}` + `Remote initialized: ${caps.get('browserName')} ${caps.get( + 'browserVersion' + )}, collectingCoverage=${collectCoverage}` ); if ([Browsers.Chrome, Browsers.ChromiumEdge].includes(browserType)) { diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 78f659a064a0c0..c5613b9e27094a 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -17,7 +17,7 @@ * under the License. */ -import { delimiter, resolve } from 'path'; +import { resolve } from 'path'; import Fs from 'fs'; import * as Rx from 'rxjs'; @@ -279,40 +279,6 @@ async function attemptToCreateCommand( }; } - case 'ie': { - // https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/ie_exports_Options.html - const driverPath = require.resolve('iedriver/lib/iedriver'); - process.env.PATH = driverPath + delimiter + process.env.PATH; - - const ieCapabilities = Capabilities.ie(); - ieCapabilities.set('se:ieOptions', { - 'ie.ensureCleanSession': true, - ignoreProtectedModeSettings: true, - ignoreZoomSetting: false, // requires us to have 100% zoom level - nativeEvents: true, // need this for values to stick but it requires 100% scaling and window focus - requireWindowFocus: true, - logLevel: 'TRACE', - }); - - let session; - if (remoteSessionUrl) { - session = await new Builder() - .forBrowser(browserType) - .withCapabilities(ieCapabilities) - .usingServer(remoteSessionUrl) - .build(); - } else { - session = await new Builder() - .forBrowser(browserType) - .withCapabilities(ieCapabilities) - .build(); - } - return { - session, - consoleLog$: Rx.EMPTY, - }; - } - default: throw new Error(`${browserType} is not supported yet`); } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 596ba17d343c0c..d0055008eb9bf7 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -16,6 +16,7 @@ "xpack.data": "plugins/data_enhanced", "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", + "xpack.enterpriseSearch": "plugins/enterprise_search", "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md new file mode 100644 index 00000000000000..8c316c848184b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/README.md @@ -0,0 +1,25 @@ +# Enterprise Search + +## Overview + +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness. + +## Development + +1. When developing locally, Enterprise Search should be running locally alongside Kibana on `localhost:3002`. +2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` +3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm) and log in as the `elastic` superuser on Kibana. + +## Testing + +### Unit tests + +From `kibana-root-folder/x-pack`, run: + +```bash +yarn test:jest plugins/enterprise_search +``` + +### E2E tests + +See [our functional test runner README](../../test/functional_enterprise_search). diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts new file mode 100644 index 00000000000000..c134131caba75c --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json new file mode 100644 index 00000000000000..9a2daefcd8c6e6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "enterpriseSearch", + "version": "kibana", + "kibanaVersion": "kibana", + "requiredPlugins": ["home", "features", "licensing"], + "configPath": ["enterpriseSearch"], + "optionalPlugins": ["usageCollection", "security"], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts new file mode 100644 index 00000000000000..14fde357a980a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mockHistory } from './react_router_history.mock'; +export { mockKibanaContext } from './kibana_context.mock'; +export { mockLicenseContext } from './license_context.mock'; +export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock'; +export { shallowWithIntl } from './shallow_with_i18n.mock'; + +// Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts new file mode 100644 index 00000000000000..fcfa1b0a21f130 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from 'src/core/public/mocks'; + +/** + * A set of default Kibana context values to use across component tests. + * @see enterprise_search/public/index.tsx for the KibanaContext definition/import + */ +export const mockKibanaContext = { + http: httpServiceMock.createSetupContract(), + setBreadcrumbs: jest.fn(), + enterpriseSearchUrl: 'http://localhost:3002', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts new file mode 100644 index 00000000000000..7c37ecc7cde1b8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { licensingMock } from '../../../../licensing/public/mocks'; + +export const mockLicenseContext = { + license: licensingMock.createLicense(), +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx new file mode 100644 index 00000000000000..dfcda544459d44 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContext } from '../'; +import { mockKibanaContext } from './kibana_context.mock'; +import { LicenseContext } from '../shared/licensing'; +import { mockLicenseContext } from './license_context.mock'; + +/** + * This helper mounts a component with all the contexts/providers used + * by the production app, while allowing custom context to be + * passed in via a second arg + * + * Example usage: + * + * const wrapper = mountWithContext(, { enterpriseSearchUrl: 'someOverride', license: {} }); + */ +export const mountWithContext = (children: React.ReactNode, context?: object) => { + return mount( + + + + {children} + + + + ); +}; + +/** + * This helper mounts a component with just the default KibanaContext - + * useful for isolated / helper components that only need this context + * + * Same usage/override functionality as mountWithContext + */ +export const mountWithKibanaContext = (children: React.ReactNode, context?: object) => { + return mount( + + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts new file mode 100644 index 00000000000000..fd422465d87f1b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +export const mockHistory = { + createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`), + push: jest.fn(), + location: { + pathname: '/current-path', + }, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(() => mockHistory), +})); + +/** + * For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts new file mode 100644 index 00000000000000..767a52a75d1fbb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.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; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: These variable names MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +import { mockKibanaContext } from './kibana_context.mock'; +import { mockLicenseContext } from './license_context.mock'; + +jest.mock('react', () => ({ + ...(jest.requireActual('react') as object), + useContext: jest.fn(() => ({ ...mockKibanaContext, ...mockLicenseContext })), +})); + +/** + * Example usage within a component test using shallow(): + * + * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed + * + * import React from 'react'; + * import { shallow } from 'enzyme'; + * + * // ... etc. + */ + +/** + * If you need to override the default mock context values, you can do so via jest.mockImplementation: + * + * import React, { useContext } from 'react'; + * + * // ... etc. + * + * it('some test', () => { + * useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'someOverride' })); + * }); + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx new file mode 100644 index 00000000000000..ae7d0b09f9872e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; +import { IntlProvider } from 'react-intl'; + +const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); +const { intl } = intlProvider.getChildContext(); + +/** + * This helper shallow wraps a component with react-intl's which + * fixes "Could not find required `intl` object" console errors when running tests + * + * Example usage (should be the same as shallow()): + * + * const wrapper = shallowWithIntl(); + */ +export const shallowWithIntl = (children: React.ReactNode) => { + const context = { context: { intl } }; + + return shallow({children}, context) + .childAt(0) + .dive(context) + .shallow(); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg new file mode 100644 index 00000000000000..ceab918e92e702 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png new file mode 100644 index 00000000000000..4d988d14f0483c Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png differ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg new file mode 100644 index 00000000000000..2284a425b5add2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg new file mode 100644 index 00000000000000..4e01e9a0b34fb9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx new file mode 100644 index 00000000000000..9bb5cd3bffdf52 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../shared/telemetry'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { EngineOverviewHeader } from '../engine_overview_header'; + +import './empty_states.scss'; + +export const EmptyState: React.FC = () => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + href: `${enterpriseSearchUrl}/as/engines/new`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'create_first_engine_button', + }), + }; + + return ( + + + + + + + + + + } + titleSize="l" + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss new file mode 100644 index 00000000000000..01b0903add5598 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Empty/Error UI states + */ +.emptyState { + min-height: $euiSizeXXL * 11.25; + display: flex; + flex-direction: column; + justify-content: center; + + &__prompt > .euiIcon { + margin-bottom: $euiSizeS; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx new file mode 100644 index 00000000000000..12bf0035641039 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; + +jest.mock('../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), + SendAppSearchTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { ErrorState, EmptyState, LoadingState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + const button = prompt.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); + +describe('LoadingState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx new file mode 100644 index 00000000000000..d8eeff2aba1c69 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButton } from '../../../shared/react_router_helpers'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { EngineOverviewHeader } from '../engine_overview_header'; + +import './empty_states.scss'; + +export const ErrorState: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + + + + + + + + + + + } + titleSize="l" + body={ + <> +

+ {enterpriseSearchUrl}, + }} + /> +

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts new file mode 100644 index 00000000000000..e92bf214c4cc75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LoadingState } from './loading_state'; +export { EmptyState } from './empty_state'; +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx new file mode 100644 index 00000000000000..2be917c8df0965 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { EngineOverviewHeader } from '../engine_overview_header'; + +import './empty_states.scss'; + +export const LoadingState: React.FC = () => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss new file mode 100644 index 00000000000000..2c7f7de6458e2a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Engine Overview + */ +.engineOverview { + width: 100%; + + &__body { + padding: $euiSize; + + @include euiBreakpoint('m', 'l', 'xl') { + padding: $euiSizeXL; + } + } +} + +.engineIcon { + display: inline-block; + width: $euiSize; + height: $euiSize; + margin-right: $euiSizeXS; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx new file mode 100644 index 00000000000000..4d2a2ea1df9aa9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/react_router_history.mock'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render, ReactWrapper } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContext } from '../../../'; +import { LicenseContext } from '../../../shared/licensing'; +import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; + +import { EmptyState, ErrorState } from '../empty_states'; +import { EngineTable, IEngineTablePagination } from './engine_table'; + +import { EngineOverview } from './'; + +describe('EngineOverview', () => { + describe('non-happy-path states', () => { + it('isLoading', () => { + // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) + // TODO: Consider pulling this out to a renderWithContext mock/helper + const wrapper: Cheerio = render( + + + + + + + + ); + + // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly + expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + }); + + it('isEmpty', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ invalidPayload: true }), + }); + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + const mockedApiResponse = { + results: [ + { + name: 'hello-world', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + document_count: 50, + field_count: 10, + }, + ], + meta: { + page: { + current: 1, + total_pages: 10, + total_results: 100, + size: 10, + }, + }, + }; + const mockApi = jest.fn(() => mockedApiResponse); + let wrapper: ReactWrapper; + + beforeAll(async () => { + wrapper = await mountWithApiMock({ get: mockApi }); + }); + + it('renders', () => { + expect(wrapper.find(EngineTable)).toHaveLength(1); + }); + + it('calls the engines API', () => { + expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 1, + }, + }); + }); + + describe('pagination', () => { + const getTablePagination: () => IEngineTablePagination = () => + wrapper.find(EngineTable).first().prop('pagination'); + + it('passes down page data from the API', () => { + const pagination = getTablePagination(); + + expect(pagination.totalEngines).toEqual(100); + expect(pagination.pageIndex).toEqual(0); + }); + + it('re-polls the API on page change', async () => { + await act(async () => getTablePagination().onPaginate(5)); + wrapper.update(); + + expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 5, + }, + }); + expect(getTablePagination().pageIndex).toEqual(4); + }); + }); + + describe('when on a platinum license', () => { + beforeAll(async () => { + mockApi.mockClear(); + wrapper = await mountWithApiMock({ + license: { type: 'platinum', isActive: true }, + get: mockApi, + }); + }); + + it('renders a 2nd meta engines table', () => { + expect(wrapper.find(EngineTable)).toHaveLength(2); + }); + + it('makes a 2nd call to the engines API with type meta', () => { + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + }); + }); + + /** + * Test helpers + */ + + const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => { + let wrapper: ReactWrapper | undefined; + const httpMock = { ...mockKibanaContext.http, get }; + + // We get a lot of act() warning/errors in the terminal without this. + // TBH, I don't fully understand why since Enzyme's mount is supposed to + // have act() baked in - could be because of the wrapping context provider? + await act(async () => { + wrapper = mountWithContext(, { http: httpMock, license }); + }); + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } + }; +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx new file mode 100644 index 00000000000000..13d092a657d11c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect, useState } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentBody, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import EnginesIcon from '../../assets/engine.svg'; +import MetaEnginesIcon from '../../assets/meta_engine.svg'; + +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; +import { EngineOverviewHeader } from '../engine_overview_header'; +import { EngineTable } from './engine_table'; + +import './engine_overview.scss'; + +interface IGetEnginesParams { + type: string; + pageIndex: number; +} +interface ISetEnginesCallbacks { + setResults: React.Dispatch>; + setResultsTotal: React.Dispatch>; +} + +export const EngineOverview: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { license } = useContext(LicenseContext) as ILicenseContext; + + const [isLoading, setIsLoading] = useState(true); + const [hasErrorConnecting, setHasErrorConnecting] = useState(false); + + const [engines, setEngines] = useState([]); + const [enginesPage, setEnginesPage] = useState(1); + const [enginesTotal, setEnginesTotal] = useState(0); + const [metaEngines, setMetaEngines] = useState([]); + const [metaEnginesPage, setMetaEnginesPage] = useState(1); + const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); + + const getEnginesData = async ({ type, pageIndex }: IGetEnginesParams) => { + return await http.get('/api/app_search/engines', { + query: { type, pageIndex }, + }); + }; + const setEnginesData = async (params: IGetEnginesParams, callbacks: ISetEnginesCallbacks) => { + try { + const response = await getEnginesData(params); + + callbacks.setResults(response.results); + callbacks.setResultsTotal(response.meta.page.total_results); + + setIsLoading(false); + } catch (error) { + setHasErrorConnecting(true); + } + }; + + useEffect(() => { + const params = { type: 'indexed', pageIndex: enginesPage }; + const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal }; + + setEnginesData(params, callbacks); + }, [enginesPage]); + + useEffect(() => { + if (hasPlatinumLicense(license)) { + const params = { type: 'meta', pageIndex: metaEnginesPage }; + const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal }; + + setEnginesData(params, callbacks); + } + }, [license, metaEnginesPage]); + + if (hasErrorConnecting) return ; + if (isLoading) return ; + if (!engines.length) return ; + + return ( + + + + + + + + + + +

+ + +

+
+
+ + + + + {metaEngines.length > 0 && ( + <> + + + +

+ + +

+
+
+ + + + + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx new file mode 100644 index 00000000000000..46b6e61e352de5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; + +import { mountWithContext } from '../../../__mocks__'; +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { EngineTable } from './engine_table'; + +describe('EngineTable', () => { + const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream + + const wrapper = mountWithContext( + + ); + const table = wrapper.find(EuiBasicTable); + + it('renders', () => { + expect(table).toHaveLength(1); + expect(table.prop('pagination').totalItemCount).toEqual(50); + + const tableContent = table.text(); + expect(tableContent).toContain('test-engine'); + expect(tableContent).toContain('January 1, 1970'); + expect(tableContent).toContain('99,999'); + expect(tableContent).toContain('10'); + + expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page + }); + + it('contains engine links which send telemetry', () => { + const engineLinks = wrapper.find(EuiLink); + + engineLinks.forEach((link) => { + expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine'); + link.simulate('click'); + + expect(sendTelemetry).toHaveBeenCalledWith({ + http: expect.any(Object), + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }); + }); + }); + + it('triggers onPaginate', () => { + table.prop('onChange')({ page: { index: 4 } }); + + expect(onPaginate).toHaveBeenCalledWith(5); + }); + + it('handles empty data', () => { + const emptyWrapper = mountWithContext( + {} }} /> + ); + const emptyTable = emptyWrapper.find(EuiBasicTable); + expect(emptyTable.prop('pagination').pageIndex).toEqual(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx new file mode 100644 index 00000000000000..1e58d820dc83b0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; +import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; + +export interface IEngineTableData { + name: string; + created_at: string; + document_count: number; + field_count: number; +} +export interface IEngineTablePagination { + totalEngines: number; + pageIndex: number; + onPaginate(pageIndex: number): void; +} +export interface IEngineTableProps { + data: IEngineTableData[]; + pagination: IEngineTablePagination; +} +export interface IOnChange { + page: { + index: number; + }; +} + +export const EngineTable: React.FC = ({ + data, + pagination: { totalEngines, pageIndex, onPaginate }, +}) => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const engineLinkProps = (name: string) => ({ + href: `${enterpriseSearchUrl}/as/engines/${name}`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }), + }); + + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), + render: (name: string) => ( + + {name} + + ), + width: '30%', + truncateText: true, + mobileOptions: { + header: true, + // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error + // @ts-ignore + enlarge: true, + fullWidth: true, + truncateText: false, + }, + }, + { + field: 'created_at', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', + { + defaultMessage: 'Created At', + } + ), + dataType: 'string', + render: (dateString: string) => ( + // e.g., January 1, 1970 + + ), + }, + { + field: 'document_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document Count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, + }, + { + field: 'field_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', + { + defaultMessage: 'Field Count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, + }, + { + field: 'name', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', + { + defaultMessage: 'Actions', + } + ), + dataType: 'string', + render: (name: string) => ( + + + + ), + align: 'right', + width: '100px', + }, + ]; + + return ( + { + const { index } = page; + onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 + }} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts new file mode 100644 index 00000000000000..48b7645dc39e8b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EngineOverview } from './engine_overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx new file mode 100644 index 00000000000000..2e49540270ef07 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { EngineOverviewHeader } from '../engine_overview_header'; + +describe('EngineOverviewHeader', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('h1')).toHaveLength(1); + }); + + it('renders a launch app search button that sends telemetry on click', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="launchButton"]'); + + expect(button.prop('href')).toBe('http://localhost:3002/as'); + expect(button.prop('isDisabled')).toBeFalsy(); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders a disabled button when isButtonDisabled is true', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="launchButton"]'); + + expect(button.prop('isDisabled')).toBe(true); + expect(button.prop('href')).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx new file mode 100644 index 00000000000000..9aafa8ec0380c7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiButton, + EuiButtonProps, + EuiLinkProps, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +interface IEngineOverviewHeaderProps { + isButtonDisabled?: boolean; +} + +export const EngineOverviewHeader: React.FC = ({ + isButtonDisabled, +}) => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + fill: true, + iconType: 'popout', + 'data-test-subj': 'launchButton', + } as EuiButtonProps & EuiLinkProps; + + if (isButtonDisabled) { + buttonProps.isDisabled = true; + } else { + buttonProps.href = `${enterpriseSearchUrl}/as`; + buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'header_launch_button', + }); + } + + return ( + + + +

+ +

+
+
+ + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts new file mode 100644 index 00000000000000..2d37f037e21e5c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EngineOverviewHeader } from './engine_overview_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts new file mode 100644 index 00000000000000..c367424d375f9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 00000000000000..82cc344d496322 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 00000000000000..df278bf938a690 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import GettingStarted from '../../assets/getting_started.png'; + +export const SetupGuide: React.FC = () => ( + + + + + + {i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', + + + +

+ +

+
+ + +

+ +

+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx new file mode 100644 index 00000000000000..45e318ca0f9d95 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../__mocks__/shallow_usecontext.mock'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; + +import { AppSearch } from './'; + +describe('App Search Routes', () => { + describe('/', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(EngineOverview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ + enterpriseSearchUrl: 'https://foo.bar', + })); + const wrapper = shallow(); + + expect(wrapper.find(EngineOverview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx new file mode 100644 index 00000000000000..8f7142f1631a95 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +import { KibanaContext, IKibanaContext } from '../index'; + +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; + +export const AppSearch: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx new file mode 100644 index 00000000000000..1aead8468ca3b0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { coreMock } from 'src/core/public/mocks'; +import { licensingMock } from '../../../licensing/public/mocks'; + +import { renderApp } from './'; +import { AppSearch } from './app_search'; + +describe('renderApp', () => { + const params = coreMock.createAppMountParamters(); + const core = coreMock.createStart(); + const config = {}; + const plugins = { + licensing: licensingMock.createSetup(), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('mounts and unmounts UI', () => { + const MockApp = () =>
Hello world!
; + + const unmount = renderApp(MockApp, core, params, config, plugins); + expect(params.element.querySelector('.hello-world')).not.toBeNull(); + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('renders AppSearch', () => { + renderApp(AppSearch, core, params, config, plugins); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx new file mode 100644 index 00000000000000..4ef7aca8260a20 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreStart, AppMountParameters, HttpSetup, ChromeBreadcrumb } from 'src/core/public'; +import { ClientConfigType, PluginsSetup } from '../plugin'; +import { LicenseProvider } from './shared/licensing'; + +export interface IKibanaContext { + enterpriseSearchUrl?: string; + http: HttpSetup; + setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; +} + +export const KibanaContext = React.createContext({}); + +/** + * This file serves as a reusable wrapper to share Kibana-level context and other helpers + * between various Enterprise Search plugins (e.g. AppSearch, WorkplaceSearch, ES landing page) + * which should be imported and passed in as the first param in plugin.ts. + */ + +export const renderApp = ( + App: React.FC, + core: CoreStart, + params: AppMountParameters, + config: ClientConfigType, + plugins: PluginsSetup +) => { + ReactDOM.render( + + + + + + + + + , + params.element + ); + return () => ReactDOM.unmountComponentAtNode(params.element); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts new file mode 100644 index 00000000000000..42f308c5542688 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getPublicUrl } from './'; + +describe('Enterprise Search URL helper', () => { + const httpMock = { get: jest.fn() } as any; + + it('calls and returns the public URL API endpoint', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://some.vanity.url' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://some.vanity.url'); + }); + + it('strips trailing slashes', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://trailing.slash/' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://trailing.slash'); + }); + + // For the most part, error logging/handling is done on the server side. + // On the front-end, we should simply gracefully fall back to config.host + // if we can't fetch a public URL + it('falls back to an empty string', async () => { + expect(await getPublicUrl(httpMock)).toEqual(''); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts new file mode 100644 index 00000000000000..419c187a0048a9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; + +/** + * On Elastic Cloud, the host URL set in kibana.yml is not necessarily the same + * URL we want to send users to in the front-end (e.g. if a vanity URL is set). + * + * This helper checks a Kibana API endpoint (which has checks an Enterprise + * Search internal API endpoint) for the correct public-facing URL to use. + */ +export const getPublicUrl = async (http: HttpSetup): Promise => { + try { + const { publicUrl } = await http.get('/api/enterprise_search/public_url'); + return stripTrailingSlash(publicUrl); + } catch { + return ''; + } +}; + +const stripTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url.slice(0, -1) : url; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts new file mode 100644 index 00000000000000..bbbb688b8ea7b4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getPublicUrl } from './get_enterprise_search_url'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts new file mode 100644 index 00000000000000..7ea73577c4de6f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generateBreadcrumb } from './generate_breadcrumbs'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; + +import { mockHistory as mockHistoryUntyped } from '../../__mocks__'; +const mockHistory = mockHistoryUntyped as any; + +jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) })); +import { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('generateBreadcrumb', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates a breadcrumb object matching EUI's breadcrumb type", () => { + const breadcrumb = generateBreadcrumb({ + text: 'Hello World', + path: '/hello_world', + history: mockHistory, + }); + expect(breadcrumb).toEqual({ + text: 'Hello World', + href: '/enterprise_search/hello_world', + onClick: expect.any(Function), + }); + }); + + it('prevents default navigation and uses React Router history on click', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; + const event = { preventDefault: jest.fn() }; + breadcrumb.onClick(event); + + expect(mockHistory.push).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; + + (letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true); + breadcrumb.onClick(); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + + it('does not generate link behavior if path is excluded', () => { + const breadcrumb = generateBreadcrumb({ text: 'Unclickable breadcrumb' }); + + expect(breadcrumb.href).toBeUndefined(); + expect(breadcrumb.onClick).toBeUndefined(); + }); +}); + +describe('enterpriseSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to page 1 second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); + +describe('appSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockHistory.createHref.mockImplementation( + ({ pathname }: any) => `/enterprise_search/app_search${pathname}` + ); + }); + + const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/app_search/', + onClick: expect.any(Function), + text: 'App Search', + }, + { + href: '/enterprise_search/app_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/app_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(appSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/app_search/', + onClick: expect.any(Function), + text: 'App Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to App Search second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 third', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[3] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts new file mode 100644 index 00000000000000..0e1bb796cbf2ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { History } from 'history'; + +import { letBrowserHandleEvent } from '../react_router_helpers'; + +/** + * Generate React-Router-friendly EUI breadcrumb objects + * https://elastic.github.io/eui/#/navigation/breadcrumbs + */ + +interface IGenerateBreadcrumbProps { + text: string; + path?: string; + history?: History; +} + +export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => { + const breadcrumb = { text } as EuiBreadcrumb; + + if (path && history) { + breadcrumb.href = history.createHref({ pathname: path }); + breadcrumb.onClick = (event) => { + if (letBrowserHandleEvent(event)) return; + event.preventDefault(); + history.push(path); + }; + } + + return breadcrumb; +}; + +/** + * Product-specific breadcrumb helpers + */ + +export type TBreadcrumbs = IGenerateBreadcrumbProps[]; + +export const enterpriseSearchBreadcrumbs = (history: History) => ( + breadcrumbs: TBreadcrumbs = [] +) => [ + generateBreadcrumb({ text: 'Enterprise Search' }), + ...breadcrumbs.map(({ text, path }: IGenerateBreadcrumbProps) => + generateBreadcrumb({ text, path, history }) + ), +]; + +export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => + enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts new file mode 100644 index 00000000000000..cf8bbbc593f2f7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; +export { appSearchBreadcrumbs } from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs } from './set_breadcrumbs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx new file mode 100644 index 00000000000000..974ca54277c51c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import '../../__mocks__/react_router_history.mock'; +import { mountWithKibanaContext } from '../../__mocks__'; + +jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() })); +import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './'; + +describe('SetAppSearchBreadcrumbs', () => { + const setBreadcrumbs = jest.fn(); + const builtBreadcrumbs = [] as any; + const appSearchBreadCrumbsInnerCall = jest.fn().mockReturnValue(builtBreadcrumbs); + const appSearchBreadCrumbsOuterCall = jest.fn().mockReturnValue(appSearchBreadCrumbsInnerCall); + (appSearchBreadcrumbs as jest.Mock).mockImplementation(appSearchBreadCrumbsOuterCall); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const mountSetAppSearchBreadcrumbs = (props: any) => { + return mountWithKibanaContext(, { + http: {}, + enterpriseSearchUrl: 'http://localhost:3002', + setBreadcrumbs, + }); + }; + + describe('when isRoot is false', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: false }); + + it('calls appSearchBreadcrumbs to build breadcrumbs, then registers them with Kibana', () => { + subject(); + + // calls appSearchBreadcrumbs to build breadcrumbs with the target page and current location + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([ + { text: 'Page 1', path: '/current-path' }, + ]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); + + describe('when isRoot is true', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: true }); + + it('calls appSearchBreadcrumbs to build breadcrumbs with an empty breadcrumb, then registers them with Kibana', () => { + subject(); + + // uses an empty bredcrumb + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx new file mode 100644 index 00000000000000..ad3cd65c09516b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { KibanaContext, IKibanaContext } from '../../index'; +import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; + +/** + * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view + * @see https://github.com/elastic/kibana/blob/master/src/core/public/chrome/chrome_service.tsx + */ + +export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; + +interface IBreadcrumbProps { + text: string; + isRoot?: never; +} +interface IRootBreadcrumbProps { + isRoot: true; + text?: never; +} + +export const SetAppSearchBreadcrumbs: React.FC = ({ + text, + isRoot, +}) => { + const history = useHistory(); + const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; + + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + + useEffect(() => { + setBreadcrumbs(appSearchBreadcrumbs(history)(crumb as TBreadcrumbs | [])); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts new file mode 100644 index 00000000000000..9c8c1417d48db2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LicenseContext, LicenseProvider, ILicenseContext } from './license_context'; +export { hasPlatinumLicense } from './license_checks'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts new file mode 100644 index 00000000000000..ad134e7d36b10c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { hasPlatinumLicense } from './license_checks'; + +describe('hasPlatinumLicense', () => { + it('is true for platinum licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true); + }); + + it('is true for enterprise licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' } as any)).toEqual(true); + }); + + it('is true for trial licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true); + }); + + it('is false if the current license is expired', () => { + expect(hasPlatinumLicense({ isActive: false, type: 'platinum' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'enterprise' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'trial' } as any)).toEqual(false); + }); + + it('is false for licenses below platinum', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'basic' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'standard' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: true, type: 'gold' } as any)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts new file mode 100644 index 00000000000000..de4a17ce2bd3c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicense } from '../../../../../licensing/public'; + +export const hasPlatinumLicense = (license?: ILicense) => { + return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx new file mode 100644 index 00000000000000..c65474ec1f5900 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { mountWithContext } from '../../__mocks__'; +import { LicenseContext, ILicenseContext } from './'; + +describe('LicenseProvider', () => { + const MockComponent: React.FC = () => { + const { license } = useContext(LicenseContext) as ILicenseContext; + return
{license?.type}
; + }; + + it('renders children', () => { + const wrapper = mountWithContext(, { license: { type: 'basic' } }); + + expect(wrapper.find('.license-test')).toHaveLength(1); + expect(wrapper.text()).toEqual('basic'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx new file mode 100644 index 00000000000000..9b47959ff75447 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; + +import { ILicense } from '../../../../../licensing/public'; + +export interface ILicenseContext { + license: ILicense; +} +interface ILicenseContextProps { + license$: Observable; + children: React.ReactNode; +} + +export const LicenseContext = React.createContext({}); + +export const LicenseProvider: React.FC = ({ license$, children }) => { + // Listen for changes to license subscription + const license = useObservable(license$); + + // Render rest of application and pass down license via context + return ; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx new file mode 100644 index 00000000000000..7d4c068b211555 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { EuiLink, EuiButton } from '@elastic/eui'; + +import '../../__mocks__/react_router_history.mock'; +import { mockHistory } from '../../__mocks__'; + +import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link'; + +describe('EUI & React Router Component Helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('renders an EuiButton', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + + it('passes down all ...rest props', () => { + const wrapper = shallow(); + const link = wrapper.find(EuiLink); + + expect(link.prop('external')).toEqual(true); + expect(link.prop('data-test-subj')).toEqual('foo'); + }); + + it('renders with the correct href and onClick props', () => { + const wrapper = mount(); + const link = wrapper.find(EuiLink); + + expect(link.prop('onClick')).toBeInstanceOf(Function); + expect(link.prop('href')).toEqual('/enterprise_search/foo/bar'); + expect(mockHistory.createHref).toHaveBeenCalled(); + }); + + describe('onClick', () => { + it('prevents default navigation and uses React Router history', () => { + const wrapper = mount(); + + const simulatedEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(simulatedEvent.preventDefault).toHaveBeenCalled(); + expect(mockHistory.push).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const wrapper = mount(); + + const simulatedEvent = { + shiftKey: true, + target: { getAttribute: () => '_blank' }, + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx new file mode 100644 index 00000000000000..f486e432bae76a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiLink, EuiButton, EuiButtonProps, EuiLinkAnchorProps } from '@elastic/eui'; + +import { letBrowserHandleEvent } from './link_events'; + +/** + * Generates either an EuiLink or EuiButton with a React-Router-ified link + * + * Based off of EUI's recommendations for handling React Router: + * https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51 + */ + +interface IEuiReactRouterProps { + to: string; +} + +export const EuiReactRouterHelper: React.FC = ({ to, children }) => { + const history = useHistory(); + + const onClick = (event: React.MouseEvent) => { + if (letBrowserHandleEvent(event)) return; + + // Prevent regular link behavior, which causes a browser refresh. + event.preventDefault(); + + // Push the route to the history. + history.push(to); + }; + + // Generate the correct link href (with basename etc. accounted for) + const href = history.createHref({ pathname: to }); + + const reactRouterProps = { href, onClick }; + return React.cloneElement(children as React.ReactElement, reactRouterProps); +}; + +type TEuiReactRouterLinkProps = EuiLinkAnchorProps & IEuiReactRouterProps; +type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps; + +export const EuiReactRouterLink: React.FC = ({ to, ...rest }) => ( + + + +); + +export const EuiReactRouterButton: React.FC = ({ to, ...rest }) => ( + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts new file mode 100644 index 00000000000000..46dc3286331533 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { letBrowserHandleEvent } from './link_events'; +export { EuiReactRouterLink as EuiLink } from './eui_link'; +export { EuiReactRouterButton as EuiButton } from './eui_link'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts new file mode 100644 index 00000000000000..3682946b63a136 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('letBrowserHandleEvent', () => { + const event = { + defaultPrevented: false, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + button: 0, + target: { + getAttribute: () => '_self', + }, + } as any; + + describe('the browser should handle the link when', () => { + it('default is prevented', () => { + expect(letBrowserHandleEvent({ ...event, defaultPrevented: true })).toBe(true); + }); + + it('is modified with metaKey', () => { + expect(letBrowserHandleEvent({ ...event, metaKey: true })).toBe(true); + }); + + it('is modified with altKey', () => { + expect(letBrowserHandleEvent({ ...event, altKey: true })).toBe(true); + }); + + it('is modified with ctrlKey', () => { + expect(letBrowserHandleEvent({ ...event, ctrlKey: true })).toBe(true); + }); + + it('is modified with shiftKey', () => { + expect(letBrowserHandleEvent({ ...event, shiftKey: true })).toBe(true); + }); + + it('it is not a left click event', () => { + expect(letBrowserHandleEvent({ ...event, button: 2 })).toBe(true); + }); + + it('the target is anything value other than _self', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue('_blank'), + }) + ).toBe(true); + }); + }); + + describe('the browser should NOT handle the link when', () => { + it('default is not prevented', () => { + expect(letBrowserHandleEvent({ ...event, defaultPrevented: false })).toBe(false); + }); + + it('is not modified', () => { + expect( + letBrowserHandleEvent({ + ...event, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + }) + ).toBe(false); + }); + + it('it is a left click event', () => { + expect(letBrowserHandleEvent({ ...event, button: 0 })).toBe(false); + }); + + it('the target is a value of _self', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue('_self'), + }) + ).toBe(false); + }); + + it('the target has no value', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue(null), + }) + ).toBe(false); + }); + }); +}); + +const targetValue = (value: string | null) => { + return { + getAttribute: () => value, + }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts new file mode 100644 index 00000000000000..93da2ab71d9527 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MouseEvent } from 'react'; + +/** + * Helper functions for determining which events we should + * let browsers handle natively, e.g. new tabs/windows + */ + +type THandleEvent = (event: MouseEvent) => boolean; + +export const letBrowserHandleEvent: THandleEvent = (event) => + event.defaultPrevented || + isModifiedEvent(event) || + !isLeftClickEvent(event) || + isTargetBlank(event); + +const isModifiedEvent: THandleEvent = (event) => + !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +const isLeftClickEvent: THandleEvent = (event) => event.button === 0; + +const isTargetBlank: THandleEvent = (event) => { + const element = event.target as HTMLElement; + const target = element.getAttribute('target'); + return !!target && target !== '_self'; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts new file mode 100644 index 00000000000000..c367424d375f9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss new file mode 100644 index 00000000000000..ecfa13cc828f0a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Setup Guide + */ +.setupGuide { + padding: 0; + min-height: 100vh; + + &__sidebar { + flex-basis: $euiSizeXXL * 7.5; + flex-shrink: 0; + padding: $euiSizeL; + margin-right: 0; + + background-color: $euiColorLightestShade; + border-color: $euiBorderColor; + border-style: solid; + border-width: 0 0 $euiBorderWidthThin 0; // bottom - mobile view + + @include euiBreakpoint('m', 'l', 'xl') { + border-width: 0 $euiBorderWidthThin 0 0; // right - desktop view + } + @include euiBreakpoint('m', 'l') { + flex-basis: $euiSizeXXL * 10; + } + @include euiBreakpoint('xl') { + flex-basis: $euiSizeXXL * 12.5; + } + } + + &__body { + align-self: start; + padding: $euiSizeL; + + @include euiBreakpoint('l') { + padding: $euiSizeXXL ($euiSizeXXL * 1.25); + } + } + + &__thumbnail { + display: block; + max-width: 100%; + height: auto; + margin: $euiSizeL auto; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx new file mode 100644 index 00000000000000..0423ae61779af1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSteps, EuiIcon, EuiLink } from '@elastic/eui'; + +import { mountWithContext } from '../../__mocks__'; + +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow( + +

Wow!

+
+ ); + + expect(wrapper.find('h1').text()).toEqual('Enterprise Search'); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('logoEnterpriseSearch'); + expect(wrapper.find('[data-test-subj="test"]').text()).toEqual('Wow!'); + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('renders with optional auth links', () => { + const wrapper = mountWithContext( + + Baz + + ); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com'); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx new file mode 100644 index 00000000000000..31ff0089dbd7c9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiPage, + EuiPageSideBar, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiIcon, + EuiSteps, + EuiCode, + EuiCodeBlock, + EuiAccordion, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import './setup_guide.scss'; + +/** + * Shared Setup Guide component. Sidebar content and product name/links are + * customizable, but the basic layout and instruction steps are DRYed out + */ + +interface ISetupGuideProps { + children: React.ReactNode; + productName: string; + productEuiIcon: 'logoAppSearch' | 'logoWorkplaceSearch' | 'logoEnterpriseSearch'; + standardAuthLink?: string; + elasticsearchNativeAuthLink?: string; +} + +export const SetupGuide: React.FC = ({ + children, + productName, + productEuiIcon, + standardAuthLink, + elasticsearchNativeAuthLink, +}) => ( + + + + + + + + + + + + + + + +

{productName}

+
+
+
+ + {children} +
+ + + + +

+ config/kibana.yml, + configSetting: enterpriseSearch.host, + }} + /> +

+ + enterpriseSearch.host: 'http://localhost:3002' + + + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { + defaultMessage: 'Reload your Kibana instance', + }), + children: ( + +

+ +

+

+ + Elasticsearch Native Auth + + ) : ( + 'Elasticsearch Native Auth' + ), + }} + /> +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { + defaultMessage: 'Troubleshooting issues', + }), + children: ( + <> + + +

+ +

+
+
+ + + +

+ +

+
+
+ + + +

+ + Standard Auth + + ) : ( + 'Standard Auth' + ), + }} + /> +

+
+
+ + ), + }, + ]} + /> +
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts new file mode 100644 index 00000000000000..f871f48b171548 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { sendTelemetry } from './send_telemetry'; +export { SendAppSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx new file mode 100644 index 00000000000000..9825c0d8ab889d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { httpServiceMock } from 'src/core/public/mocks'; +import { mountWithKibanaContext } from '../../__mocks__'; +import { sendTelemetry, SendAppSearchTelemetry } from './'; + +describe('Shared Telemetry Helpers', () => { + const httpMock = httpServiceMock.createSetupContract(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sendTelemetry', () => { + it('successfully calls the server-side telemetry endpoint', () => { + sendTelemetry({ + http: httpMock, + product: 'enterprise_search', + action: 'viewed', + metric: 'setup_guide', + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers: { 'Content-Type': 'application/json' }, + body: '{"action":"viewed","metric":"setup_guide"}', + }); + }); + + it('throws an error if the telemetry endpoint fails', () => { + const httpRejectMock = sendTelemetry({ + http: { put: () => Promise.reject() }, + } as any); + + expect(httpRejectMock).rejects.toThrow('Unable to send telemetry'); + }); + }); + + describe('React component helpers', () => { + it('SendAppSearchTelemetry component', () => { + mountWithKibanaContext(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { + headers: { 'Content-Type': 'application/json' }, + body: '{"action":"clicked","metric":"button"}', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx new file mode 100644 index 00000000000000..300cb182727174 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect } from 'react'; + +import { HttpSetup } from 'src/core/public'; +import { KibanaContext, IKibanaContext } from '../../index'; + +interface ISendTelemetryProps { + action: 'viewed' | 'error' | 'clicked'; + metric: string; // e.g., 'setup_guide' +} + +interface ISendTelemetry extends ISendTelemetryProps { + http: HttpSetup; + product: 'app_search' | 'workplace_search' | 'enterprise_search'; +} + +/** + * Base function - useful for non-component actions, e.g. clicks + */ + +export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { + try { + await http.put(`/api/${product}/telemetry`, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, metric }), + }); + } catch (error) { + throw new Error('Unable to send telemetry'); + } +}; + +/** + * React component helpers - useful for on-page-load/views + * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + */ + +export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'app_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/index.ts b/x-pack/plugins/enterprise_search/public/index.ts new file mode 100644 index 00000000000000..06272641b19294 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { EnterpriseSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new EnterpriseSearchPlugin(initializerContext); +}; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts new file mode 100644 index 00000000000000..fbfcc303de47a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Plugin, + PluginInitializerContext, + CoreSetup, + CoreStart, + AppMountParameters, + HttpSetup, +} from 'src/core/public'; + +import { + FeatureCatalogueCategory, + HomePublicPluginSetup, +} from '../../../../src/plugins/home/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; +import { LicensingPluginSetup } from '../../licensing/public'; + +import { getPublicUrl } from './applications/shared/enterprise_search_url'; +import AppSearchLogo from './applications/app_search/assets/logo.svg'; + +export interface ClientConfigType { + host?: string; +} +export interface PluginsSetup { + home: HomePublicPluginSetup; + licensing: LicensingPluginSetup; +} + +export class EnterpriseSearchPlugin implements Plugin { + private config: ClientConfigType; + private hasCheckedPublicUrl: boolean = false; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } + + public setup(core: CoreSetup, plugins: PluginsSetup) { + const config = { host: this.config.host }; + + core.application.register({ + id: 'appSearch', + title: 'App Search', + appRoute: '/app/enterprise_search/app_search', + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + + await this.setPublicUrl(config, coreStart.http); + + const { renderApp } = await import('./applications'); + const { AppSearch } = await import('./applications/app_search'); + + return renderApp(AppSearch, coreStart, params, config, plugins); + }, + }); + // TODO: Workplace Search will need to register its own plugin. + + plugins.home.featureCatalogue.register({ + id: 'appSearch', + title: 'App Search', + icon: AppSearchLogo, + description: + 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', + path: '/app/enterprise_search/app_search', + category: FeatureCatalogueCategory.DATA, + showOnHomePage: true, + }); + // TODO: Workplace Search will need to register its own feature catalogue section/card. + } + + public start(core: CoreStart) {} + + public stop() {} + + private async setPublicUrl(config: ClientConfigType, http: HttpSetup) { + if (!config.host) return; // No API to check + if (this.hasCheckedPublicUrl) return; // We've already performed the check + + const publicUrl = await getPublicUrl(http); + if (publicUrl) config.host = publicUrl; + this.hasCheckedPublicUrl = true; + } +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts new file mode 100644 index 00000000000000..e95056b8713248 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggingSystemMock } from 'src/core/server/mocks'; + +jest.mock('../../../../../../src/core/server', () => ({ + SavedObjectsErrorHelpers: { + isNotFoundError: jest.fn(), + }, +})); +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; + +describe('App Search Telemetry Usage Collector', () => { + const mockLogger = loggingSystemMock.create().get(); + + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + } as any; + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.engines_overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_clicked.create_first_engine_button': 40, + 'ui_clicked.header_launch_button': 50, + 'ui_clicked.engine_table_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + engines_overview: 20, + }, + ui_error: { + cannot_connect: 3, + }, + ui_clicked: { + create_first_engine_button: 40, + header_launch_button: 50, + engine_table_link: 60, + }, + }); + }); + + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + create_first_engine_button: 0, + header_launch_button: 0, + engine_table_link: 0, + }, + }); + }); + + it('should not throw but log a warning if saved objects errors', async () => { + const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any; + registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger); + + // Without log warning (not found) + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); + await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + + // With log warning + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); + await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function' + ); + }); + }); + + describe('incrementUICounter', () => { + it('should increment the saved objects internal repository', async () => { + const response = await incrementUICounter({ + savedObjects: savedObjectsMock, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( + 'app_search_telemetry', + 'app_search_telemetry', + 'ui_clicked.button' + ); + expect(response).toEqual({ success: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts new file mode 100644 index 00000000000000..a10f96907ad28a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { + ISavedObjectsRepository, + SavedObjectsServiceStart, + SavedObjectAttributes, + Logger, +} from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +interface ITelemetry { + ui_viewed: { + setup_guide: number; + engines_overview: number; + }; + ui_error: { + cannot_connect: number; + }; + ui_clicked: { + create_first_engine_button: number; + header_launch_button: number; + engine_table_link: number; + }; +} + +export const AS_TELEMETRY_NAME = 'app_search_telemetry'; + +/** + * Register the telemetry collector + */ + +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart, + log: Logger +) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: 'app_search', + fetch: async () => fetchTelemetryMetrics(savedObjects, log), + isReady: () => true, + schema: { + ui_viewed: { + setup_guide: { type: 'long' }, + engines_overview: { type: 'long' }, + }, + ui_error: { + cannot_connect: { type: 'long' }, + }, + ui_clicked: { + create_first_engine_button: { type: 'long' }, + header_launch_button: { type: 'long' }, + engine_table_link: { type: 'long' }, + }, + }, + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( + savedObjectsRepository, + log + )) as SavedObjectAttributes; + + const defaultTelemetrySavedObject: ITelemetry = { + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + create_first_engine_button: 0, + header_launch_button: 0, + engine_table_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + return { + ui_viewed: { + setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0), + engines_overview: get(savedObjectAttributes, 'ui_viewed.engines_overview', 0), + }, + ui_error: { + cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0), + }, + ui_clicked: { + create_first_engine_button: get( + savedObjectAttributes, + 'ui_clicked.create_first_engine_button', + 0 + ), + header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), + engine_table_link: get(savedObjectAttributes, 'ui_clicked.engine_table_link', 0), + }, + } as ITelemetry; +}; + +/** + * Helper function - fetches saved objects attributes + */ + +const getSavedObjectAttributesFromRepo = async ( + savedObjectsRepository: ISavedObjectsRepository, + log: Logger +) => { + try { + return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; + } catch (e) { + if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { + log.warn(`Failed to retrieve App Search telemetry data: ${e}`); + } + return null; + } +}; + +/** + * Set saved objection attributes - used by telemetry route + */ + +interface IIncrementUICounter { + savedObjects: SavedObjectsServiceStart; + uiAction: string; + metric: string; +} + +export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) { + const internalRepository = savedObjects.createInternalRepository(); + + await internalRepository.incrementCounter( + AS_TELEMETRY_NAME, + AS_TELEMETRY_NAME, + `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide + ); + + return { success: true }; +} diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts new file mode 100644 index 00000000000000..1e4159124ed942 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { EnterpriseSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new EnterpriseSearchPlugin(initializerContext); +}; + +export const configSchema = schema.object({ + host: schema.maybe(schema.string()), + enabled: schema.boolean({ defaultValue: true }), + accessCheckTimeout: schema.number({ defaultValue: 5000 }), + accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), +}); + +export type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + host: true, + }, +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts new file mode 100644 index 00000000000000..11d4a387b533ff --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +import { checkAccess } from './check_access'; + +describe('checkAccess', () => { + const mockSecurity = { + authz: { + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: false, + }), + }), + actions: { + ui: { + get: () => null, + }, + }, + }, + }; + const mockDependencies = { + request: {}, + config: { host: 'http://localhost:3002' }, + security: mockSecurity, + } as any; + + describe('when security is disabled', () => { + it('should allow all access', async () => { + const security = undefined; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + }); + + describe('when the user is a superuser', () => { + it('should allow all access', async () => { + const security = { + ...mockSecurity, + authz: { + mode: { useRbacForRequest: () => true }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: true, + }), + }), + actions: { ui: { get: () => {} } }, + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: () => ({ + globally: () => Promise.reject({ statusCode: 403 }), + }), + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + + it('throws other authz errors', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: undefined, + }, + }; + await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + }); + }); + + describe('when the user is a non-superuser', () => { + describe('when enterpriseSearch.host is not set in kibana.yml', () => { + it('should deny all access', async () => { + const config = { host: undefined }; + expect(await checkAccess({ ...mockDependencies, config })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when enterpriseSearch.host is set in kibana.yml', () => { + it('should make a http call and return the access response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ + access: { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }, + })); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to no access if no http response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts new file mode 100644 index 00000000000000..0239cb6422d032 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest, Logger } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { ConfigType } from '../'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +interface ICheckAccess { + request: KibanaRequest; + security?: SecurityPluginSetup; + config: ConfigType; + log: Logger; +} +export interface IAccess { + hasAppSearchAccess: boolean; + hasWorkplaceSearchAccess: boolean; +} + +const ALLOW_ALL_PLUGINS = { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, +}; +const DENY_ALL_PLUGINS = { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, +}; + +/** + * Determines whether the user has access to our Enterprise Search products + * via HTTP call. If not, we hide the corresponding plugin links from the + * nav and catalogue in `plugin.ts`, which disables plugin access + */ +export const checkAccess = async ({ + config, + security, + request, + log, +}: ICheckAccess): Promise => { + // If security has been disabled, always show the plugin + if (!security?.authz.mode.useRbacForRequest(request)) { + return ALLOW_ALL_PLUGINS; + } + + // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin + const isSuperUser = async (): Promise => { + try { + const { hasAllRequested } = await security.authz + .checkPrivilegesWithRequest(request) + .globally(security.authz.actions.ui.get('enterpriseSearch', 'all')); + return hasAllRequested; + } catch (err) { + if (err.statusCode === 401 || err.statusCode === 403) { + return false; + } + throw err; + } + }; + if (await isSuperUser()) { + return ALLOW_ALL_PLUGINS; + } + + // Hide the plugin when enterpriseSearch.host is not defined in kibana.yml + if (!config.host) { + return DENY_ALL_PLUGINS; + } + + // When enterpriseSearch.host is defined in kibana.yml, + // make a HTTP call which returns product access + const { access } = (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + return access || DENY_ALL_PLUGINS; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts new file mode 100644 index 00000000000000..cf35a458b48258 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('node-fetch'); +const fetchMock = require('node-fetch') as jest.Mock; +const { Response } = jest.requireActual('node-fetch'); + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +describe('callEnterpriseSearchConfigAPI', () => { + const mockConfig = { + host: 'http://localhost:3002', + accessCheckTimeout: 200, + accessCheckTimeoutWarning: 100, + }; + const mockRequest = { + url: { path: '/app/kibana' }, + headers: { authorization: '==someAuth' }, + }; + const mockDependencies = { + config: mockConfig, + request: mockRequest, + log: loggingSystemMock.create().get(), + } as any; + + const mockResponse = { + version: { + number: '1.0.0', + }, + settings: { + external_url: 'http://some.vanity.url/', + }, + access: { + user: 'someuser', + products: { + app_search: true, + workplace_search: false, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the config API endpoint', async () => { + fetchMock.mockImplementationOnce((url: string) => { + expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config'); + return Promise.resolve(new Response(JSON.stringify(mockResponse))); + }); + + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ + publicUrl: 'http://some.vanity.url/', + access: { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: false, + }, + }); + }); + + it('returns early if config.host is not set', async () => { + const config = { host: '' }; + + expect(await callEnterpriseSearchConfigAPI({ ...mockDependencies, config })).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('handles server errors', async () => { + fetchMock.mockImplementationOnce(() => { + return Promise.reject('500'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: 500' + ); + + fetchMock.mockImplementationOnce(() => { + return Promise.resolve('Bad Data'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: TypeError: response.json is not a function' + ); + }); + + it('handles timeouts', async () => { + jest.useFakeTimers(); + + // Warning + callEnterpriseSearchConfigAPI(mockDependencies); + jest.advanceTimersByTime(150); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + 'Enterprise Search access check took over 100ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.' + ); + + // Timeout + fetchMock.mockImplementationOnce(async () => { + jest.advanceTimersByTime(250); + return Promise.reject({ name: 'AbortError' }); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + "Exceeded 200ms timeout while checking http://localhost:3002. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses." + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts new file mode 100644 index 00000000000000..7a6d1eac1b4545 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import AbortController from 'abort-controller'; +import fetch from 'node-fetch'; + +import { KibanaRequest, Logger } from 'src/core/server'; +import { ConfigType } from '../'; +import { IAccess } from './check_access'; + +interface IParams { + request: KibanaRequest; + config: ConfigType; + log: Logger; +} +interface IReturn { + publicUrl?: string; + access?: IAccess; +} + +/** + * Calls an internal Enterprise Search API endpoint which returns + * useful various settings (e.g. product access, external URL) + * needed by the Kibana plugin at the setup stage + */ +const ENDPOINT = '/api/ent/v1/internal/client_config'; + +export const callEnterpriseSearchConfigAPI = async ({ + config, + log, + request, +}: IParams): Promise => { + if (!config.host) return {}; + + const TIMEOUT_WARNING = `Enterprise Search access check took over ${config.accessCheckTimeoutWarning}ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.`; + const TIMEOUT_MESSAGE = `Exceeded ${config.accessCheckTimeout}ms timeout while checking ${config.host}. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses.`; + const CONNECTION_ERROR = 'Could not perform access check to Enterprise Search'; + + const warningTimeout = setTimeout(() => { + log.warn(TIMEOUT_WARNING); + }, config.accessCheckTimeoutWarning); + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, config.accessCheckTimeout); + + try { + const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); + const response = await fetch(enterpriseSearchUrl, { + headers: { Authorization: request.headers.authorization as string }, + signal: controller.signal, + }); + const data = await response.json(); + + return { + publicUrl: data?.settings?.external_url, + access: { + hasAppSearchAccess: !!data?.access?.products?.app_search, + hasWorkplaceSearchAccess: !!data?.access?.products?.workplace_search, + }, + }; + } catch (err) { + if (err.name === 'AbortError') { + log.warn(TIMEOUT_MESSAGE); + } else { + log.error(`${CONNECTION_ERROR}: ${err.toString()}`); + if (err instanceof Error) log.debug(err.stack as string); + } + return {}; + } finally { + clearTimeout(warningTimeout); + clearTimeout(timeout); + } +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts new file mode 100644 index 00000000000000..70be8600862e9c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + Plugin, + PluginInitializerContext, + CoreSetup, + Logger, + SavedObjectsServiceStart, + IRouter, + KibanaRequest, +} from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; + +import { ConfigType } from './'; +import { checkAccess } from './lib/check_access'; +import { registerPublicUrlRoute } from './routes/enterprise_search/public_url'; +import { registerEnginesRoute } from './routes/app_search/engines'; +import { registerTelemetryRoute } from './routes/app_search/telemetry'; +import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; + +export interface PluginsSetup { + usageCollection?: UsageCollectionSetup; + security?: SecurityPluginSetup; + features: FeaturesPluginSetup; +} + +export interface IRouteDependencies { + router: IRouter; + config: ConfigType; + log: Logger; + getSavedObjectsService?(): SavedObjectsServiceStart; +} + +export class EnterpriseSearchPlugin implements Plugin { + private config: Observable; + private logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.create(); + this.logger = initializerContext.logger.get(); + } + + public async setup( + { capabilities, http, savedObjects, getStartServices }: CoreSetup, + { usageCollection, security, features }: PluginsSetup + ) { + const config = await this.config.pipe(first()).toPromise(); + + /** + * Register space/feature control + */ + features.registerFeature({ + id: 'enterpriseSearch', + name: 'Enterprise Search', + order: 0, + icon: 'logoEnterpriseSearch', + navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId + app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + privileges: null, + }); + + /** + * Register user access to the Enterprise Search plugins + */ + capabilities.registerSwitcher(async (request: KibanaRequest) => { + const dependencies = { config, security, request, log: this.logger }; + + const { hasAppSearchAccess } = await checkAccess(dependencies); + // TODO: hasWorkplaceSearchAccess + + return { + navLinks: { + appSearch: hasAppSearchAccess, + }, + catalogue: { + appSearch: hasAppSearchAccess, + }, + }; + }); + + /** + * Register routes + */ + const router = http.createRouter(); + const dependencies = { router, config, log: this.logger }; + + registerPublicUrlRoute(dependencies); + registerEnginesRoute(dependencies); + + /** + * Bootstrap the routes, saved objects, and collector for telemetry + */ + savedObjects.registerType(appSearchTelemetryType); + let savedObjectsStarted: SavedObjectsServiceStart; + + getStartServices().then(([coreStart]) => { + savedObjectsStarted = coreStart.savedObjects; + if (usageCollection) { + registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + } + }); + registerTelemetryRoute({ + ...dependencies, + getSavedObjectsService: () => savedObjectsStarted, + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts new file mode 100644 index 00000000000000..3cca5e21ce9c3c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MockRouter } from './router.mock'; +export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock'; diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts new file mode 100644 index 00000000000000..1ca7755979f99b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + IRouter, + KibanaRequest, + RequestHandlerContext, + RouteValidatorConfig, +} from 'src/core/server'; + +/** + * Test helper that mocks Kibana's router and DRYs out various helper (callRoute, schema validation) + */ + +type methodType = 'get' | 'post' | 'put' | 'patch' | 'delete'; +type payloadType = 'params' | 'query' | 'body'; + +interface IMockRouterProps { + method: methodType; + payload?: payloadType; +} +interface IMockRouterRequest { + body?: object; + query?: object; + params?: object; +} +type TMockRouterRequest = KibanaRequest | IMockRouterRequest; + +export class MockRouter { + public router!: jest.Mocked; + public method: methodType; + public payload?: payloadType; + public response = httpServerMock.createResponseFactory(); + + constructor({ method, payload }: IMockRouterProps) { + this.createRouter(); + this.method = method; + this.payload = payload; + } + + public createRouter = () => { + this.router = httpServiceMock.createRouter(); + }; + + public callRoute = async (request: TMockRouterRequest) => { + const [, handler] = this.router[this.method].mock.calls[0]; + + const context = {} as jest.Mocked; + await handler(context, httpServerMock.createKibanaRequest(request as any), this.response); + }; + + /** + * Schema validation helpers + */ + + public validateRoute = (request: TMockRouterRequest) => { + if (!this.payload) throw new Error('Cannot validate wihout a payload type specified.'); + + const [config] = this.router[this.method].mock.calls[0]; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + + const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void }; + const payloadRequest = request[this.payload] as KibanaRequest; + + payloadValidation.validate(payloadRequest); + }; + + public shouldValidate = (request: TMockRouterRequest) => { + expect(() => this.validateRoute(request)).not.toThrow(); + }; + + public shouldThrow = (request: TMockRouterRequest) => { + expect(() => this.validateRoute(request)).toThrow(); + }; +} + +/** + * Example usage: + */ +// const mockRouter = new MockRouter({ method: 'get', payload: 'body' }); +// +// beforeEach(() => { +// jest.clearAllMocks(); +// mockRouter.createRouter(); +// +// registerExampleRoute({ router: mockRouter.router, ...dependencies }); // Whatever other dependencies the route needs +// }); + +// it('hits the endpoint successfully', async () => { +// await mockRouter.callRoute({ body: { foo: 'bar' } }); +// +// expect(mockRouter.response.ok).toHaveBeenCalled(); +// }); + +// it('validates', () => { +// const request = { body: { foo: 'bar' } }; +// mockRouter.shouldValidate(request); +// }); diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts new file mode 100644 index 00000000000000..9b6fa30271d613 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggingSystemMock } from 'src/core/server/mocks'; +import { ConfigType } from '../../'; + +export const mockLogger = loggingSystemMock.createLogger().get(); + +export const mockConfig = { + enabled: true, + host: 'http://localhost:3002', + accessCheckTimeout: 5000, + accessCheckTimeoutWarning: 300, +} as ConfigType; + +/** + * This is useful for tests that don't use either config or log, + * but should still pass them in to pass Typescript definitions + */ +export const mockDependencies = { + // Mock router should be handled on a per-test basis + config: mockConfig, + log: mockLogger, +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts new file mode 100644 index 00000000000000..d5b1bc50034562 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerEnginesRoute } from './engines'; + +jest.mock('node-fetch'); +const fetch = jest.requireActual('node-fetch'); +const { Response } = fetch; +const fetchMock = require('node-fetch') as jest.Mocked; + +describe('engine routes', () => { + describe('GET /api/app_search/engines', () => { + const AUTH_HEADER = 'Basic 123'; + const mockRequest = { + headers: { + authorization: AUTH_HEADER, + }, + query: { + type: 'indexed', + pageIndex: 1, + }, + }; + + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + + registerEnginesRoute({ + router: mockRouter.router, + log: mockLogger, + config: mockConfig, + }); + }); + + describe('when the underlying App Search API returns a 200', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturn({ + results: [{ name: 'engine1' }], + meta: { page: { total_results: 1 } }, + }); + }); + + it('should return 200 with a list of engines from the App Search API', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } }, + }); + }); + }); + + describe('when the App Search URL is invalid', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturnError(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('when the App Search API returns invalid data', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturnInvalidData(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}' + ); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { query: { type: 'meta', pageIndex: 5 } }; + mockRouter.shouldValidate(request); + }); + + it('wrong pageIndex type', () => { + const request = { query: { type: 'indexed', pageIndex: 'indexed' } }; + mockRouter.shouldThrow(request); + }); + + it('wrong type string', () => { + const request = { query: { type: 'invalid', pageIndex: 1 } }; + mockRouter.shouldThrow(request); + }); + + it('missing pageIndex', () => { + const request = { query: { type: 'indexed' } }; + mockRouter.shouldThrow(request); + }); + + it('missing type', () => { + const request = { query: { pageIndex: 1 } }; + mockRouter.shouldThrow(request); + }); + }); + + const AppSearchAPI = { + shouldBeCalledWith(expectedUrl: string, expectedParams: object) { + return { + andReturn(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify(response))); + }); + }, + andReturnInvalidData() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); + }); + }, + andReturnError() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.reject('Failed'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts new file mode 100644 index 00000000000000..ca83c0e187ddb5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fetch from 'node-fetch'; +import querystring from 'querystring'; +import { schema } from '@kbn/config-schema'; + +import { IRouteDependencies } from '../../plugin'; +import { ENGINES_PAGE_SIZE } from '../../../common/constants'; + +export function registerEnginesRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/app_search/engines', + validate: { + query: schema.object({ + type: schema.oneOf([schema.literal('indexed'), schema.literal('meta')]), + pageIndex: schema.number(), + }), + }, + }, + async (context, request, response) => { + try { + const enterpriseSearchUrl = config.host as string; + const { type, pageIndex } = request.query; + + const params = querystring.stringify({ + type, + 'page[current]': pageIndex, + 'page[size]': ENGINES_PAGE_SIZE, + }); + const url = `${encodeURI(enterpriseSearchUrl)}/as/engines/collection?${params}`; + + const enginesResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization as string }, + }); + + const engines = await enginesResponse.json(); + const hasValidData = + Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; + + if (hasValidData) { + return response.ok({ body: engines }); + } else { + // Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data + throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`); + } + } catch (e) { + log.error(`Cannot connect to App Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack as string); + + return response.notFound({ body: 'cannot-connect' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts new file mode 100644 index 00000000000000..e2d5fbcec37056 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerTelemetryRoute } from './telemetry'; + +jest.mock('../../collectors/app_search/telemetry', () => ({ + incrementUICounter: jest.fn(), +})); +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +/** + * Since these route callbacks are so thin, these serve simply as integration tests + * to ensure they're wired up to the collector functions correctly. Business logic + * is tested more thoroughly in the collectors/telemetry tests. + */ +describe('App Search Telemetry API', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + + registerTelemetryRoute({ + router: mockRouter.router, + getSavedObjectsService: () => savedObjectsServiceMock.createStartContract(), + log: mockLogger, + config: mockConfig, + }); + }); + + describe('PUT /api/app_search/telemetry', () => { + it('increments the saved objects counter', async () => { + const successResponse = { success: true }; + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); + + await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + savedObjects: expect.any(Object), + uiAction: 'ui_viewed', + metric: 'setup_guide', + }); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + + it('throws an error when incrementing fails', async () => { + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed'))); + + await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); + + expect(incrementUICounter).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockRouter.response.internalError).toHaveBeenCalled(); + }); + + it('throws an error if the Saved Objects service is unavailable', async () => { + jest.clearAllMocks(); + registerTelemetryRoute({ + router: mockRouter.router, + getSavedObjectsService: null, + log: mockLogger, + } as any); + await mockRouter.callRoute({}); + + expect(incrementUICounter).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockRouter.response.internalError).toHaveBeenCalled(); + expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual( + expect.stringContaining( + 'App Search UI telemetry error: Error: Could not find Saved Objects service' + ) + ); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + mockRouter.shouldValidate(request); + }); + + it('wrong action string', () => { + const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + mockRouter.shouldThrow(request); + }); + + it('wrong metric type', () => { + const request = { body: { action: 'clicked', metric: true } }; + mockRouter.shouldThrow(request); + }); + + it('action is missing', () => { + const request = { body: { metric: 'engines_overview' } }; + mockRouter.shouldThrow(request); + }); + + it('metric is missing', () => { + const request = { body: { action: 'error' } }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts new file mode 100644 index 00000000000000..4cc9b64adc0927 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouteDependencies } from '../../plugin'; +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +export function registerTelemetryRoute({ + router, + getSavedObjectsService, + log, +}: IRouteDependencies) { + router.put( + { + path: '/api/app_search/telemetry', + validate: { + body: schema.object({ + action: schema.oneOf([ + schema.literal('viewed'), + schema.literal('clicked'), + schema.literal('error'), + ]), + metric: schema.string(), + }), + }, + }, + async (ctx, request, response) => { + const { action, metric } = request.body; + + try { + if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service'); + + return response.ok({ + body: await incrementUICounter({ + savedObjects: getSavedObjectsService(), + uiAction: `ui_${action}`, + metric, + }), + }); + } catch (e) { + log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`); + return response.internalError({ body: 'App Search UI telemetry failed' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts new file mode 100644 index 00000000000000..846aae3fce56f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockDependencies } from '../__mocks__'; + +jest.mock('../../lib/enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +import { registerPublicUrlRoute } from './public_url'; + +describe('Enterprise Search Public URL API', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + mockRouter = new MockRouter({ method: 'get' }); + + registerPublicUrlRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + describe('GET /api/enterprise_search/public_url', () => { + it('returns a publicUrl', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ publicUrl: 'http://some.vanity.url' }); + }); + + await mockRouter.callRoute({}); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { publicUrl: 'http://some.vanity.url' }, + headers: { 'content-type': 'application/json' }, + }); + }); + + // For the most part, all error logging is handled by callEnterpriseSearchConfigAPI. + // This endpoint should mostly just fall back gracefully to an empty string + it('falls back to an empty string', async () => { + await mockRouter.callRoute({}); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { publicUrl: '' }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts new file mode 100644 index 00000000000000..a9edd4eb10da03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouteDependencies } from '../../plugin'; +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +export function registerPublicUrlRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/enterprise_search/public_url', + validate: false, + }, + async (context, request, response) => { + const { publicUrl = '' } = + (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + + return response.ok({ + body: { publicUrl }, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts new file mode 100644 index 00000000000000..32322d494b5e21 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; + +export const appSearchTelemetryType: SavedObjectsType = { + name: AS_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/ingest_manager/common/types/models/settings.ts b/x-pack/plugins/ingest_manager/common/types/models/settings.ts index 2921808230b47f..98d99911f1b3fc 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/settings.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/settings.ts @@ -10,6 +10,7 @@ interface BaseSettings { package_auto_upgrade?: boolean; kibana_url?: string; kibana_ca_sha256?: string; + has_seen_add_data_notice?: boolean; } export interface Settings extends BaseSettings { diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index 181b93a9e24252..877184740166f5 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -5,6 +5,6 @@ "ui": true, "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], - "optionalPlugins": ["security", "features", "cloud", "usageCollection"], + "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"], "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/index.ts new file mode 100644 index 00000000000000..bab6049198249b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { TutorialDirectoryNotice, TutorialDirectoryHeaderLink } from './tutorial_directory_notice'; +export { TutorialModuleNotice } from './tutorial_module_notice'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx new file mode 100644 index 00000000000000..553623380dcc05 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useState, useCallback, useEffect } from 'react'; +import { BehaviorSubject } from 'rxjs'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiLink, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { + TutorialDirectoryNoticeComponent, + TutorialDirectoryHeaderLinkComponent, +} from 'src/plugins/home/public'; +import { sendPutSettings, useGetSettings, useLink, useCapabilities } from '../../hooks'; + +const FlexItemButtonWrapper = styled(EuiFlexItem)` + &&& { + margin-bottom: 0; + } +`; + +const tutorialDirectoryNoticeState$ = new BehaviorSubject({ + settingsDataLoaded: false, + hasSeenNotice: false, +}); + +export const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => { + const { getHref } = useLink(); + const { show: hasIngestManager } = useCapabilities(); + const { data: settingsData, isLoading } = useGetSettings(); + const [dismissedNotice, setDismissedNotice] = useState(false); + + const dismissNotice = useCallback(async () => { + setDismissedNotice(true); + await sendPutSettings({ + has_seen_add_data_notice: true, + }); + }, []); + + useEffect(() => { + tutorialDirectoryNoticeState$.next({ + settingsDataLoaded: !isLoading, + hasSeenNotice: Boolean(dismissedNotice || settingsData?.item?.has_seen_add_data_notice), + }); + }, [isLoading, settingsData, dismissedNotice]); + + const hasSeenNotice = + isLoading || settingsData?.item?.has_seen_add_data_notice || dismissedNotice; + + return hasIngestManager && !hasSeenNotice ? ( + <> + + + + + ), + }} + /> + } + > +

+ + + + ), + }} + /> +

+ + +
+ + + +
+
+ +
+ { + dismissNotice(); + }} + > + + +
+
+
+
+ + ) : null; +}); + +export const TutorialDirectoryHeaderLink: TutorialDirectoryHeaderLinkComponent = memo(() => { + const { getHref } = useLink(); + const { show: hasIngestManager } = useCapabilities(); + const [noticeState, setNoticeState] = useState({ + settingsDataLoaded: false, + hasSeenNotice: false, + }); + + useEffect(() => { + const subscription = tutorialDirectoryNoticeState$.subscribe((value) => setNoticeState(value)); + return () => { + subscription.unsubscribe(); + }; + }, []); + + return hasIngestManager && noticeState.settingsDataLoaded && noticeState.hasSeenNotice ? ( + + + + ) : null; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx new file mode 100644 index 00000000000000..a26691bdd64a00 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; +import { TutorialModuleNoticeComponent } from 'src/plugins/home/public'; +import { useGetPackages, useLink, useCapabilities } from '../../hooks'; + +export const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName }) => { + const { getHref } = useLink(); + const { show: hasIngestManager } = useCapabilities(); + const { data: packagesData, isLoading } = useGetPackages(); + + const pkgInfo = + !isLoading && + packagesData?.response && + packagesData.response.find((pkg) => pkg.name === moduleName); + + if (hasIngestManager && pkgInfo) { + return ( + <> + + +

+ + + + ), + availableAsIntegrationLink: ( + + + + ), + blogPostLink: ( + + + + ), + }} + /> +

+
+ + ); + } + + return null; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 623df428b7dd92..94d3379f35e051 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -22,7 +22,7 @@ import { PAGE_ROUTING_PATHS } from './constants'; import { DefaultLayout, WithoutHeaderLayout } from './layouts'; import { Loading, Error } from './components'; import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp, DataStreamApp } from './sections'; -import { DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; +import { DepsContext, ConfigContext, useConfig } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { useCore, sendSetup, sendGetPermissionsCheck } from './hooks'; import { FleetStatusProvider } from './hooks/use_fleet_status'; @@ -260,7 +260,6 @@ export function renderApp( startDeps: IngestManagerStartDeps, config: IngestManagerConfigType ) { - setHttpClient(coreStart.http); ReactDOM.render( { group: 'global', expectManageSpaces: true, expectGetFeatures: true, + expectEnterpriseSearch: true, }, { group: 'space', expectManageSpaces: false, expectGetFeatures: false, + expectEnterpriseSearch: false, }, -].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { +].forEach(({ group, expectManageSpaces, expectGetFeatures, expectEnterpriseSearch }) => { describe(`${group}`, () => { test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ @@ -256,6 +258,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), @@ -450,6 +453,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -514,6 +518,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -579,6 +584,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -840,6 +846,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.ui.get('foo', 'foo'), ]); expect(actual).toHaveProperty('global.read', [ @@ -991,6 +998,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1189,6 +1197,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1315,6 +1324,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1477,6 +1487,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1592,6 +1603,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 5a15290a7f1a29..f9ee5fc7501275 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -101,6 +101,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ...allActions, ], read: [actions.login, actions.version, ...readActions], diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 517b8478556478..b5c4e6481216c1 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiBreadcrumbs, Breadcrumb, EuiCode } from '@elastic/eui'; +import { EuiBreadcrumbs, Breadcrumb, EuiCode, EuiBetaBadge } from '@elastic/eui'; import styled from 'styled-components'; import React, { memo } from 'react'; import { useResolverTheme } from '../assets'; @@ -19,6 +19,10 @@ export const BoldCode = styled(EuiCode)` } `; +const BetaHeader = styled(`header`)` + margin-bottom: 1em; +`; + /** * The two query parameters we read/write on to control which view the table presents: */ @@ -40,6 +44,13 @@ const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: str } `; +const betaBadgeLabel = i18n.translate( + 'xpack.securitySolution.enpdoint.resolver.panelutils.betaBadgeLabel', + { + defaultMessage: 'BETA', + } +); + /** * Breadcrumb menu with adjustments per direction from UX team */ @@ -54,12 +65,17 @@ export const StyledBreadcrumbs = memo(function StyledBreadcrumbs({ colorMap: { resolverBreadcrumbBackground, resolverEdgeText }, } = useResolverTheme(); return ( - + <> + + + + + ); }); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index babd25dd3ec4b2..797d7fd1bdcc4e 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -59,6 +59,27 @@ const features = ([ }, }, }, + { + // feature 4 intentionally delcares the same items as feature 3 + id: 'feature_4', + name: 'Feature 4', + navLinkId: 'feature3', + app: ['feature3', 'feature3_app'], + catalogue: ['feature3Entry'], + management: { + kibana: ['indices'], + }, + privileges: { + all: { + app: [], + ui: [], + savedObject: { + all: [], + read: [], + }, + }, + }, + }, ] as unknown) as Feature[]; const buildCapabilities = () => @@ -73,6 +94,7 @@ const buildCapabilities = () => catalogue: { discover: true, visualize: false, + feature3Entry: true, }, management: { kibana: { @@ -217,11 +239,38 @@ describe('capabilitiesSwitcher', () => { expect(result).toEqual(expectedCapabilities); }); + it('does not disable catalogue, management, or app entries when they are shared with an enabled feature', async () => { + const space: Space = { + id: 'space', + name: '', + disabledFeatures: ['feature_3'], + }; + + const capabilities = buildCapabilities(); + + const { switcher } = setup(space); + const request = httpServerMock.createKibanaRequest(); + const result = await switcher(request, capabilities); + + const expectedCapabilities = buildCapabilities(); + + // These capabilities are shared by feature_4, which is enabled + expectedCapabilities.navLinks.feature3 = true; + expectedCapabilities.navLinks.feature3_app = true; + expectedCapabilities.catalogue.feature3Entry = true; + expectedCapabilities.management.kibana.indices = true; + // These capabilities are only exposed by feature_3, which is disabled + expectedCapabilities.feature_3.bar = false; + expectedCapabilities.feature_3.foo = false; + + expect(result).toEqual(expectedCapabilities); + }); + it('can disable everything', async () => { const space: Space = { id: 'space', name: '', - disabledFeatures: ['feature_1', 'feature_2', 'feature_3'], + disabledFeatures: ['feature_1', 'feature_2', 'feature_3', 'feature_4'], }; const capabilities = buildCapabilities(); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 05d04295964892..00e2419136f488 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -54,22 +54,38 @@ function toggleDisabledFeatures( ) { const disabledFeatureKeys = activeSpace.disabledFeatures; - const disabledFeatures = disabledFeatureKeys - .map((key) => features.find((feature) => feature.id === key)) - .filter((feature) => typeof feature !== 'undefined') as Feature[]; + const [enabledFeatures, disabledFeatures] = features.reduce( + (acc, feature) => { + if (disabledFeatureKeys.includes(feature.id)) { + return [acc[0], [...acc[1], feature]]; + } + return [[...acc[0], feature], acc[1]]; + }, + [[], []] as [Feature[], Feature[]] + ); const navLinks = capabilities.navLinks; const catalogueEntries = capabilities.catalogue; const managementItems = capabilities.management; + const enabledAppEntries = new Set(enabledFeatures.flatMap((ef) => ef.app ?? [])); + const enabledCatalogueEntries = new Set(enabledFeatures.flatMap((ef) => ef.catalogue ?? [])); + const enabledManagementEntries = enabledFeatures.reduce((acc, feature) => { + const sections = Object.entries(feature.management ?? {}); + sections.forEach((section) => { + if (!acc.has(section[0])) { + acc.set(section[0], []); + } + acc.get(section[0])!.push(...section[1]); + }); + return acc; + }, new Map()); + for (const feature of disabledFeatures) { // Disable associated navLink, if one exists - if (feature.navLinkId && navLinks.hasOwnProperty(feature.navLinkId)) { - navLinks[feature.navLinkId] = false; - } - - feature.app.forEach((app) => { - if (navLinks.hasOwnProperty(app)) { + const featureNavLinks = feature.navLinkId ? [feature.navLinkId, ...feature.app] : feature.app; + featureNavLinks.forEach((app) => { + if (navLinks.hasOwnProperty(app) && !enabledAppEntries.has(app)) { navLinks[app] = false; } }); @@ -77,18 +93,24 @@ function toggleDisabledFeatures( // Disable associated catalogue entries const privilegeCatalogueEntries = feature.catalogue || []; privilegeCatalogueEntries.forEach((catalogueEntryId) => { - catalogueEntries[catalogueEntryId] = false; + if (!enabledCatalogueEntries.has(catalogueEntryId)) { + catalogueEntries[catalogueEntryId] = false; + } }); // Disable associated management items const privilegeManagementSections = feature.management || {}; Object.entries(privilegeManagementSections).forEach(([sectionId, sectionItems]) => { sectionItems.forEach((item) => { + const enabledManagementEntriesSection = enabledManagementEntries.get(sectionId); if ( managementItems.hasOwnProperty(sectionId) && managementItems[sectionId].hasOwnProperty(item) ) { - managementItems[sectionId][item] = false; + const isEnabledElsewhere = (enabledManagementEntriesSection ?? []).includes(item); + if (!isEnabledElsewhere) { + managementItems[sectionId][item] = false; + } } }); }); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 13d7c62316040b..1ea16a2a9940c9 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -7,6 +7,40 @@ } } }, + "app_search": { + "properties": { + "ui_viewed": { + "properties": { + "setup_guide": { + "type": "long" + }, + "engines_overview": { + "type": "long" + } + } + }, + "ui_error": { + "properties": { + "cannot_connect": { + "type": "long" + } + } + }, + "ui_clicked": { + "properties": { + "create_first_engine_button": { + "type": "long" + }, + "header_launch_button": { + "type": "long" + }, + "engine_table_link": { + "type": "long" + } + } + } + } + }, "fileUploadTelemetry": { "properties": { "filesUploadedTotalCount": { diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 29be6d826c1bc4..ee8af9e040401a 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -53,6 +53,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting_api_integration/config.js'), require.resolve('../test/functional_embedded/config.ts'), require.resolve('../test/ingest_manager_api_integration/config.ts'), + require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 11fb9b2de71991..df6eca795f8019 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { 'visualize', 'dashboard', 'dev_tools', + 'enterpriseSearch', 'advancedSettings', 'indexPatterns', 'timelion', diff --git a/x-pack/test/functional/config.ie.js b/x-pack/test/functional/config.ie.js deleted file mode 100644 index 1289bb723cfec1..00000000000000 --- a/x-pack/test/functional/config.ie.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export default async function ({ readConfigFile }) { - const defaultConfig = await readConfigFile(require.resolve('./config')); - - return { - ...defaultConfig.getAll(), - //csp.strict: false - // testFiles: [ - // require.resolve(__dirname, './apps/advanced_settings'), - // require.resolve(__dirname, './apps/canvas'), - // require.resolve(__dirname, './apps/graph'), - // require.resolve(__dirname, './apps/monitoring'), - // require.resolve(__dirname, './apps/watcher'), - // require.resolve(__dirname, './apps/dashboard'), - // require.resolve(__dirname, './apps/dashboard_mode'), - // require.resolve(__dirname, './apps/discover'), - // require.resolve(__dirname, './apps/security'), - // require.resolve(__dirname, './apps/spaces'), - // require.resolve(__dirname, './apps/lens'), - // require.resolve(__dirname, './apps/logstash'), - // require.resolve(__dirname, './apps/grok_debugger'), - // require.resolve(__dirname, './apps/infra'), - // require.resolve(__dirname, './apps/ml'), - // require.resolve(__dirname, './apps/rollup_job'), - // require.resolve(__dirname, './apps/maps'), - // require.resolve(__dirname, './apps/status_page'), - // require.resolve(__dirname, './apps/timelion'), - // require.resolve(__dirname, './apps/upgrade_assistant'), - // require.resolve(__dirname, './apps/visualize'), - // require.resolve(__dirname, './apps/uptime'), - // require.resolve(__dirname, './apps/saved_objects_management'), - // require.resolve(__dirname, './apps/dev_tools'), - // require.resolve(__dirname, './apps/apm'), - // require.resolve(__dirname, './apps/index_patterns'), - // require.resolve(__dirname, './apps/index_management'), - // require.resolve(__dirname, './apps/index_lifecycle_management'), - // require.resolve(__dirname, './apps/snapshot_restore'), - // require.resolve(__dirname, './apps/cross_cluster_replication'), - // require.resolve(__dirname, './apps/remote_clusters'), - // // This license_management file must be last because it is destructive. - // require.resolve(__dirname, './apps/license_management'), - // ], - - browser: { - type: 'ie', - }, - - junit: { - reportName: 'Internet Explorer UI Functional X-Pack Tests', - }, - - uiSettings: { - defaults: { - 'accessibility:disableAnimations': true, - 'dateFormat:tz': 'UTC', - 'state:storeInSessionStorage': true, - }, - }, - - kbnTestServer: { - ...defaultConfig.get('kbnTestServer'), - serverArgs: [ - ...defaultConfig.get('kbnTestServer.serverArgs'), - '--csp.strict=false', - '--telemetry.optIn=false', - ], - }, - }; -} diff --git a/x-pack/test/functional_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md new file mode 100644 index 00000000000000..63d13cbac7020c --- /dev/null +++ b/x-pack/test/functional_enterprise_search/README.md @@ -0,0 +1,41 @@ +# Enterprise Search Functional E2E Tests + +## Running these tests + +Follow the [Functional Test Runner instructions](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html#_running_functional_tests). + +There are two suites available to run, a suite that requires a Kibana instance without an `enterpriseSearch.host` +configured, and one that does. The later also [requires a running Enterprise Search instance](#enterprise-search-requirement), and a Private API key +from that instance set in an Environment variable. + +Ex. + +```sh +# Run specs from the x-pack directory +cd x-pack + +# Run tests that do not require enterpriseSearch.host variable +node scripts/functional_tests --config test/functional_enterprise_search/without_host_configured.config.ts + +# Run tests that require enterpriseSearch.host variable +APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/with_host_configured.config.ts +``` + +## Enterprise Search Requirement + +The `with_host_configured` tests will not currently start an instance of App Search automatically. As such, they are not run as part of CI and are most useful for local regression testing. + +The easiest way to start Enterprise Search for these tests is to check out the `ent-search` project +and use the following script. + +```sh +cd script/stack_scripts +/start-with-license-and-expiration.sh platinum 500000 +``` + +Requirements for Enterprise Search: + +- Running on port 3002 against a separate Elasticsearch cluster. +- Elasticsearch must have a platinum or greater level license (or trial). +- Must have Standard or Native Auth configured with an `enterprise_search` user with password `changeme`. +- There should be NO existing Engines or Meta Engines. diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts new file mode 100644 index 00000000000000..e4ebd61c0692a2 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { EsArchiver } from 'src/es_archiver'; +import { AppSearchService, IEngine } from '../../../../services/app_search_service'; +import { Browser } from '../../../../../../../test/functional/services/common'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupEnginesTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver') as EsArchiver; + const browser = getService('browser') as Browser; + const retry = getService('retry'); + const appSearch = getService('appSearch') as AppSearchService; + + const PageObjects = getPageObjects(['appSearch', 'security']); + + describe('Engines Overview', function () { + let engine1: IEngine; + let engine2: IEngine; + let metaEngine: IEngine; + + before(async () => { + await esArchiver.load('empty_kibana'); + engine1 = await appSearch.createEngine(); + engine2 = await appSearch.createEngine(); + metaEngine = await appSearch.createMetaEngine([engine1.name, engine2.name]); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + appSearch.destroyEngine(engine1.name); + appSearch.destroyEngine(engine2.name); + appSearch.destroyEngine(metaEngine.name); + }); + + describe('when an enterpriseSearch.host is configured', () => { + it('navigating to the enterprise_search plugin will redirect a user to the App Search Engines Overview page', async () => { + await PageObjects.security.forceLogout(); + const { user, password } = appSearch.getEnterpriseSearchUser(); + await PageObjects.security.login(user, password, { + expectSpaceSelector: false, + }); + + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search'); + }); + }); + + it('lists engines', async () => { + const engineLinks = await PageObjects.appSearch.getEngineLinks(); + const engineLinksText = await Promise.all(engineLinks.map((l) => l.getVisibleText())); + + expect(engineLinksText.includes(engine1.name)).to.equal(true); + expect(engineLinksText.includes(engine2.name)).to.equal(true); + }); + + it('lists meta engines', async () => { + const metaEngineLinks = await PageObjects.appSearch.getMetaEngineLinks(); + const metaEngineLinksText = await Promise.all( + metaEngineLinks.map((l) => l.getVisibleText()) + ); + expect(metaEngineLinksText.includes(metaEngine.name)).to.equal(true); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts new file mode 100644 index 00000000000000..ac4984e0db0190 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + loadTestFile(require.resolve('./app_search/engines')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts new file mode 100644 index 00000000000000..1d478c6baf29cb --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupGuideTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const retry = getService('retry'); + + const PageObjects = getPageObjects(['appSearch']); + + describe('Setup Guide', function () { + before(async () => await esArchiver.load('empty_kibana')); + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('when no enterpriseSearch.host is configured', () => { + it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => { + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search/setup_guide'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts new file mode 100644 index 00000000000000..31a92e752fcf4e --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + this.tags('ciGroup10'); + + loadTestFile(require.resolve('./app_search/setup_guide')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/base_config.ts b/x-pack/test/functional_enterprise_search/base_config.ts new file mode 100644 index 00000000000000..f737b6cd4b5f45 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/base_config.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackFunctionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + // default to the xpack functional config + ...xPackFunctionalConfig.getAll(), + services, + pageObjects, + }; +} diff --git a/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..bb257cdcbfe1b5 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts new file mode 100644 index 00000000000000..d845a1935a1496 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; +import { TestSubjects } from '../../../../test/functional/services/common'; +import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; + +export function AppSearchPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common']); + const testSubjects = getService('testSubjects') as TestSubjects; + + return { + async navigateToPage(): Promise { + return await PageObjects.common.navigateToApp('enterprise_search/app_search'); + }, + + async getEngineLinks(): Promise { + const engines = await testSubjects.find('appSearchEngines'); + return await testSubjects.findAllDescendant('engineNameLink', engines); + }, + + async getMetaEngineLinks(): Promise { + const metaEngines = await testSubjects.find('appSearchMetaEngines'); + return await testSubjects.findAllDescendant('engineNameLink', metaEngines); + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts new file mode 100644 index 00000000000000..009fb264824195 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pageObjects as basePageObjects } from '../../functional/page_objects'; +import { AppSearchPageProvider } from './app_search'; + +export const pageObjects = { + ...basePageObjects, + appSearch: AppSearchPageProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts new file mode 100644 index 00000000000000..fbd15b83f97ea7 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import http from 'http'; + +/** + * A simple request client for making API calls to the App Search API + */ +const makeRequest = (method: string, path: string, body?: object): Promise => { + return new Promise(function (resolve, reject) { + const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY; + + if (!APP_SEARCH_API_KEY) { + throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.'); + } + + let postData; + + if (body) { + postData = JSON.stringify(body); + } + + const req = http.request( + { + method, + hostname: 'localhost', + port: 3002, + path, + agent: false, // Create a new agent just for this one request + headers: { + Authorization: `Bearer ${APP_SEARCH_API_KEY}`, + 'Content-Type': 'application/json', + ...(!!postData && { 'Content-Length': Buffer.byteLength(postData) }), + }, + }, + (res) => { + const bodyChunks: Uint8Array[] = []; + res.on('data', function (chunk) { + bodyChunks.push(chunk); + }); + + res.on('end', function () { + let responseBody; + try { + responseBody = JSON.parse(Buffer.concat(bodyChunks).toString()); + } catch (e) { + reject(e); + } + + if (res.statusCode && res.statusCode > 299) { + reject('Error calling App Search API: ' + JSON.stringify(responseBody)); + } + + resolve(responseBody); + }); + } + ); + + req.on('error', (e) => { + reject(e); + }); + + if (postData) { + req.write(postData); + } + req.end(); + }); +}; + +export interface IEngine { + name: string; +} + +export const createEngine = async (engineName: string): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { name: engineName }); +}; + +export const destroyEngine = async (engineName: string): Promise => { + return await makeRequest('DELETE', `/api/as/v1/engines/${engineName}`); +}; + +export const createMetaEngine = async ( + engineName: string, + sourceEngines: string[] +): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { + name: engineName, + type: 'meta', + source_engines: sourceEngines, + }); +}; + +export interface ISearchResponse { + results: object[]; +} + +const search = async (engineName: string): Promise => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/search`, { query: '' }); +}; + +// Since the App Search API does not issue document receipts, the only way to tell whether or not documents +// are fully indexed is to poll the search endpoint. +export const waitForIndexedDocs = (engineName: string) => { + return new Promise(async function (resolve) { + let isReady = false; + while (!isReady) { + const response = await search(engineName); + if (response.results && response.results.length > 0) { + isReady = true; + resolve(); + } + } + }); +}; + +export const indexData = async (engineName: string, docs: object[]) => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/documents`, docs); +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_service.ts b/x-pack/test/functional_enterprise_search/services/app_search_service.ts new file mode 100644 index 00000000000000..9a43783402f4b3 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_service.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +const ENTERPRISE_SEARCH_USER = 'enterprise_search'; +const ENTERPRISE_SEARCH_PASSWORD = 'changeme'; +import { + createEngine, + createMetaEngine, + indexData, + waitForIndexedDocs, + destroyEngine, + IEngine, +} from './app_search_client'; + +export interface IUser { + user: string; + password: string; +} +export { IEngine }; + +export class AppSearchService { + getEnterpriseSearchUser(): IUser { + return { + user: ENTERPRISE_SEARCH_USER, + password: ENTERPRISE_SEARCH_PASSWORD, + }; + } + + createEngine(): Promise { + const engineName = `test-engine-${new Date().getTime()}`; + return createEngine(engineName); + } + + async createEngineWithDocs(): Promise { + const engine = await this.createEngine(); + const docs = [ + { id: 1, name: 'doc1' }, + { id: 2, name: 'doc2' }, + { id: 3, name: 'doc2' }, + ]; + await indexData(engine.name, docs); + await waitForIndexedDocs(engine.name); + return engine; + } + + createMetaEngine(sourceEngines: string[]): Promise { + const engineName = `test-meta-engine-${new Date().getTime()}`; + return createMetaEngine(engineName, sourceEngines); + } + + destroyEngine(engineName: string) { + return destroyEngine(engineName); + } +} + +export async function AppSearchServiceProvider({ getService }: FtrProviderContext) { + const lifecycle = getService('lifecycle'); + const security = getService('security'); + + lifecycle.beforeTests.add(async () => { + // The App Search plugin passes through the current user name and password + // through on the API call to App Search. Therefore, we need to be signed + // in as the enterprise_search user in order for this plugin to work. + await security.user.create(ENTERPRISE_SEARCH_USER, { + password: ENTERPRISE_SEARCH_PASSWORD, + roles: ['kibana_admin'], + full_name: ENTERPRISE_SEARCH_USER, + }); + }); + + return new AppSearchService(); +} diff --git a/x-pack/test/functional_enterprise_search/services/index.ts b/x-pack/test/functional_enterprise_search/services/index.ts new file mode 100644 index 00000000000000..1715c98677ac6f --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as functionalServices } from '../../functional/services'; +import { AppSearchServiceProvider } from './app_search_service'; + +export const services = { + ...functionalServices, + appSearch: AppSearchServiceProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/with_host_configured.config.ts b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts new file mode 100644 index 00000000000000..f425f806f4bcd5 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/with_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests with Host Configured', + }, + + kbnTestServer: { + ...baseConfig.get('kbnTestServer'), + serverArgs: [ + ...baseConfig.get('kbnTestServer.serverArgs'), + '--enterpriseSearch.host=http://localhost:3002', + ], + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/without_host_configured.config.ts b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts new file mode 100644 index 00000000000000..0f2afd214abedf --- /dev/null +++ b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/without_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests without Host Configured', + }, + }; +} diff --git a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js deleted file mode 100644 index 933a59e4e25b90..00000000000000 --- a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export default async ({ readConfigFile }) => { - const baseConfigs = await readConfigFile( - require.resolve('./config.stack_functional_integration_base.js') - ); - return { - ...baseConfigs.getAll(), - browser: { - type: 'ie', - }, - security: { disableTestUser: true }, - }; -}; diff --git a/x-pack/test/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts index 405ef4dbdc5b1d..b20a499ba7e20d 100644 --- a/x-pack/test/ui_capabilities/common/nav_links_builder.ts +++ b/x-pack/test/ui_capabilities/common/nav_links_builder.ts @@ -15,6 +15,10 @@ export class NavLinksBuilder { management: { navLinkId: 'kibana:stack_management', }, + // TODO: Temp until navLinkIds fix is merged in + appSearch: { + navLinkId: 'appSearch', + }, }; } diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index f8f3f2be2b2ec1..0e0d46c6ce2cd2 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -32,17 +32,27 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'global_all at everything_space': - case 'dual_privileges_all at everything_space': + case 'dual_privileges_all at everything_space': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'everything_space_all at everything_space': case 'global_read at everything_space': case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml and monitoring and enterprise search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 10ecf5d25d3469..08a7d789153e77 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -38,14 +38,20 @@ export default function navLinksTests({ getService }: FtrProviderContext) { break; case 'global_all at everything_space': case 'dual_privileges_all at everything_space': - case 'dual_privileges_read at everything_space': - case 'global_read at everything_space': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring') + ); + break; case 'everything_space_all at everything_space': + case 'global_read at everything_space': + case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring') + navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch') ); break; case 'superuser at nothing_space': diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 52a1f30147b4ff..99f91407dc1d2b 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -32,9 +32,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'all': - case 'read': - case 'dual_privileges_all': - case 'dual_privileges_read': { + case 'dual_privileges_all': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring is enabled @@ -45,6 +43,18 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } + case 'read': + case 'dual_privileges_read': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring and enterprise search is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'foo_all': case 'foo_read': { expect(uiCapabilities.success).to.be(true); diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index fe9ffa9286de83..d3bd2e1afd357c 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -37,15 +37,21 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.all()); break; case 'all': - case 'read': case 'dual_privileges_all': - case 'dual_privileges_read': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( navLinksBuilder.except('ml', 'monitoring') ); break; + case 'read': + case 'dual_privileges_read': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring', 'appSearch') + ); + break; case 'foo_all': case 'foo_read': expect(uiCapabilities.success).to.be(true);