From f7b5144e1d991e1b679dbeed168a41648c571dd0 Mon Sep 17 00:00:00 2001 From: Constance <constancecchen@users.noreply.github.com> Date: Thu, 9 Jul 2020 13:10:31 -0700 Subject: [PATCH] New Enterprise Search Kibana plugin (#66922) * Initial App Search in Kibana plugin work - Initializes a new platform plugin that ships out of the box w/ x-pack - Contains a very basic front-end that shows AS engines, error states, or a Setup Guide - Contains a very basic server that remotely calls the AS internal engines API and returns results * Update URL casing to match Kibana best practices - URL casing appears to be snake_casing, but kibana.json casing appears to be camelCase * Register App Search plugin in Home Feature Catalogue * Add custom App Search in Kibana logo - I haven't had much success in surfacing a SVG file via a server-side endpoint/URL, but then I realized EuiIcon supports passing in a ReactElement directly. Woo! * Fix appSearch.host config setting to be optional - instead of crashing folks on load * Rename plugin to Enterprise Search - per product decision, URL should be enterprise_search/app_search and Workplace Search should also eventually live here - reorganize folder structure in anticipation for another workplace_search plugin/codebase living alongside app_search - rename app.tsx/main.tsx to a standard top-level index.tsx (which will contain top-level routes/state) - rename AS->ES files/vars where applicable - TODO: React Router * Set up React Router URL structure * Convert showSetupGuide action/flag to a React Router link - remove showSetupGuide flag - add a new shared helper component for combining EuiButton/EuiLink with React Router behavior (https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51) * Implement Kibana Chrome breadcrumbs - create shared helper (WS will presumably also want this) for generating EUI breadcrumb objects with React Router links+click behavior - create React component that calls chrome.setBreadcrumbs on page mount - clean up type definitions - move app-wide props to IAppSearchProps and update most pages/views to simply import it instead of calling their own definitions * Added server unit tests (#2) * Added unit test for server * PR Feedback * Refactor top-level Kibana props to a global context state - rather them passing them around verbosely as props, the components that need them should be able to call the useContext hook + Remove IAppSearchProps in favor of IKibanaContext + Also rename `appSearchUrl` to `enterpriseSearchUrl`, since this context will contained shared/Kibana-wide values/actions useful to both AS and WS * Added unit tests for public (#4) * application.test.ts * Added Unit Test for EngineOverviewHeader * Added Unit Test for generate_breadcrumbs * Added Unit Test for set_breadcrumb.tsx * Added a unit test for link_events - Also changed link_events.tsx to link_events.ts since it's just TS, no React - Modified letBrowserHandleEvent so it will still return a false boolean when target is blank * Betterize these tests Co-Authored-By: Constance <constancecchen@users.noreply.github.com> Co-authored-by: Constance <constancecchen@users.noreply.github.com> * Add UI telemetry tracking to AS in Kibana (#5) * Set up Telemetry usageCollection, savedObjects, route, & shared helper - The Kibana UsageCollection plugin handles collecting our telemetry UI data (views, clicks, errors, etc.) and pushing it to elastic's telemetry servers - That data is stored in incremented in Kibana's savedObjects lib/plugin (as well as mapped) - When an end-user hits a certain view or action, the shared helper will ping the app search telemetry route which increments the savedObject store * Update client-side views/links to new shared telemetry helper * Write tests for new telemetry files * Implement remaining unit tests (#7) * Write tests for React Router+EUI helper components * Update generate_breadcrumbs test - add test suite for generateBreadcrumb() itself (in order to cover a missing branch) - minor lint fixes - remove unnecessary import from set_breadcrumbs test * Write test for get_username util + update test to return a more consistent falsey value (null) * Add test for SetupGuide * [Refactor] Pull out various Kibana context mocks into separate files - I'm creating a reusable useContext mock for shallow()ed enzyme components + add more documentation comments + examples * Write tests for empty state components + test new usecontext shallow mock * Empty state components: Add extra getUserName branch test * Write test for app search index/routes * Write tests for engine overview table + fix bonus bug * Write Engine Overview tests + Update EngineOverview logic to account for issues found during tests :) - Move http to async/await syntax instead of promise syntax (works better with existing HttpServiceMock jest.fn()s) - hasValidData wasn't strict enough in type checking/object nest checking and was causing the app itself to crash (no bueno) * Refactor EngineOverviewHeader test to use shallow + to full coverage - missed adding this test during telemetry work - switching to shallow and beforeAll reduces the test time from 5s to 4s! * [Refactor] Pull out React Router history mocks into a test util helper + minor refactors/updates * Add small tests to increase branch coverage - mostly testing fallbacks or removing fallbacks in favor of strict type interface - these are slightly obsessive so I'd also be fine ditching them if they aren't terribly valuable * Address larger tech debt/TODOs (#8) * Fix optional chaining TODO - turns out my local Prettier wasn't up to date, completely my bad * Fix constants TODO - adds a common folder/architecture for others to use in the future * Remove TODO for eslint-disable-line and specify lint rule being skipped - hopefully that's OK for review, I can't think of any other way to sanely do this without re-architecting the entire file or DDoSing our API * Add server-side logging to route dependencies + add basic example of error catching/logging to Telemetry route + [extra] refactor mockResponseFactory name to something slightly easier to read * Move more Engines Overview API logic/logging to server-side - handle data validation in the server-side - wrap server-side API in a try/catch to account for fetch issues - more correctly return 2xx/4xx statuses and more correctly deal with those responses in the front-end - Add server info/error/debug logs (addresses TODO) - Update tests + minor refactors/cleanup - remove expectResponseToBe200With helper (since we're now returning multiple response types) and instead make mockResponse var name more readable - one-line header auth - update tests with example error logs - update schema validation for `type` to be an enum of `indexed`/`meta` (more accurately reflecting API) * Per telemetry team feedback, rename usageCollection telemetry mapping name to simpler 'app_search' - since their mapping already nests under 'kibana.plugins' - note: I left the savedObjects name with the '_telemetry' suffix, as there very well may be a use case for top-level generic 'app_search' saved objects * Update Setup Guide installation instructions (#9) Co-authored-by: Chris Cressman <chris@chriscressman.com> * [Refactor] DRY out route test helper * [Refactor] Rename public/test_utils to public/__mocks__ - to better follow/use jest setups and for .mock.ts suffixes * Add platinum licensing check to Meta Engines table/call (#11) * Licensing plugin setup * Add LicensingContext setup * Update EngineOverview to not hit meta engines API on platinum license * Add Jest test helpers for future shallow/context use * Update plugin to use new Kibana nav + URL update (#12) * Update new nav categories to add Enterprise Search + update plugin to use new category - per @johnbarrierwilson and Matt Riley, Enterprise Search should be under Kibana and above Observability - Run `node scripts/check_published_api_changes.js --accept` since this new category affects public API * [URL UPDATE] Change '/app/enterprise_search/app_search' to '/app/app_search' - This needs to be done because App Search and Workplace search *have* to be registered as separate plugins to have 2 distinct nav links - Currently Kibana doesn't support nested app names (see: https://github.com/elastic/kibana/issues/59190) but potentially will in the future - To support this change, we need to update applications/index.tsx to NOT handle '/app/enterprise_search' level routing, but instead accept an async imported app component (e.g. AppSearch, WorkplaceSearch). - AppSearch should now treat its router as root '/' instead of '/app_search' - (Addl) Per Josh Dover's recommendation, switch to `<Router history={params.history}>` from `<BrowserRouter basename={params.appBasePath}>` since they're deprecating appBasePath * Update breadcrumbs helper to account for new URLs - Remove path for Enterprise Search breadcrumb, since '/app/enterprise_search' will not link anywhere meaningful for the foreseeable future, so the Enterprise Search root should not go anywhere - Update App Search helper to go to root path, per new React Router setup Test changes: - Mock custom basepath for App Search tests - Swap enterpriseSearchBreadcrumbs and appSearchBreadcrumbs test order (since the latter overrides the default mock) * Add create_first_engine_button telemetry tracking to EmptyState * Switch plugin URLs back to /app/enterprise_search/app_search Now that https://github.com/elastic/kibana/pull/66455 has been merged in :tada: * Add i18n formatted messages / translations (#13) * Add i18n provider and formatted/i18n translated messages * Update tests to account for new I18nProvider context + FormattedMessage components - Add new mountWithContext helper that provides all contexts+providers used in top-level app - Add new shallowWithIntl helper for shallow() components that dive into FormattedMessage * Format i18n dates and numbers + update some mock tests to not throw react-intl invalid date messages * Update EngineOverviewHeader to disable button on prop * Address review feedback (#14) * Fix Prettier linting issues * Escape App Search API endpoint URLs - per PR feedback - querystring should automatically encodeURIComponent / escape query param strings * Update server plugin.ts to use getStartServices() rather than storing local references from start() - Per feedback: https://github.com/elastic/kibana/blob/master/src/core/CONVENTIONS.md#applications - Note: savedObjects.registerType needs to be outside of getStartServices, or an error is thrown - Side update to registerTelemetryUsageCollector to simplify args - Update/fix tests to account for changes * E2E testing (#6) * Wired up basics for E2E testing * Added version with App Search * Updated naming * Switched configuration around * Added concept of 'fixtures' * Figured out how to log in as the enterprise_search user * Refactored to use an App Search service * Added some real tests * Added a README * Cleanup * More cleanup * Error handling + README updatre * Removed unnecessary files * Apply suggestions from code review Co-authored-by: Constance <constancecchen@users.noreply.github.com> * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx Co-authored-by: Constance <constancecchen@users.noreply.github.com> * PR feedback - updated README * Additional lint fixes Co-authored-by: Constance <constancecchen@users.noreply.github.com> * Add README and CODEOWNERS (#15) * Add plugin README and CODEOWNERS * Fix Typescript errors (#16) * Fix public mocks * Fix empty states types * Fix engine table component errors * Fix engine overview component errors * Fix setup guide component errors - SetBreadcrumbs will be fixed in a separate commit * Fix App Search index errors * Fix engine overview header component errors * Fix applications context index errors * Fix kibana breadcrumb helper errors * Fix license helper errors * :exclamation: Refactor React Router EUI link/button helpers - in order to fix typescript errors - this changes the component logic significantly to a react render prop, so that the Link and Button components can have different types - however, end behavior should still remain the same * Fix telemetry helper errors * Minor unused var cleanup in plugin files * Fix telemetry collector/savedobjects errors * Fix MockRouter type errors and add IRouteDependencies export - routes will use IRouteDependencies in the next few commits * Fix engines route errors * Fix telemetry route errors * Remove any type from source code - thanks to Scotty for the inspiration * Add eslint rules for Enterprise Search plugin - Add checks for type any, but only on non-test files - Disable react-hooks/exhaustive-deps, since we're already disabling it in a few files and other plugins also have it turned off * Cover uncovered lines in engines_table and telemetry tests * Fixed TS warnings in E2E tests (#17) * Feedback: Convert static CSS values to EUI variables where possible * Feedback: Flatten nested CSS where possible - Prefer setting CSS class overrides on individual EUI components, not on a top-level page + Change CSS class casing from kebab-case to camelCase to better match EUI/Kibana + Remove unnecessary .euiPageContentHeader margin-bottom override by changing the panelPaddingSize of euiPageContent + Decrease engine overview table padding on mobile * Refactor out components shared with Workplace Search (#18) * Move getUserName helper to shared - in preparation for Workplace Search plugin also using this helper * Move Setup Guide layout to a shared component * Setup Guide: add extra props for standard/native auth links Note: It's possible this commit may be unnecessary if we can publish shared Enterprise Search security mode docs * Update copy per feedback from copy team * Address various telemetry issues - saved objects: removing indexing per #43673 - add schema and generate json per #64942 - move definitions over to collectors since saved objects is mostly empty at this point, and schema throws an error when it imports an obj instead of being defined inline - istanbul ignore saved_objects file since it doesn't have anything meaningful to test but was affecting code coverage * Disable plugin access if a normal user does not have access to App Search (#19) * Set up new server security dependency and configs * Set up access capabilities * Set up checkAccess helper/caller * Remove NoUserState component from the public UI - Since this is now being handled by checkAccess / normal users should never see the plugin at all if they don't have an account/access, the component is no longer needed * Update server routes to account for new changes - Remove login redirect catch from routes, since the access helper should now handle that for most users by disabling the plugin (superusers will see a generic cannot connect/error screen) - Refactor out new config values to a shared mock * Refactor Enterprise Search http call to hit/return new internal API endpoint + pull out the http call to a separate library for upcoming public URL work (so that other files can call it directly as well) * [Discussion] Increase timeout but add another warning timeout for slow servers - per recommendation/convo with Brandon * Register feature control * Remove no_as_account from UI telemetry - since we're no longer tracking that in the UI * Address PR feedback - isSuperUser check * Public URL support for Elastic Cloud (#21) * Add server-side public URL route - Per feedback from Kibana platform team, it's not possible to pass info from server/ to public/ without a HTTP call :[ * Update MockRouter for routes without any payload/params * Add client-side helper for calling the new public URL API + API seems to return a URL a trailing slash, which we need to omit * Update public/plugin.ts to check and set a public URL - relies on this.hasCheckedPublicUrl to only make the call once per page load instead of on every page nav * Fix failing feature control tests - Split up scenario cases as needed - Add plugin as an exception alongside ML & Monitoring * Address PR feedback - version: kibana - copy edits - Sass vars - code cleanup * Casing feedback: change all plugin registration IDs from snake_case to camelCase - note: current remainng snake_case exceptions are telemetry keys - file names and api endpoints are snake_case per conventions * Misc security feedback - remove set - remove unnecessary capabilities registration - telemetry namespace agnostic * Security feedback: add warn logging to telemetry collector see https://github.com/elastic/kibana/pull/66922#discussion_r451215760 - add if statement - pass log dependency around (this is kinda medium, should maybe refactor) - update tests - move test file comment to the right file (was meant for telemetry route file) * Address feedback from Pierre - Remove unnecessary ServerConfigType - Remove unnecessary uiCapabilities - Move registerTelemetryRoute / SavedObjectsServiceStart workaround - Remove unnecessary license optional chaining * PR feedback Address type/typos * Fix telemetry API call returning 415 on Chrome - I can't even?? I swear charset=utf-8 fixed the same error a few weeks ago * Fix failing tests * Update Enterprise Search functional tests (without host) to run on CI - Fix incorrect navigateToApp slug (hadn't realized this was a URL, not an ID) - Update without_host_configured tests to run without API key - Update README * Address PR feedback from Pierre - remove unnecessary authz? - remove unnecessary content-type json headers - add loggingSystemMock.collect(mockLogger).error assertion - reconstrcut new MockRouter on beforeEach for better sandboxing - fix incorrect describe()s -should be it() - pull out reusable mockDependencies helper (renamed/extended from mockConfig) for tests that don't particularly use config/log but still want to pass type definitions - Fix comment copy Co-authored-by: Jason Stoltzfus <jastoltz24@gmail.com> Co-authored-by: Chris Cressman <chris@chriscressman.com> Co-authored-by: scottybollinger <scotty.bollinger@elastic.co> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .eslintrc.js | 12 + .github/CODEOWNERS | 5 + .../collapsible_nav.test.tsx.snap | 6 +- src/core/public/public.api.md | 6 + src/core/server/server.api.md | 6 + src/core/utils/default_app_categories.ts | 12 +- x-pack/.i18nrc.json | 1 + x-pack/plugins/enterprise_search/README.md | 25 ++ .../enterprise_search/common/constants.ts | 7 + x-pack/plugins/enterprise_search/kibana.json | 10 + .../public/applications/__mocks__/index.ts | 13 + .../__mocks__/kibana_context.mock.ts | 17 ++ .../__mocks__/license_context.mock.ts | 11 + .../__mocks__/mount_with_context.mock.tsx | 49 ++++ .../__mocks__/react_router_history.mock.ts | 25 ++ .../__mocks__/shallow_usecontext.mock.ts | 40 ++++ .../__mocks__/shallow_with_i18n.mock.tsx | 30 +++ .../applications/app_search/assets/engine.svg | 3 + .../app_search/assets/getting_started.png | Bin 0 -> 92044 bytes .../applications/app_search/assets/logo.svg | 4 + .../app_search/assets/meta_engine.svg | 4 + .../components/empty_states/empty_state.tsx | 74 ++++++ .../components/empty_states/empty_states.scss | 19 ++ .../empty_states/empty_states.test.tsx | 53 ++++ .../components/empty_states/error_state.tsx | 95 ++++++++ .../components/empty_states/index.ts | 9 + .../components/empty_states/loading_state.tsx | 30 +++ .../engine_overview/engine_overview.scss | 27 +++ .../engine_overview/engine_overview.test.tsx | 171 +++++++++++++ .../engine_overview/engine_overview.tsx | 155 ++++++++++++ .../engine_overview/engine_table.test.tsx | 80 +++++++ .../engine_overview/engine_table.tsx | 153 ++++++++++++ .../components/engine_overview/index.ts | 7 + .../engine_overview_header.test.tsx | 41 ++++ .../engine_overview_header.tsx | 72 ++++++ .../engine_overview_header/index.ts | 7 + .../components/setup_guide/index.ts | 7 + .../setup_guide/setup_guide.test.tsx | 21 ++ .../components/setup_guide/setup_guide.tsx | 64 +++++ .../applications/app_search/index.test.tsx | 46 ++++ .../public/applications/app_search/index.tsx | 28 +++ .../public/applications/index.test.tsx | 40 ++++ .../public/applications/index.tsx | 56 +++++ .../get_enterprise_search_url.test.ts | 30 +++ .../get_enterprise_search_url.ts | 27 +++ .../shared/enterprise_search_url/index.ts | 7 + .../generate_breadcrumbs.test.ts | 206 ++++++++++++++++ .../generate_breadcrumbs.ts | 54 +++++ .../shared/kibana_breadcrumbs/index.ts | 9 + .../set_breadcrumbs.test.tsx | 63 +++++ .../kibana_breadcrumbs/set_breadcrumbs.tsx | 43 ++++ .../applications/shared/licensing/index.ts | 8 + .../shared/licensing/license_checks.test.ts | 33 +++ .../shared/licensing/license_checks.ts | 11 + .../shared/licensing/license_context.test.tsx | 24 ++ .../shared/licensing/license_context.tsx | 29 +++ .../react_router_helpers/eui_link.test.tsx | 77 ++++++ .../shared/react_router_helpers/eui_link.tsx | 57 +++++ .../shared/react_router_helpers/index.ts | 9 + .../react_router_helpers/link_events.test.ts | 102 ++++++++ .../react_router_helpers/link_events.ts | 31 +++ .../applications/shared/setup_guide/index.ts | 7 + .../shared/setup_guide/setup_guide.scss | 51 ++++ .../shared/setup_guide/setup_guide.test.tsx | 44 ++++ .../shared/setup_guide/setup_guide.tsx | 226 ++++++++++++++++++ .../applications/shared/telemetry/index.ts | 8 + .../shared/telemetry/send_telemetry.test.tsx | 56 +++++ .../shared/telemetry/send_telemetry.tsx | 50 ++++ .../plugins/enterprise_search/public/index.ts | 12 + .../enterprise_search/public/plugin.ts | 88 +++++++ .../collectors/app_search/telemetry.test.ts | 143 +++++++++++ .../server/collectors/app_search/telemetry.ts | 156 ++++++++++++ .../plugins/enterprise_search/server/index.ts | 29 +++ .../server/lib/check_access.test.ts | 128 ++++++++++ .../server/lib/check_access.ts | 76 ++++++ .../lib/enterprise_search_config_api.test.ts | 111 +++++++++ .../lib/enterprise_search_config_api.ts | 78 ++++++ .../enterprise_search/server/plugin.ts | 121 ++++++++++ .../server/routes/__mocks__/index.ts | 8 + .../server/routes/__mocks__/router.mock.ts | 102 ++++++++ .../__mocks__/routerDependencies.mock.ts | 27 +++ .../server/routes/app_search/engines.test.ts | 160 +++++++++++++ .../server/routes/app_search/engines.ts | 59 +++++ .../routes/app_search/telemetry.test.ts | 108 +++++++++ .../server/routes/app_search/telemetry.ts | 50 ++++ .../enterprise_search/public_url.test.ts | 52 ++++ .../routes/enterprise_search/public_url.ts | 26 ++ .../saved_objects/app_search/telemetry.ts | 19 ++ .../privileges/privileges.test.ts | 14 +- .../authorization/privileges/privileges.ts | 1 + .../schema/xpack_plugins.json | 34 +++ x-pack/scripts/functional_tests.js | 1 + .../apis/features/features/features.ts | 1 + .../functional_enterprise_search/README.md | 41 ++++ .../app_search/engines.ts | 75 ++++++ .../with_host_configured/index.ts | 13 + .../app_search/setup_guide.ts | 36 +++ .../without_host_configured/index.ts | 15 ++ .../base_config.ts | 20 ++ .../ftr_provider_context.d.ts | 12 + .../page_objects/app_search.ts | 30 +++ .../page_objects/index.ts | 13 + .../services/app_search_client.ts | 121 ++++++++++ .../services/app_search_service.ts | 77 ++++++ .../services/index.ts | 13 + .../with_host_configured.config.ts | 31 +++ .../without_host_configured.config.ts | 23 ++ .../common/nav_links_builder.ts | 4 + .../security_and_spaces/tests/catalogue.ts | 16 +- .../security_and_spaces/tests/nav_links.ts | 12 +- .../security_only/tests/catalogue.ts | 16 +- .../security_only/tests/nav_links.ts | 10 +- 112 files changed, 4968 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/README.md create mode 100644 x-pack/plugins/enterprise_search/common/constants.ts create mode 100644 x-pack/plugins/enterprise_search/kibana.json create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx create mode 100644 x-pack/plugins/enterprise_search/public/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/plugin.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts create mode 100644 x-pack/plugins/enterprise_search/server/plugin.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts create mode 100644 x-pack/test/functional_enterprise_search/README.md create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/base_config.ts create mode 100644 x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/app_search.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/index.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_client.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_service.ts create mode 100644 x-pack/test/functional_enterprise_search/services/index.ts create mode 100644 x-pack/test/functional_enterprise_search/with_host_configured.config.ts create mode 100644 x-pack/test/functional_enterprise_search/without_host_configured.config.ts diff --git a/.eslintrc.js b/.eslintrc.js index 8d979dc0f8645..4425ad3a12659 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 4aab9943022d4..f053c6da9c29b 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/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 9fee7b50f371b..1cfded4dc7b8f 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 86e281a49b744..40fc3f977006f 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 efeafc9e68d35..95912c3af63e5 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 5708bcfeac31a..cc9bfb1db04d5 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/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 596ba17d343c0..d0055008eb9bf 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 0000000000000..8c316c848184b --- /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 0000000000000..c134131caba75 --- /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 0000000000000..9a2daefcd8c6e --- /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 0000000000000..14fde357a980a --- /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 0000000000000..fcfa1b0a21f13 --- /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 0000000000000..7c37ecc7cde1b --- /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 0000000000000..dfcda544459d4 --- /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(<Component />, { enterpriseSearchUrl: 'someOverride', license: {} }); + */ +export const mountWithContext = (children: React.ReactNode, context?: object) => { + return mount( + <I18nProvider> + <KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}> + <LicenseContext.Provider value={{ ...mockLicenseContext, ...context }}> + {children} + </LicenseContext.Provider> + </KibanaContext.Provider> + </I18nProvider> + ); +}; + +/** + * 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( + <KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}> + {children} + </KibanaContext.Provider> + ); +}; 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 0000000000000..fd422465d87f1 --- /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 0000000000000..767a52a75d1fb --- /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 0000000000000..ae7d0b09f9872 --- /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 <I18nProvider> which + * fixes "Could not find required `intl` object" console errors when running tests + * + * Example usage (should be the same as shallow()): + * + * const wrapper = shallowWithIntl(<Component />); + */ +export const shallowWithIntl = (children: React.ReactNode) => { + const context = { context: { intl } }; + + return shallow(<I18nProvider>{children}</I18nProvider>, 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 0000000000000..ceab918e92e70 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg @@ -0,0 +1,3 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M9.776 1.389a7.66 7.66 0 00-.725-.04v.001H9a7.65 7.65 0 00-.051 15.301v-.001H9a7.65 7.65 0 00.776-15.261zm-1.52 1.254a6.401 6.401 0 00.02 12.716l2.333-3.791a.875.875 0 00-.354-1.242l-3.07-1.534a2.125 2.125 0 01-.859-3.015l1.93-3.134zm1.489 12.714l1.929-3.134a2.125 2.125 0 00-.86-3.015l-3.07-1.534a.875.875 0 01-.353-1.242L9.724 2.64a6.401 6.401 0 01.02 12.717z" fill="#000"/> +</svg> 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 0000000000000000000000000000000000000000..4d988d14f0483c31045783b714eedde9baac92b9 GIT binary patch literal 92044 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�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 b{wDtaU9#Ss{&OGS00000NkvXXu0mjf=D5g< literal 0 HcmV?d00001 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 0000000000000..2284a425b5add --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg @@ -0,0 +1,4 @@ +<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M15.5 11.915L27 5.277 19.5.937a7.002 7.002 0 00-7 0l-8 4.62A7 7 0 001 11.62v9.237a7 7 0 003.5 6.062l7.5 4.33V17.979a7 7 0 013.5-6.063zM10 27.785v-9.808a9 9 0 014.5-7.793l8.503-4.91L18.5 2.67a5.003 5.003 0 00-5 0l-8 4.619a5 5 0 00-2.5 4.33v9.238a5 5 0 002.5 4.33l4.5 2.598z" fill="#343741" /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M18.409 13.55a7.09 7.09 0 011.035 1.711A6.93 6.93 0 0120 17.978v13.27l7.5-4.33a7 7 0 003.5-6.061v-9.238a6.992 6.992 0 00-1.587-4.422L18.409 13.55zm2.777.705A8.933 8.933 0 0122 17.978v9.807l4.5-2.598a5 5 0 002.5-4.33v-9.238c0-.588-.106-1.161-.303-1.7l-7.51 4.336z" fill="#017D73" /> +</svg> 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 0000000000000..4e01e9a0b34fb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg @@ -0,0 +1,4 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M4 9a5.002 5.002 0 005 5 5 5 0 10-5-5zm5.506 1.653L8.37 12.697a3.751 3.751 0 01-.003-7.394L7.402 7.04a1.625 1.625 0 00.519 2.142l1.465.976a.375.375 0 01.12.495zm1.092.607l-.777 1.4a3.751 3.751 0 00-.04-7.329L8.494 7.647a.375.375 0 00.12.495l1.465.976c.705.47.93 1.402.52 2.142z" fill="#000"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 1.375A5.125 5.125 0 001.375 6.5v5A5.125 5.125 0 006.5 16.625h5a5.125 5.125 0 005.125-5.125v-5A5.125 5.125 0 0011.5 1.375h-5zM2.625 6.5A3.875 3.875 0 016.5 2.625h5A3.875 3.875 0 0115.375 6.5v5a3.875 3.875 0 01-3.875 3.875h-5A3.875 3.875 0 012.625 11.5v-5z" fill="#000"/> + </svg> 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 0000000000000..9bb5cd3bffdf5 --- /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 ( + <EuiPage restrictWidth> + <SetBreadcrumbs isRoot /> + + <EuiPageBody> + <EngineOverviewHeader /> + <EuiPageContent className="emptyState"> + <EuiEmptyPrompt + className="emptyState__prompt" + iconType="eyeClosed" + title={ + <h2> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.emptyState.title" + defaultMessage="Create your first engine" + /> + </h2> + } + titleSize="l" + body={ + <p> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.emptyState.description1" + defaultMessage="An App Search engine stores the documents for your search experience." + /> + </p> + } + actions={ + <EuiButton iconType="popout" fill {...buttonProps}> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta" + defaultMessage="Create an engine" + /> + </EuiButton> + } + /> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; 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 0000000000000..01b0903add559 --- /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 0000000000000..12bf003564103 --- /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(<ErrorState />); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow(<EmptyState />); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(<EmptyState />); + 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(<LoadingState />); + + 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 0000000000000..d8eeff2aba1c6 --- /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 ( + <EuiPage restrictWidth> + <SetBreadcrumbs isRoot /> + <SendTelemetry action="error" metric="cannot_connect" /> + + <EuiPageBody> + <EngineOverviewHeader isButtonDisabled /> + <EuiPageContent className="emptyState"> + <EuiEmptyPrompt + className="emptyState__prompt" + iconType="alert" + iconColor="danger" + title={ + <h2> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.errorConnectingState.title" + defaultMessage="Unable to connect" + /> + </h2> + } + titleSize="l" + body={ + <> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.errorConnectingState.description1" + defaultMessage="We can’t establish a connection to App Search at the host URL: {enterpriseSearchUrl}" + values={{ + enterpriseSearchUrl: <EuiCode>{enterpriseSearchUrl}</EuiCode>, + }} + /> + </p> + <ol className="eui-textLeft"> + <li> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.errorConnectingState.description2" + defaultMessage="Ensure the host URL is configured correctly in {configFile}." + values={{ + configFile: <EuiCode>config/kibana.yml</EuiCode>, + }} + /> + </li> + <li> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.errorConnectingState.description3" + defaultMessage="Confirm that the App Search server is responsive." + /> + </li> + <li> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.errorConnectingState.description4" + defaultMessage="Review the Setup guide or check your server log for {pluginLog} log messages." + values={{ + pluginLog: <EuiCode>[enterpriseSearch][plugins]</EuiCode>, + }} + /> + </li> + </ol> + </> + } + actions={ + <EuiButton iconType="help" fill to="/setup_guide"> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.errorConnectingState.setupGuideCta" + defaultMessage="Review setup guide" + /> + </EuiButton> + } + /> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; 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 0000000000000..e92bf214c4cc7 --- /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 0000000000000..2be917c8df096 --- /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 ( + <EuiPage restrictWidth> + <SetBreadcrumbs isRoot /> + + <EuiPageBody> + <EngineOverviewHeader /> + <EuiPageContent className="emptyState"> + <EuiLoadingContent lines={5} /> + <EuiSpacer size="xxl" /> + <EuiLoadingContent lines={4} /> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; 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 0000000000000..2c7f7de6458e2 --- /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 0000000000000..4d2a2ea1df9aa --- /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( + <I18nProvider> + <KibanaContext.Provider value={{ http: {} }}> + <LicenseContext.Provider value={{ license: {} }}> + <EngineOverview /> + </LicenseContext.Provider> + </KibanaContext.Provider> + </I18nProvider> + ); + + // 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(<EngineOverview />, { 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 0000000000000..13d092a657d11 --- /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<React.SetStateAction<never[]>>; + setResultsTotal: React.Dispatch<React.SetStateAction<number>>; +} + +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 <ErrorState />; + if (isLoading) return <LoadingState />; + if (!engines.length) return <EmptyState />; + + return ( + <EuiPage restrictWidth className="engineOverview"> + <SetBreadcrumbs isRoot /> + <SendTelemetry action="viewed" metric="engines_overview" /> + + <EuiPageBody> + <EngineOverviewHeader /> + + <EuiPageContent panelPaddingSize="s" className="engineOverview__body"> + <EuiPageContentHeader> + <EuiTitle size="s"> + <h2> + <img src={EnginesIcon} alt="" className="engineIcon" /> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.enginesOverview.engines" + defaultMessage="Engines" + /> + </h2> + </EuiTitle> + </EuiPageContentHeader> + <EuiPageContentBody data-test-subj="appSearchEngines"> + <EngineTable + data={engines} + pagination={{ + totalEngines: enginesTotal, + pageIndex: enginesPage - 1, + onPaginate: setEnginesPage, + }} + /> + </EuiPageContentBody> + + {metaEngines.length > 0 && ( + <> + <EuiSpacer size="xl" /> + <EuiPageContentHeader> + <EuiTitle size="s"> + <h2> + <img src={MetaEnginesIcon} alt="" className="engineIcon" /> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines" + defaultMessage="Meta Engines" + /> + </h2> + </EuiTitle> + </EuiPageContentHeader> + <EuiPageContentBody data-test-subj="appSearchMetaEngines"> + <EngineTable + data={metaEngines} + pagination={{ + totalEngines: metaEnginesTotal, + pageIndex: metaEnginesPage - 1, + onPaginate: setMetaEnginesPage, + }} + /> + </EuiPageContentBody> + </> + )} + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; 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 0000000000000..46b6e61e352de --- /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( + <EngineTable + data={[ + { + name: 'test-engine', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + document_count: 99999, + field_count: 10, + }, + ]} + pagination={{ + totalEngines: 50, + pageIndex: 0, + onPaginate, + }} + /> + ); + 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( + <EngineTable data={[]} pagination={{ totalEngines: 0, pageIndex: 0, onPaginate: () => {} }} /> + ); + 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 0000000000000..1e58d820dc83b --- /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<IEngineTableProps> = ({ + 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<EuiBasicTableColumn<IEngineTableData>> = [ + { + field: 'name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), + render: (name: string) => ( + <EuiLink data-test-subj="engineNameLink" {...engineLinkProps(name)}> + {name} + </EuiLink> + ), + 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 + <FormattedDate value={new Date(dateString)} year="numeric" month="long" day="numeric" /> + ), + }, + { + field: 'document_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document Count', + } + ), + dataType: 'number', + render: (number: number) => <FormattedNumber value={number} />, + truncateText: true, + }, + { + field: 'field_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', + { + defaultMessage: 'Field Count', + } + ), + dataType: 'number', + render: (number: number) => <FormattedNumber value={number} />, + truncateText: true, + }, + { + field: 'name', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', + { + defaultMessage: 'Actions', + } + ), + dataType: 'string', + render: (name: string) => ( + <EuiLink {...engineLinkProps(name)}> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage" + defaultMessage="Manage" + /> + </EuiLink> + ), + align: 'right', + width: '100px', + }, + ]; + + return ( + <EuiBasicTable + items={data} + columns={columns} + pagination={{ + pageIndex, + pageSize: ENGINES_PAGE_SIZE, + totalItemCount: totalEngines, + hidePerPageOptions: true, + }} + onChange={({ page }: IOnChange) => { + 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 0000000000000..48b7645dc39e8 --- /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 0000000000000..2e49540270ef0 --- /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(<EngineOverviewHeader />); + expect(wrapper.find('h1')).toHaveLength(1); + }); + + it('renders a launch app search button that sends telemetry on click', () => { + const wrapper = shallow(<EngineOverviewHeader />); + 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(<EngineOverviewHeader isButtonDisabled />); + 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 0000000000000..9aafa8ec0380c --- /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<IEngineOverviewHeaderProps> = ({ + 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 ( + <EuiPageHeader> + <EuiPageHeaderSection> + <EuiTitle size="l"> + <h1> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.enginesOverview.title" + defaultMessage="Engine Overview" + /> + </h1> + </EuiTitle> + </EuiPageHeaderSection> + <EuiPageHeaderSection> + <EuiButton {...buttonProps}> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.productCta" + defaultMessage="Launch App Search" + /> + </EuiButton> + </EuiPageHeaderSection> + </EuiPageHeader> + ); +}; 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 0000000000000..2d37f037e21e5 --- /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 0000000000000..c367424d375f9 --- /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 0000000000000..82cc344d49632 --- /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(<SetupGuide />); + + 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 0000000000000..df278bf938a69 --- /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 = () => ( + <SetupGuideLayout + productName={i18n.translate('xpack.enterpriseSearch.appSearch.productName', { + defaultMessage: 'App Search', + })} + productEuiIcon="logoAppSearch" + standardAuthLink="https://swiftype.com/documentation/app-search/self-managed/security#standard" + elasticsearchNativeAuthLink="https://swiftype.com/documentation/app-search/self-managed/security#elasticsearch-native-realm" + > + <SetBreadcrumbs text="Setup Guide" /> + <SendTelemetry action="viewed" metric="setup_guide" /> + + <a + href="https://www.elastic.co/webinars/getting-started-with-elastic-app-search" + target="_blank" + rel="noopener noreferrer" + > + <img + className="setupGuide__thumbnail" + src={GettingStarted} + alt={i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', { + defaultMessage: + "Getting started with App Search - in this short video we'll guide you through how to get App Search up and running", + })} + width="1280" + height-="720" + /> + </a> + + <EuiTitle size="s"> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.setupGuide.description" + defaultMessage="Elastic App Search provides tools to design and deploy a powerful search to your websites and mobile applications." + /> + </p> + </EuiTitle> + <EuiSpacer size="m" /> + <EuiText> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.appSearch.setupGuide.notConfigured" + defaultMessage="App Search is not configured in your Kibana instance yet." + /> + </p> + </EuiText> + </SetupGuideLayout> +); 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 0000000000000..45e318ca0f9d9 --- /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(<AppSearch />); + + 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(<AppSearch />); + + expect(wrapper.find(EngineOverview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(<AppSearch />); + + 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 0000000000000..8f7142f1631a9 --- /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 ( + <> + <Route exact path="/"> + {!enterpriseSearchUrl ? <Redirect to="/setup_guide" /> : <EngineOverview />} + </Route> + <Route path="/setup_guide"> + <SetupGuide /> + </Route> + </> + ); +}; 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 0000000000000..1aead8468ca3b --- /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 = () => <div className="hello-world">Hello world!</div>; + + 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 0000000000000..4ef7aca8260a2 --- /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( + <I18nProvider> + <KibanaContext.Provider + value={{ + http: core.http, + enterpriseSearchUrl: config.host, + setBreadcrumbs: core.chrome.setBreadcrumbs, + }} + > + <LicenseProvider license$={plugins.licensing.license$}> + <Router history={params.history}> + <App /> + </Router> + </LicenseProvider> + </KibanaContext.Provider> + </I18nProvider>, + 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 0000000000000..42f308c554268 --- /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 0000000000000..419c187a0048a --- /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<string> => { + 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 0000000000000..bbbb688b8ea7b --- /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 0000000000000..7ea73577c4de6 --- /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 0000000000000..0e1bb796cbf2e --- /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 0000000000000..cf8bbbc593f2f --- /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 0000000000000..974ca54277c51 --- /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(<SetAppSearchBreadcrumbs {...props} />, { + 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 0000000000000..ad3cd65c09516 --- /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<IBreadcrumbProps | IRootBreadcrumbProps> = ({ + 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 0000000000000..9c8c1417d48db --- /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 0000000000000..ad134e7d36b10 --- /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 0000000000000..de4a17ce2bd3c --- /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 0000000000000..c65474ec1f590 --- /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 <div className="license-test">{license?.type}</div>; + }; + + it('renders children', () => { + const wrapper = mountWithContext(<MockComponent />, { 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 0000000000000..9b47959ff7544 --- /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<ILicense>; + children: React.ReactNode; +} + +export const LicenseContext = React.createContext({}); + +export const LicenseProvider: React.FC<ILicenseContextProps> = ({ license$, children }) => { + // Listen for changes to license subscription + const license = useObservable(license$); + + // Render rest of application and pass down license via context + return <LicenseContext.Provider value={{ license }} children={children} />; +}; 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 0000000000000..7d4c068b21155 --- /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(<EuiReactRouterLink to="/" />); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('renders an EuiButton', () => { + const wrapper = shallow(<EuiReactRouterButton to="/" />); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + + it('passes down all ...rest props', () => { + const wrapper = shallow(<EuiReactRouterLink to="/" data-test-subj="foo" external={true} />); + 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(<EuiReactRouterLink to="/foo/bar" />); + 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(<EuiReactRouterLink to="/bar/baz" />); + + 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(<EuiReactRouterLink to="/bar/baz" />); + + 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 0000000000000..f486e432bae76 --- /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<IEuiReactRouterProps> = ({ 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<TEuiReactRouterLinkProps> = ({ to, ...rest }) => ( + <EuiReactRouterHelper to={to}> + <EuiLink {...rest} /> + </EuiReactRouterHelper> +); + +export const EuiReactRouterButton: React.FC<TEuiReactRouterButtonProps> = ({ to, ...rest }) => ( + <EuiReactRouterHelper to={to}> + <EuiButton {...rest} /> + </EuiReactRouterHelper> +); 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 0000000000000..46dc328633153 --- /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 0000000000000..3682946b63a13 --- /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 0000000000000..93da2ab71d952 --- /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 0000000000000..c367424d375f9 --- /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 0000000000000..ecfa13cc828f0 --- /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 0000000000000..0423ae61779af --- /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( + <SetupGuide productName="Enterprise Search" productEuiIcon="logoEnterpriseSearch"> + <p data-test-subj="test">Wow!</p> + </SetupGuide> + ); + + 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( + <SetupGuide + productName="Foo" + productEuiIcon="logoAppSearch" + standardAuthLink="http://foo.com" + elasticsearchNativeAuthLink="http://bar.com" + > + Baz + </SetupGuide> + ); + + 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 0000000000000..31ff0089dbd7c --- /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<ISetupGuideProps> = ({ + children, + productName, + productEuiIcon, + standardAuthLink, + elasticsearchNativeAuthLink, +}) => ( + <EuiPage className="setupGuide"> + <EuiPageSideBar className="setupGuide__sidebar"> + <EuiText color="subdued" size="s"> + <strong> + <FormattedMessage + id="xpack.enterpriseSearch.setupGuide.title" + defaultMessage="Setup Guide" + /> + </strong> + </EuiText> + <EuiSpacer size="s" /> + + <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <EuiIcon type={productEuiIcon} size="l" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiTitle size="m"> + <h1>{productName}</h1> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + + {children} + </EuiPageSideBar> + + <EuiPageBody className="setupGuide__body"> + <EuiPageContent> + <EuiSteps + headingElement="h2" + steps={[ + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step1.title', { + defaultMessage: 'Add your {productName} host URL to your Kibana configuration', + values: { productName }, + }), + children: ( + <EuiText> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.setupGuide.step1.instruction1" + defaultMessage="In your {configFile} file, set {configSetting} to the URL of your {productName} instance. For example:" + values={{ + productName, + configFile: <EuiCode>config/kibana.yml</EuiCode>, + configSetting: <EuiCode>enterpriseSearch.host</EuiCode>, + }} + /> + </p> + <EuiCodeBlock language="yml"> + enterpriseSearch.host: 'http://localhost:3002' + </EuiCodeBlock> + </EuiText> + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { + defaultMessage: 'Reload your Kibana instance', + }), + children: ( + <EuiText> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.setupGuide.step2.instruction1" + defaultMessage="Restart Kibana to pick up the configuration changes from the previous step." + /> + </p> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.setupGuide.step2.instruction2" + defaultMessage="If you’re using {elasticsearchNativeAuthLink} in {productName}, you’re all set. Your users can now access {productName} in Kibana with their current {productName} access and permissions." + values={{ + productName, + elasticsearchNativeAuthLink: elasticsearchNativeAuthLink ? ( + <EuiLink href={elasticsearchNativeAuthLink} target="_blank"> + Elasticsearch Native Auth + </EuiLink> + ) : ( + 'Elasticsearch Native Auth' + ), + }} + /> + </p> + </EuiText> + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { + defaultMessage: 'Troubleshooting issues', + }), + children: ( + <> + <EuiAccordion + buttonContent={i18n.translate( + 'xpack.enterpriseSearch.troubleshooting.differentEsClusters.title', + { + defaultMessage: + '{productName} and Kibana are on different Elasticsearch clusters', + values: { productName }, + } + )} + id="differentEsClusters" + paddingSize="s" + > + <EuiText> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.troubleshooting.differentEsClusters.description" + defaultMessage="This plugin does not currently support {productName} and Kibana running on different clusters." + values={{ productName }} + /> + </p> + </EuiText> + </EuiAccordion> + <EuiSpacer /> + <EuiAccordion + buttonContent={i18n.translate( + 'xpack.enterpriseSearch.troubleshooting.differentAuth.title', + { + defaultMessage: + '{productName} and Kibana are on different authentication methods', + values: { productName }, + } + )} + id="differentAuth" + paddingSize="s" + > + <EuiText> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.troubleshooting.differentAuth.description" + defaultMessage="This plugin does not currently support {productName} and Kibana operating on different authentication methods, for example, {productName} using a different SAML provider than Kibana." + values={{ productName }} + /> + </p> + </EuiText> + </EuiAccordion> + <EuiSpacer /> + <EuiAccordion + buttonContent={i18n.translate( + 'xpack.enterpriseSearch.troubleshooting.standardAuth.title', + { + defaultMessage: '{productName} on Standard authentication is not supported', + values: { productName }, + } + )} + id="standardAuth" + paddingSize="s" + > + <EuiText> + <p> + <FormattedMessage + id="xpack.enterpriseSearch.troubleshooting.standardAuth.description" + defaultMessage="This plugin does not fully support {productName} on {standardAuthLink}. Users created in {productName} must have Kibana access. Users created in Kibana will not see {productName} in the navigation menu." + values={{ + productName, + standardAuthLink: standardAuthLink ? ( + <EuiLink href={standardAuthLink} target="_blank"> + Standard Auth + </EuiLink> + ) : ( + 'Standard Auth' + ), + }} + /> + </p> + </EuiText> + </EuiAccordion> + </> + ), + }, + ]} + /> + </EuiPageContent> + </EuiPageBody> + </EuiPage> +); 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 0000000000000..f871f48b17154 --- /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 0000000000000..9825c0d8ab889 --- /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(<SendAppSearchTelemetry action="clicked" metric="button" />, { + 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 0000000000000..300cb18272717 --- /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<ISendTelemetryProps> = ({ 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 0000000000000..06272641b1929 --- /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 0000000000000..fbfcc303de47a --- /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<ClientConfigType>(); + } + + 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 0000000000000..e95056b871324 --- /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 0000000000000..a10f96907ad28 --- /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<ITelemetry>({ + 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 0000000000000..1e4159124ed94 --- /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<typeof configSchema>; + +export const config: PluginConfigDescriptor<ConfigType> = { + 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 0000000000000..11d4a387b533f --- /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 0000000000000..0239cb6422d03 --- /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<IAccess> => { + // 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<boolean> => { + 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 0000000000000..cf35a458b4825 --- /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 0000000000000..7a6d1eac1b454 --- /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<IReturn> => { + 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 0000000000000..70be8600862e9 --- /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<ConfigType>; + private logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.create<ConfigType>(); + 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 0000000000000..3cca5e21ce9c3 --- /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 0000000000000..1ca7755979f99 --- /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<IRouter>; + 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<RequestHandlerContext>; + 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 0000000000000..9b6fa30271d61 --- /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 0000000000000..d5b1bc5003456 --- /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<typeof fetch>; + +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 0000000000000..ca83c0e187ddb --- /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 0000000000000..e2d5fbcec3705 --- /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 0000000000000..4cc9b64adc092 --- /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 0000000000000..846aae3fce56f --- /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 0000000000000..a9edd4eb10da0 --- /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 0000000000000..32322d494b5e2 --- /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/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 06f064a379fe6..8a499a3eba8fa 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -189,13 +189,15 @@ describe('features', () => { 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 5a15290a7f1a2..f9ee5fc750127 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/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 13d7c62316040..1ea16a2a9940c 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 29be6d826c1bc..ee8af9e040401 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 11fb9b2de7199..df6eca795f801 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_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md new file mode 100644 index 0000000000000..63d13cbac7020 --- /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 0000000000000..e4ebd61c0692a --- /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 0000000000000..ac4984e0db019 --- /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 0000000000000..1d478c6baf29c --- /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 0000000000000..31a92e752fcf4 --- /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 0000000000000..f737b6cd4b5f4 --- /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 0000000000000..bb257cdcbfe1b --- /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<typeof services, typeof pageObjects>; 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 0000000000000..d845a1935a149 --- /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<void> { + return await PageObjects.common.navigateToApp('enterprise_search/app_search'); + }, + + async getEngineLinks(): Promise<WebElementWrapper[]> { + const engines = await testSubjects.find('appSearchEngines'); + return await testSubjects.findAllDescendant('engineNameLink', engines); + }, + + async getMetaEngineLinks(): Promise<WebElementWrapper[]> { + 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 0000000000000..009fb26482419 --- /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 0000000000000..fbd15b83f97ea --- /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 = <T>(method: string, path: string, body?: object): Promise<T> => { + 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<IEngine> => { + return await makeRequest('POST', '/api/as/v1/engines', { name: engineName }); +}; + +export const destroyEngine = async (engineName: string): Promise<object> => { + return await makeRequest('DELETE', `/api/as/v1/engines/${engineName}`); +}; + +export const createMetaEngine = async ( + engineName: string, + sourceEngines: string[] +): Promise<IEngine> => { + 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<ISearchResponse> => { + 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 0000000000000..9a43783402f4b --- /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<IEngine> { + const engineName = `test-engine-${new Date().getTime()}`; + return createEngine(engineName); + } + + async createEngineWithDocs(): Promise<IEngine> { + 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<IEngine> { + 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 0000000000000..1715c98677ac6 --- /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 0000000000000..f425f806f4bcd --- /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 0000000000000..0f2afd214abed --- /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/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts index 405ef4dbdc5b1..b20a499ba7e20 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 f8f3f2be2b2ec..0e0d46c6ce2cd 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 10ecf5d25d346..08a7d789153e7 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 52a1f30147b4f..99f91407dc1d2 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 fe9ffa9286de8..d3bd2e1afd357 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);