diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c212a02c5391b..930d512352f95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -474,6 +474,7 @@ packages/kbn-object-versioning @elastic/appex-sharedux x-pack/packages/observability/alert_details @elastic/actionable-observability x-pack/test/cases_api_integration/common/plugins/observability @elastic/response-ops x-pack/plugins/observability @elastic/actionable-observability +x-pack/plugins/observability_shared @elastic/actionable-observability x-pack/test/security_api_integration/plugins/oidc_provider @elastic/kibana-security test/common/plugins/otel_metrics @elastic/infra-monitoring-ui packages/kbn-optimizer @elastic/kibana-operations diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 0f284948452c6..572452cec368c 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -641,6 +641,10 @@ Elastic. |This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI. +|{kib-repo}blob/{branch}/x-pack/plugins/observability_shared/README.md[observabilityShared] +|A plugin that contains components and utilities shared by all Observability plugins. + + |{kib-repo}blob/{branch}/x-pack/plugins/osquery/README.md[osquery] |This plugin adds extended support to Security Solution Fleet Osquery integration diff --git a/package.json b/package.json index 93f2848a94c69..7cb1cf41df00a 100644 --- a/package.json +++ b/package.json @@ -489,6 +489,7 @@ "@kbn/observability-alert-details": "link:x-pack/packages/observability/alert_details", "@kbn/observability-fixtures-plugin": "link:x-pack/test/cases_api_integration/common/plugins/observability", "@kbn/observability-plugin": "link:x-pack/plugins/observability", + "@kbn/observability-shared-plugin": "link:x-pack/plugins/observability_shared", "@kbn/oidc-provider-plugin": "link:x-pack/test/security_api_integration/plugins/oidc_provider", "@kbn/open-telemetry-instrumented-plugin": "link:test/common/plugins/otel_metrics", "@kbn/osquery-io-ts-types": "link:packages/kbn-osquery-io-ts-types", diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index 9970e587c9b4f..f9b64a5f5804b 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -14,7 +14,7 @@ module.exports = { USES_STYLED_COMPONENTS: [ /packages[\/\\](kbn-ui-shared-deps-(npm|src)|kbn-ecs-data-quality-dashboard)[\/\\]/, /src[\/\\]plugins[\/\\](kibana_react)[\/\\]/, - /x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|exploratory_view|osquery|security_solution|timelines|synthetics|ux)[\/\\]/, + /x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|observability_shared|exploratory_view|osquery|security_solution|timelines|synthetics|ux)[\/\\]/, /x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/, ], }; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 091b016246911..74d7df9394153 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -94,6 +94,7 @@ pageLoadAssetSize: navigation: 37269 newsfeed: 42228 observability: 95000 + observabilityShared: 21266 osquery: 107090 painlessLab: 179748 presentationUtil: 58834 diff --git a/tsconfig.base.json b/tsconfig.base.json index 313f478e28b46..60fa22a69367b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -942,6 +942,8 @@ "@kbn/observability-fixtures-plugin/*": ["x-pack/test/cases_api_integration/common/plugins/observability/*"], "@kbn/observability-plugin": ["x-pack/plugins/observability"], "@kbn/observability-plugin/*": ["x-pack/plugins/observability/*"], + "@kbn/observability-shared-plugin": ["x-pack/plugins/observability_shared"], + "@kbn/observability-shared-plugin/*": ["x-pack/plugins/observability_shared/*"], "@kbn/oidc-provider-plugin": ["x-pack/test/security_api_integration/plugins/oidc_provider"], "@kbn/oidc-provider-plugin/*": ["x-pack/test/security_api_integration/plugins/oidc_provider/*"], "@kbn/open-telemetry-instrumented-plugin": ["test/common/plugins/otel_metrics"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 004f8bed6bd58..ff4718c2c7f3a 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -7,6 +7,7 @@ "xpack.stackAlerts": "plugins/stack_alerts", "xpack.stackConnectors": "plugins/stack_connectors", "xpack.apm": "plugins/apm", + "xpack.banners": "plugins/banners", "xpack.canvas": "plugins/canvas", "xpack.cases": "plugins/cases", "xpack.cloud": "plugins/cloud", @@ -47,9 +48,11 @@ "xpack.aiops": ["packages/ml/aiops_components", "plugins/aiops"], "xpack.ml": ["packages/ml/date_picker", "packages/ml/trained_models_utils", "plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], + "xpack.observability": "plugins/observability", + "xpack.observabilityShared": "plugins/observability_shared", "xpack.osquery": ["plugins/osquery"], "xpack.painlessLab": "plugins/painless_lab", - "xpack.profiling": [ "plugins/profiling" ], + "xpack.profiling": ["plugins/profiling"], "xpack.remoteClusters": "plugins/remote_clusters", "xpack.reporting": ["plugins/reporting"], "xpack.rollupJobs": ["plugins/rollup"], @@ -64,6 +67,7 @@ "xpack.spaces": "plugins/spaces", "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], "xpack.taskManager": "legacy/plugins/task_manager", + "xpack.threatIntelligence": "plugins/threat_intelligence", "xpack.timelines": "plugins/timelines", "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", @@ -71,10 +75,7 @@ "xpack.synthetics": ["plugins/synthetics"], "xpack.ux": ["plugins/ux"], "xpack.urlDrilldown": "plugins/drilldowns/url_drilldown", - "xpack.watcher": "plugins/watcher", - "xpack.observability": "plugins/observability", - "xpack.banners": "plugins/banners", - "xpack.threatIntelligence": "plugins/threat_intelligence" + "xpack.watcher": "plugins/watcher" }, "exclude": ["examples"], "translations": [ diff --git a/x-pack/plugins/observability_shared/.storybook/jest_setup.js b/x-pack/plugins/observability_shared/.storybook/jest_setup.js new file mode 100644 index 0000000000000..32071b8aa3f62 --- /dev/null +++ b/x-pack/plugins/observability_shared/.storybook/jest_setup.js @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setGlobalConfig } from '@storybook/testing-react'; +import * as globalStorybookConfig from './preview'; + +setGlobalConfig(globalStorybookConfig); diff --git a/x-pack/plugins/observability_shared/.storybook/main.js b/x-pack/plugins/observability_shared/.storybook/main.js new file mode 100644 index 0000000000000..86b48c32f103e --- /dev/null +++ b/x-pack/plugins/observability_shared/.storybook/main.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/x-pack/plugins/observability_shared/.storybook/preview.js b/x-pack/plugins/observability_shared/.storybook/preview.js new file mode 100644 index 0000000000000..3200746243d47 --- /dev/null +++ b/x-pack/plugins/observability_shared/.storybook/preview.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiThemeProviderDecorator } from '@kbn/kibana-react-plugin/common'; + +export const decorators = [EuiThemeProviderDecorator]; diff --git a/x-pack/plugins/observability_shared/README.md b/x-pack/plugins/observability_shared/README.md new file mode 100644 index 0000000000000..ee0094851ca16 --- /dev/null +++ b/x-pack/plugins/observability_shared/README.md @@ -0,0 +1,11 @@ +# Observability Shared + +A plugin that contains components and utilities shared by all Observability plugins. + +## Shared navigation + +The Observability plugin maintains a navigation registry for Observability solutions, and exposes a shared page template component. Please refer to the docs in [the component directory](public/components/shared/page_template) for more information on registering your solution's navigation structure, and rendering the navigation via the shared component. + +## A note on cyclical dependencies + +Do not import any Observability plugins into this plugin. Only export shared stuff to other plugins. diff --git a/x-pack/plugins/observability_shared/common/index.ts b/x-pack/plugins/observability_shared/common/index.ts new file mode 100644 index 0000000000000..49ab983118a3b --- /dev/null +++ b/x-pack/plugins/observability_shared/common/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const observabilityFeatureId = 'observability'; +export const observabilityAppId = 'observability-overview'; +export const casesFeatureId = 'observabilityCases'; +export const sloFeatureId = 'slo'; diff --git a/x-pack/plugins/observability_shared/jest.config.js b/x-pack/plugins/observability_shared/jest.config.js new file mode 100644 index 0000000000000..4820bc69ae4ab --- /dev/null +++ b/x-pack/plugins/observability_shared/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/observability_shared'], + setupFiles: ['/x-pack/plugins/observability_shared/.storybook/jest_setup.js'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/observability_shared', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/observability_shared/{common,public,server}/**/*.{js,ts,tsx}', + ], +}; diff --git a/x-pack/plugins/observability_shared/kibana.jsonc b/x-pack/plugins/observability_shared/kibana.jsonc new file mode 100644 index 0000000000000..c89bcf5ddf519 --- /dev/null +++ b/x-pack/plugins/observability_shared/kibana.jsonc @@ -0,0 +1,15 @@ +{ + "type": "plugin", + "id": "@kbn/observability-shared-plugin", + "owner": "@elastic/actionable-observability", + "plugin": { + "id": "observabilityShared", + "server": false, + "browser": true, + "configPath": ["xpack", "observability_shared"], + "requiredPlugins": ["cases", "guidedOnboarding"], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"], + "extraPublicDirs": ["common"] + } +} diff --git a/x-pack/plugins/observability_shared/public/components/page_template/README.md b/x-pack/plugins/observability_shared/public/components/page_template/README.md new file mode 100644 index 0000000000000..d7ca1a42c3f0c --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/README.md @@ -0,0 +1,169 @@ +## Overview + +Observability solutions can register their navigation structures via the Observability plugin, this ensures that these navigation options display in the Observability page template component. This is a two part process, A) register your navigation structure and B) consume and render the shared page template component. These two elements are documented below. + +## Navigation registration + +To register a solution's navigation structure you'll first need to ensure your solution has the Shared Observability plugin specified as a dependency in your `kibana.json` file, e.g. + +```json +"requiredPlugins": [ + "sharedObservability" +], +``` + +Now within your solution's **public** plugin `setup` lifecycle method you can +call the `registerSections` method, this will register your solution's specific +navigation structure with the overall Observability navigation registry. + +The `registerSections` function takes an `Observable` of an array of +`NavigationSection`s. Each section can be defined as + +```typescript +export interface NavigationSection { + // the label of the section, should be translated + label: string | undefined; + // the key to sort by in ascending order relative to other entries + sortKey: number; + // the entries to render inside the section + entries: NavigationEntry[]; +} +``` + +Each entry inside of a navigation section is defined as + +```typescript +export interface NavigationEntry { + // the label of the menu entry, should be translated + label: string; + // the kibana app id + app: string; + // the path after the application prefix corresponding to this entry + path: string; + // whether to only match when the full path matches, defaults to `false` + matchFullPath?: boolean; + // whether to ignore trailing slashes, defaults to `true` + ignoreTrailingSlash?: boolean; + // shows NEW badge besides the navigation label, which will automatically disappear when menu item is clicked. + isNewFeature?: boolean; + // shows beta badge lab icon if the feature is still beta besides the navigation label + isBeta?: boolean; +} +``` + +A registration might therefore look like the following: + +```typescript +// x-pack/plugins/example_plugin/public/plugin.ts + +import { of } from 'rxjs'; + +export class Plugin implements PluginClass { + constructor(_context: PluginInitializerContext) {} + + setup(core: CoreSetup, plugins: PluginsSetup) { + plugins.observabilityShared.navigation.registerSections( + of([ + { + label: 'A solution section', + sortKey: 200, + entries: [ + { label: 'Home Page', app: 'exampleA', path: '/', matchFullPath: true }, + { label: 'Example Page', app: 'exampleA', path: '/example' }, + { label: 'Another Example Page', app: 'exampleA', path: '/another-example' }, + ], + }, + { + label: 'Another solution section', + sortKey: 300, + entries: [{ label: 'Example page', app: 'exampleB', path: '/example' }], + }, + ]) + ); + } + + start() {} + + stop() {} +} +``` + +Here `app` would match your solution - e.g. logs, metrics, APM, uptime etc. The registry is fully typed so please refer to the types for specific options. + +Observables are used to facilitate changes over time, for example within the lifetime of your application a license type or set of user permissions may change and as such you may wish to change the navigation structure. If your navigation needs are simple you can pass a value and forget about it. **Solutions are expected to handle their own permissions, and what should or should not be displayed at any time**, the Observability plugin will not add and remove items for you. + +The Observability navigation registry is now aware of your solution's navigation needs ✅ + +## Page template component + +The shared page template component can be used to actually display and render all of the registered navigation structures within your solution. + +The `start` contract of the public Observability plugin exposes a React component, under `navigation.PageTemplate`. + +This can be accessed like so: + +``` +const [coreStart, pluginsStart] = await core.getStartServices(); +const ObservabilityPageTemplate = pluginsStart.observabilityShared.navigation.PageTemplate; +``` + +Now that you have access to the component you can render your solution's content using it. + +```jsx +, + ], + }} +> + // Render anything you like here, this is just an example. + + // Content + + // Content + + +``` + +The `` component is a wrapper around the `` component (which in turn is a wrapper around the `` component). As such the props mostly reflect those available on the wrapped components, again everything is fully typed so please refer to the types for specific options. The `pageSideBar` prop is handled by the component, and will take care of rendering out and managing the items from the registry. + +After these two steps we should see something like the following (note the navigation on the left): + +![Page template rendered example](./page_template.png) + +## Adding NEW badge + +You can add a NEW badge beside the label by using the property `isNewFeature?: boolean;`. + +```js +setup(core: CoreSetup, plugins: PluginsSetup) { + plugins.observabilityShared.navigation.registerSections( + of([ + { + label: 'A solution section', + sortKey: 200, + entries: [ + { label: 'Backends', app: 'exampleA', path: '/example', isNewFeature: true }, + ], + } + ]) + ); + } + +``` + +![NEW Badge example](./badge.png) + +The badge is going to be shown until user clicks on the menu item for the first time. Then we'll save an information at local storage, following this pattern `observability.nav_item_badge_visible_${app}${path}`, the above example would save `observability.nav_item_badge_visible_exampleA/example`. And the badge is removed. It'll only show again if the item saved at local storage is removed or set to `false`. + +It's recommended to remove the badge (e.g. a new feature promotion) in the subsequent release. + +To avoid the navigation flooding with badges, we also want to propose keeping it to maximum 2 active badges for every iteration diff --git a/x-pack/plugins/observability_shared/public/components/page_template/badge.png b/x-pack/plugins/observability_shared/public/components/page_template/badge.png new file mode 100644 index 0000000000000..55ebc28febb43 Binary files /dev/null and b/x-pack/plugins/observability_shared/public/components/page_template/badge.png differ diff --git a/x-pack/plugins/observability_shared/public/components/page_template/helpers/navigation_registry.ts b/x-pack/plugins/observability_shared/public/components/page_template/helpers/navigation_registry.ts new file mode 100644 index 0000000000000..43f78f77c973f --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/helpers/navigation_registry.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { combineLatest, map, Observable, ReplaySubject, scan, shareReplay, switchMap } from 'rxjs'; +import { NavigationSection } from '../page_template'; + +export interface NavigationRegistry { + registerSections: (sections$: Observable) => void; + sections$: Observable; +} + +export const createNavigationRegistry = (): NavigationRegistry => { + const registeredSections$ = new ReplaySubject>(); + + const registerSections = (sections$: Observable) => { + registeredSections$.next(sections$); + }; + + const sections$: Observable = registeredSections$.pipe( + scan( + (accumulatedSections$, newSections) => accumulatedSections$.add(newSections), + new Set>() + ), + switchMap((registeredSections) => combineLatest([...registeredSections])), + map((registeredSections) => + registeredSections.flat().sort((first, second) => first.sortKey - second.sortKey) + ), + shareReplay(1) + ); + + return { + registerSections, + sections$, + }; +}; diff --git a/x-pack/plugins/observability_shared/public/components/page_template/index.ts b/x-pack/plugins/observability_shared/public/components/page_template/index.ts new file mode 100644 index 0000000000000..5ecb2c493f687 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { createLazyObservabilityPageTemplate } from './lazy_page_template'; +export type { LazyObservabilityPageTemplateProps } from './lazy_page_template'; diff --git a/x-pack/plugins/observability_shared/public/components/page_template/lazy_page_template.tsx b/x-pack/plugins/observability_shared/public/components/page_template/lazy_page_template.tsx new file mode 100644 index 0000000000000..7c61cae4f2c73 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/lazy_page_template.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { + ObservabilityPageTemplateDependencies, + WrappedPageTemplateProps, +} from './page_template'; + +export const LazyObservabilityPageTemplate = React.lazy(() => import('./page_template')); + +export type LazyObservabilityPageTemplateProps = WrappedPageTemplateProps; + +export function createLazyObservabilityPageTemplate( + injectedDeps: ObservabilityPageTemplateDependencies +) { + return (pageTemplateProps: LazyObservabilityPageTemplateProps) => ( + + + + ); +} diff --git a/x-pack/plugins/observability_shared/public/components/page_template/nav_name_with_badge.tsx b/x-pack/plugins/observability_shared/public/components/page_template/nav_name_with_badge.tsx new file mode 100644 index 0000000000000..72d0ac4f93945 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/nav_name_with_badge.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; + +interface Props { + label: string; + localStorageId: string; +} + +const LabelContainer = styled.span` + max-width: 72%; + float: left; + &:hover, + &:focus { + text-decoration: underline; + } +`; + +const StyledBadge = styled(EuiBadge)` + margin-left: 8px; +`; + +/** + * Gets current state from local storage to show or hide the badge. + * Default value: true + * @param localStorageId + */ +function getBadgeVisibility(localStorageId: string) { + const storedItem = window.localStorage.getItem(localStorageId); + if (storedItem) { + return JSON.parse(storedItem) as boolean; + } + + return true; +} + +/** + * Saves on local storage that this item should no longer be visible + * @param localStorageId + */ +export function hideBadge(localStorageId: string) { + window.localStorage.setItem(localStorageId, JSON.stringify(false)); +} + +export function NavNameWithBadge({ label, localStorageId }: Props) { + const isBadgeVisible = getBadgeVisibility(localStorageId); + return ( + <> + + {label} + + {isBadgeVisible && ( + + {i18n.translate('xpack.observabilityShared.navigation.newBadge', { + defaultMessage: 'NEW', + })} + + )} + + ); +} diff --git a/x-pack/plugins/observability_shared/public/components/page_template/nav_name_with_beta_badge.tsx b/x-pack/plugins/observability_shared/public/components/page_template/nav_name_with_beta_badge.tsx new file mode 100644 index 0000000000000..f9517d92ec574 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/nav_name_with_beta_badge.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, IconType } from '@elastic/eui'; + +interface Props { + label?: string; + isTechnicalPreview?: boolean; + iconType?: IconType; +} + +export function NavNameWithBetaBadge({ label, iconType, isTechnicalPreview }: Props) { + return ( + + + + {label} + + + + {isTechnicalPreview ? ( + + ) : ( + + )} + + + ); +} diff --git a/x-pack/plugins/observability_shared/public/components/page_template/page_template.png b/x-pack/plugins/observability_shared/public/components/page_template/page_template.png new file mode 100644 index 0000000000000..7dc88b937c27b Binary files /dev/null and b/x-pack/plugins/observability_shared/public/components/page_template/page_template.png differ diff --git a/x-pack/plugins/observability_shared/public/components/page_template/page_template.test.tsx b/x-pack/plugins/observability_shared/public/components/page_template/page_template.test.tsx new file mode 100644 index 0000000000000..b33925ed09e4b --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/page_template.test.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { I18nProvider } from '@kbn/i18n-react'; +import { render } from '@testing-library/react'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { of } from 'rxjs'; +import { getKibanaPageTemplateKibanaDependenciesMock as getPageTemplateServices } from '@kbn/shared-ux-page-kibana-template-mocks'; +import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; + +import { createLazyObservabilityPageTemplate } from './lazy_page_template'; +import { ObservabilityPageTemplate } from './page_template'; +import { createNavigationRegistry } from './helpers/navigation_registry'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: '/test-path', + }), +})); + +const navigationRegistry = createNavigationRegistry(); + +navigationRegistry.registerSections( + of([ + { + label: 'Test A', + sortKey: 100, + entries: [ + { label: 'Section A Url A', app: 'TestA', path: '/url-a' }, + { label: 'Section A Url B', app: 'TestA', path: '/url-b' }, + ], + }, + { + label: 'Test B', + sortKey: 200, + entries: [ + { label: 'Section B Url A', app: 'TestB', path: '/url-a' }, + { label: 'Section B Url B', app: 'TestB', path: '/url-b' }, + ], + }, + ]) +); + +describe('Page template', () => { + it('Provides a working lazy wrapper', () => { + const LazyObservabilityPageTemplate = createLazyObservabilityPageTemplate({ + currentAppId$: of('Test app ID'), + getUrlForApp: () => '/test-url', + navigateToApp: async () => {}, + navigationSections$: navigationRegistry.sections$, + getPageTemplateServices, + guidedOnboardingApi: guidedOnboardingMock.createStart().guidedOnboardingApi, + }); + + const component = shallow( + Test side item], + }} + > +
Test structure
+
+ ); + + expect(component.exists('lazy')).toBe(true); + }); + + it('Utilises the KibanaPageTemplate for rendering', () => { + const component = shallow( + '/test-url'} + navigateToApp={async () => {}} + navigationSections$={navigationRegistry.sections$} + pageHeader={{ + pageTitle: 'Test title', + rightSideItems: [Test side item], + }} + getPageTemplateServices={getPageTemplateServices} + guidedOnboardingApi={guidedOnboardingMock.createStart().guidedOnboardingApi} + > +
Test structure
+
+ ); + + expect(component.is('KibanaPageTemplate')); + }); + + it('Handles outputting the registered navigation structures within a side nav', () => { + const { container } = render( + + '/test-url'} + navigateToApp={async () => {}} + navigationSections$={navigationRegistry.sections$} + pageHeader={{ + pageTitle: 'Test title', + rightSideItems: [Test side item], + }} + getPageTemplateServices={getPageTemplateServices} + guidedOnboardingApi={guidedOnboardingMock.createStart().guidedOnboardingApi} + > +
Test structure
+
+
+ ); + + expect(container).toHaveTextContent('Section A Url A'); + expect(container).toHaveTextContent('Section A Url B'); + expect(container).toHaveTextContent('Section B Url A'); + expect(container).toHaveTextContent('Section B Url B'); + }); +}); diff --git a/x-pack/plugins/observability_shared/public/components/page_template/page_template.tsx b/x-pack/plugins/observability_shared/public/components/page_template/page_template.tsx new file mode 100644 index 0000000000000..696cda97e3e53 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/page_template/page_template.tsx @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSideNavItemType, EuiPageSectionProps, EuiErrorBoundary } from '@elastic/eui'; +import { _EuiPageBottomBarProps } from '@elastic/eui/src/components/page_template/bottom_bar/page_bottom_bar'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; +import type { ApplicationStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { + KibanaPageTemplate, + KibanaPageTemplateKibanaProvider, +} from '@kbn/shared-ux-page-kibana-template'; +import type { + KibanaPageTemplateProps, + KibanaPageTemplateKibanaDependencies, +} from '@kbn/shared-ux-page-kibana-template'; +import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; +import { ObservabilityTour } from '../tour'; +import { NavNameWithBadge, hideBadge } from './nav_name_with_badge'; +import { NavNameWithBetaBadge } from './nav_name_with_beta_badge'; + +export type WrappedPageTemplateProps = Pick< + KibanaPageTemplateProps, + | 'children' + | 'data-test-subj' + | 'paddingSize' + | 'pageHeader' + | 'restrictWidth' + | 'isEmptyState' + | 'noDataConfig' +> & { + showSolutionNav?: boolean; + isPageDataLoaded?: boolean; + pageSectionProps?: EuiPageSectionProps; + bottomBar?: React.ReactNode; + bottomBarProps?: _EuiPageBottomBarProps; +}; + +export interface NavigationEntry { + // the label of the menu entry, should be translated + label: string; + // the kibana app id + app: string; + // the path after the application prefix corresponding to this entry + path: string; + // whether to only match when the full path matches, defaults to `false` + matchFullPath?: boolean; + // whether to ignore trailing slashes, defaults to `true` + ignoreTrailingSlash?: boolean; + // handler to be called when the item is clicked + onClick?: (event: React.MouseEvent) => void; + // shows NEW badge besides the navigation label, which will automatically disappear when menu item is clicked. + isNewFeature?: boolean; + // shows technical preview lab icon if the feature is still in technical preview besides the navigation label + isTechnicalPreview?: boolean; + // shows beta badge besides the navigation label + isBetaFeature?: boolean; + // override default path matching logic to determine if nav entry is selected + matchPath?: (path: string) => boolean; +} + +export interface NavigationSection { + // the label of the section, should be translated + label: string | undefined; + // the key to sort by in ascending order relative to other entries + sortKey: number; + // the entries to render inside the section + entries: NavigationEntry[]; + // shows beta badge besides the navigation label + isBetaFeature?: boolean; +} + +export interface ObservabilityPageTemplateDependencies { + currentAppId$: Observable; + getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToApp: ApplicationStart['navigateToApp']; + navigationSections$: Observable; + getPageTemplateServices: () => KibanaPageTemplateKibanaDependencies; + guidedOnboardingApi: GuidedOnboardingPluginStart['guidedOnboardingApi']; +} + +export type ObservabilityPageTemplateProps = ObservabilityPageTemplateDependencies & + WrappedPageTemplateProps; + +export function ObservabilityPageTemplate({ + children, + currentAppId$, + getUrlForApp, + navigateToApp, + navigationSections$, + showSolutionNav = true, + isPageDataLoaded = true, + getPageTemplateServices, + bottomBar, + bottomBarProps, + pageSectionProps, + guidedOnboardingApi, + ...pageTemplateProps +}: ObservabilityPageTemplateProps): React.ReactElement | null { + const sections = useObservable(navigationSections$, []); + const currentAppId = useObservable(currentAppId$, undefined); + const { pathname: currentPath } = useLocation(); + + const { services } = useKibana(); + + const sideNavItems = useMemo>>( + () => + sections.map(({ label, entries, isBetaFeature }, sectionIndex) => ({ + id: `${sectionIndex}`, + name: isBetaFeature ? : label, + items: entries.map((entry, entryIndex) => { + const href = getUrlForApp(entry.app, { + path: entry.path, + }); + + const isSelected = + entry.app === currentAppId && + (entry.matchPath + ? entry.matchPath(currentPath) + : matchPath(currentPath, { + path: entry.path, + exact: !!entry.matchFullPath, + strict: !entry.ignoreTrailingSlash, + }) != null); + const badgeLocalStorageId = `observability.nav_item_badge_visible_${entry.app}${entry.path}`; + const navId = entry.label.toLowerCase().split(' ').join('_'); + return { + id: `${sectionIndex}.${entryIndex}`, + name: entry.isBetaFeature ? ( + + ) : entry.isNewFeature ? ( + + ) : entry.isTechnicalPreview ? ( + + ) : ( + entry.label + ), + href, + isSelected, + 'data-nav-id': navId, + 'data-test-subj': `observability-nav-${entry.app}-${navId}`, + onClick: (event) => { + if (entry.onClick) { + entry.onClick(event); + } + + // Hides NEW badge when the item is clicked + if (entry.isNewFeature) { + hideBadge(badgeLocalStorageId); + } + + if ( + event.button !== 0 || + event.defaultPrevented || + event.metaKey || + event.altKey || + event.ctrlKey || + event.shiftKey + ) { + return; + } + + event.preventDefault(); + navigateToApp(entry.app, { + path: entry.path, + }); + }, + }; + }), + })), + [currentAppId, currentPath, getUrlForApp, navigateToApp, sections] + ); + + return ( + + + {({ isTourVisible }) => { + return ( + + + + {children} + + + {bottomBar && ( + + {bottomBar} + + )} + + ); + }} + + + ); +} + +// for lazy import +// eslint-disable-next-line import/no-default-export +export default ObservabilityPageTemplate; + +const sideNavTitle = i18n.translate('xpack.observabilityShared.pageLayout.sideNavTitle', { + defaultMessage: 'Observability', +}); + +export const LazyObservabilityPageTemplate = React.lazy(() => import('./page_template')); + +export type LazyObservabilityPageTemplateProps = WrappedPageTemplateProps; + +export function createLazyObservabilityPageTemplate( + injectedDeps: ObservabilityPageTemplateDependencies +) { + return (pageTemplateProps: LazyObservabilityPageTemplateProps) => ( + + + + ); +} diff --git a/x-pack/plugins/observability_shared/public/components/tour/index.ts b/x-pack/plugins/observability_shared/public/components/tour/index.ts new file mode 100644 index 0000000000000..45449abf3ed5a --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/tour/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ObservabilityTour, observTourStepStorageKey, useObservabilityTourContext } from './tour'; diff --git a/x-pack/plugins/observability_shared/public/components/tour/steps_config.ts b/x-pack/plugins/observability_shared/public/components/tour/steps_config.ts new file mode 100644 index 0000000000000..5555a81813b40 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/tour/steps_config.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { EuiTourStepProps, ElementTarget } from '@elastic/eui'; + +interface TourStep { + content: string; + anchor: ElementTarget; + anchorPosition: EuiTourStepProps['anchorPosition']; + title: EuiTourStepProps['title']; + dataTestSubj: string; + offset?: number; + imageConfig?: { + name: string; + altText: string; + }; +} + +export const tourStepsConfig: TourStep[] = [ + { + title: i18n.translate('xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle', { + defaultMessage: 'Welcome to Elastic Observability', + }), + content: i18n.translate( + 'xpack.observabilityShared.tour.observabilityOverviewStep.tourContent', + { + defaultMessage: + 'Take a quick tour to learn the benefits of having all of your observability data in one stack.', + } + ), + anchor: `[id^="SolutionNav"]`, + anchorPosition: 'rightUp', + dataTestSubj: 'overviewStep', + }, + { + title: i18n.translate('xpack.observabilityShared.tour.streamStep.tourTitle', { + defaultMessage: 'Tail your logs in real time', + }), + content: i18n.translate('xpack.observabilityShared.tour.streamStep.tourContent', { + defaultMessage: + 'Monitor, filter, and inspect log events flowing in from your applications, servers, virtual machines, and containers.', + }), + anchor: `[data-nav-id="stream"]`, + anchorPosition: 'rightUp', + dataTestSubj: 'streamStep', + imageConfig: { + name: 'onboarding_tour_step_logs.gif', + altText: i18n.translate('xpack.observabilityShared.tour.streamStep.imageAltText', { + defaultMessage: 'Logs stream demonstration', + }), + }, + }, + { + title: i18n.translate('xpack.observabilityShared.tour.metricsExplorerStep.tourTitle', { + defaultMessage: 'Monitor your infrastructure health', + }), + content: i18n.translate('xpack.observabilityShared.tour.metricsExplorerStep.tourContent', { + defaultMessage: + 'Stream, group, and visualize metrics from your systems, cloud, network, and other infrastructure sources.', + }), + anchor: `[data-nav-id="metrics_explorer"]`, + anchorPosition: 'rightUp', + dataTestSubj: 'metricsExplorerStep', + imageConfig: { + name: 'onboarding_tour_step_metrics.gif', + altText: i18n.translate('xpack.observabilityShared.tour.metricsExplorerStep.imageAltText', { + defaultMessage: 'Metrics explorer demonstration', + }), + }, + }, + { + title: i18n.translate('xpack.observabilityShared.tour.servicesStep.tourTitle', { + defaultMessage: 'Identify and resolve application issues', + }), + content: i18n.translate('xpack.observabilityShared.tour.servicesStep.tourContent', { + defaultMessage: + 'Find and fix performance problems quickly by collecting detailed information about your services.', + }), + anchor: `[data-nav-id="services"]`, + anchorPosition: 'rightUp', + dataTestSubj: 'servicesStep', + imageConfig: { + name: 'onboarding_tour_step_services.gif', + altText: i18n.translate('xpack.observabilityShared.tour.servicesStep.imageAltText', { + defaultMessage: 'Services demonstration', + }), + }, + }, + { + title: i18n.translate('xpack.observabilityShared.tour.alertsStep.tourTitle', { + defaultMessage: 'Get notified when something changes', + }), + content: i18n.translate('xpack.observabilityShared.tour.alertsStep.tourContent', { + defaultMessage: + 'Define and detect conditions that trigger alerts with third-party platform integrations like email, PagerDuty, and Slack.', + }), + anchor: `[data-nav-id="alerts"]`, + anchorPosition: 'rightUp', + dataTestSubj: 'alertStep', + imageConfig: { + name: 'onboarding_tour_step_alerts.gif', + altText: i18n.translate('xpack.observabilityShared.tour.alertsStep.imageAltText', { + defaultMessage: 'Alerts demonstration', + }), + }, + }, + { + title: i18n.translate('xpack.observabilityShared.tour.guidedSetupStep.tourTitle', { + defaultMessage: 'Do more with Elastic Observability', + }), + content: i18n.translate('xpack.observabilityShared.tour.guidedSetupStep.tourContent', { + defaultMessage: + 'The easiest way to continue with Elastic Observability is to follow recommended next steps in the data assistant.', + }), + anchor: '#guidedSetupButton', + anchorPosition: 'rightUp', + dataTestSubj: 'guidedSetupStep', + offset: 10, + }, +]; diff --git a/x-pack/plugins/observability_shared/public/components/tour/tour.tsx b/x-pack/plugins/observability_shared/public/components/tour/tour.tsx new file mode 100644 index 0000000000000..49083bf307e31 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/components/tour/tour.tsx @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { + ReactNode, + useState, + useCallback, + useEffect, + createContext, + useContext, +} from 'react'; + +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiTourStep, + EuiTourStepProps, + EuiImage, + EuiSpacer, + EuiText, + useIsWithinBreakpoints, +} from '@elastic/eui'; +import { useLocation } from 'react-router-dom'; +import { ApplicationStart } from '@kbn/core/public'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; +import type { GuidedOnboardingApi } from '@kbn/guided-onboarding-plugin/public/types'; +import { observabilityAppId } from '../../../common'; +import { tourStepsConfig } from './steps_config'; + +const minWidth: EuiTourStepProps['minWidth'] = 360; +const maxWidth: EuiTourStepProps['maxWidth'] = 360; +const offset: EuiTourStepProps['offset'] = 30; +const repositionOnScroll: EuiTourStepProps['repositionOnScroll'] = true; + +const overviewPath = '/overview'; +const dataAssistantStep = 6; + +export const observTourStepStorageKey = 'guidedOnboarding.observability.tourStep'; + +const getSteps = ({ + activeStep, + incrementStep, + endTour, + prependBasePath, +}: { + activeStep: number; + incrementStep: () => void; + endTour: () => void; + prependBasePath?: (imageName: string) => string; +}) => { + const footerAction = ( + + + endTour()} + size="xs" + color="text" + // Used for testing and to track FS usage + data-test-subj="onboarding--observTourSkipButton" + > + {i18n.translate('xpack.observabilityShared.tour.skipButtonLabel', { + defaultMessage: 'Skip tour', + })} + + + + incrementStep()} + size="s" + color="success" + // Used for testing and to track FS usage + data-test-subj="onboarding--observTourNextStepButton" + > + {i18n.translate('xpack.observabilityShared.tour.nextButtonLabel', { + defaultMessage: 'Next', + })} + + + + ); + + const lastStepFooterAction = ( + // data-test-subj is used for testing and to track FS usage + endTour()} + data-test-subj="onboarding--observTourEndButton" + > + {i18n.translate('xpack.observabilityShared.tour.endButtonLabel', { + defaultMessage: 'End tour', + })} + + ); + + return tourStepsConfig.map((stepConfig, index) => { + const step = index + 1; + const { dataTestSubj, content, offset: stepOffset, imageConfig, ...tourStepProps } = stepConfig; + return ( + endTour()} + footerAction={activeStep === tourStepsConfig.length ? lastStepFooterAction : footerAction} + panelProps={{ + 'data-test-subj': dataTestSubj, + }} + content={ + <> + +

{content}

+
+ {imageConfig && prependBasePath && ( + <> + + + + )} + + } + /> + ); + }); +}; + +export interface ObservabilityTourContextValue { + endTour: () => void; + isTourVisible: boolean; +} + +const ObservabilityTourContext = createContext({ + endTour: () => {}, + isTourVisible: false, +} as ObservabilityTourContextValue); + +export function ObservabilityTour({ + children, + navigateToApp, + isPageDataLoaded, + showTour, + prependBasePath, + guidedOnboardingApi, +}: { + children: ({ isTourVisible }: { isTourVisible: boolean }) => ReactNode; + navigateToApp: ApplicationStart['navigateToApp']; + isPageDataLoaded: boolean; + showTour: boolean; + prependBasePath?: (imageName: string) => string; + guidedOnboardingApi?: GuidedOnboardingApi; +}) { + const prevActiveStep = localStorage.getItem(observTourStepStorageKey); + const initialActiveStep = prevActiveStep === null ? 1 : Number(prevActiveStep); + + const isGuidedOnboardingActive = useObservable( + // if guided onboarding is not available, return false + guidedOnboardingApi + ? guidedOnboardingApi.isGuideStepActive$('kubernetes', 'tour_observability') + : of(false) + ); + + const [isTourActive, setIsTourActive] = useState(false); + const [activeStep, setActiveStep] = useState(initialActiveStep); + + const { pathname: currentPath } = useLocation(); + + const isSmallBreakpoint = useIsWithinBreakpoints(['s']); + + const isOverviewPage = currentPath === overviewPath; + + const incrementStep = useCallback(() => { + setActiveStep((prevState) => prevState + 1); + }, []); + + const endTour = useCallback(async () => { + // Mark the onboarding guide step as complete + if (guidedOnboardingApi) { + await guidedOnboardingApi.completeGuideStep('kubernetes', 'tour_observability'); + } + // Reset EuiTour step state + setActiveStep(1); + }, [guidedOnboardingApi]); + + /** + * The tour should only be visible if the following conditions are met: + * - Only pages with the side nav should show the tour (showTour === true) + * - Tour is set to active per the guided onboarding service (isTourActive === true) + * - Any page data must be loaded in order for the tour to render correctly + * - The tour should only render on medium-large screens + */ + const isTourVisible = showTour && isTourActive && isPageDataLoaded && isSmallBreakpoint === false; + + const context: ObservabilityTourContextValue = { endTour, isTourVisible }; + + useEffect(() => { + localStorage.setItem(observTourStepStorageKey, String(activeStep)); + }, [activeStep]); + + useEffect(() => { + setIsTourActive(Boolean(isGuidedOnboardingActive)); + }, [isGuidedOnboardingActive]); + + useEffect(() => { + // The user must be on the overview page to view the data assistant step in the tour + if (isTourActive && isOverviewPage === false && activeStep === dataAssistantStep) { + navigateToApp(observabilityAppId, { + path: overviewPath, + }); + } + }, [activeStep, isOverviewPage, isTourActive, navigateToApp]); + + return ( + + <> + {children({ isTourVisible })} + {isTourVisible && getSteps({ activeStep, incrementStep, endTour, prependBasePath })} + + + ); +} + +export const useObservabilityTourContext = (): ObservabilityTourContextValue => { + const ctx = useContext(ObservabilityTourContext); + if (!ctx) { + throw new Error('useObservabilityTourContext can only be called inside of TourContext'); + } + return ctx; +}; diff --git a/x-pack/plugins/observability_shared/public/index.ts b/x-pack/plugins/observability_shared/public/index.ts new file mode 100644 index 0000000000000..ecb2095e4eb6f --- /dev/null +++ b/x-pack/plugins/observability_shared/public/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ObservabilitySharedPlugin } from './plugin'; +export type { + ObservabilitySharedPlugin, + ObservabilitySharedPluginSetup, + ObservabilitySharedPluginStart, +} from './plugin'; + +export type { + ObservabilityPageTemplateProps, + LazyObservabilityPageTemplateProps, +} from './components/page_template/page_template'; + +export type { NavigationEntry } from './components/page_template/page_template'; + +export const plugin = () => { + return new ObservabilitySharedPlugin(); +}; + +export { observabilityFeatureId, casesFeatureId, sloFeatureId } from '../common'; diff --git a/x-pack/plugins/observability_shared/public/plugin.ts b/x-pack/plugins/observability_shared/public/plugin.ts new file mode 100644 index 0000000000000..527d33e8a502e --- /dev/null +++ b/x-pack/plugins/observability_shared/public/plugin.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreStart, Plugin } from '@kbn/core/public'; +import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; +import { createNavigationRegistry } from './components/page_template/helpers/navigation_registry'; +import { createLazyObservabilityPageTemplate } from './components/page_template'; +import { updateGlobalNavigation } from './services/update_global_navigation'; + +export interface ObservabilitySharedStart { + guidedOnboarding: GuidedOnboardingPluginStart; +} + +export type ObservabilitySharedPluginSetup = ReturnType; +export type ObservabilitySharedPluginStart = ReturnType; + +export class ObservabilitySharedPlugin implements Plugin { + private readonly navigationRegistry = createNavigationRegistry(); + + public setup() { + return { + navigation: { + registerSections: this.navigationRegistry.registerSections, + }, + }; + } + + public start(core: CoreStart, plugins: ObservabilitySharedStart) { + const { application } = core; + + const PageTemplate = createLazyObservabilityPageTemplate({ + currentAppId$: application.currentAppId$, + getUrlForApp: application.getUrlForApp, + navigateToApp: application.navigateToApp, + navigationSections$: this.navigationRegistry.sections$, + guidedOnboardingApi: plugins.guidedOnboarding.guidedOnboardingApi, + getPageTemplateServices: () => ({ coreStart: core }), + }); + + return { + navigation: { + PageTemplate, + }, + updateGlobalNavigation, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/observability_shared/public/services/update_global_navigation.test.tsx b/x-pack/plugins/observability_shared/public/services/update_global_navigation.test.tsx new file mode 100644 index 0000000000000..cc908f6b3d108 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/services/update_global_navigation.test.tsx @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Subject } from 'rxjs'; +import { App, AppDeepLink, ApplicationStart, AppNavLinkStatus, AppUpdater } from '@kbn/core/public'; +import { casesFeatureId, sloFeatureId } from '../../common'; +import { updateGlobalNavigation } from './update_global_navigation'; + +// Used in updater callback +const app = {} as unknown as App; + +describe('updateGlobalNavigation', () => { + describe('when no observability apps are enabled', () => { + it('hides the overview link', () => { + const capabilities = { + navLinks: { apm: false, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + const deepLinks: AppDeepLink[] = []; + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks, + navLinkStatus: AppNavLinkStatus.hidden, + }); + }); + }); + + describe('when one observability app is enabled', () => { + it('shows the overview link', () => { + const capabilities = { + navLinks: { apm: true, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + const deepLinks: AppDeepLink[] = []; + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks, + navLinkStatus: AppNavLinkStatus.visible, + }); + }); + + describe('when cases are enabled', () => { + it('shows the cases deep link', () => { + const capabilities = { + [casesFeatureId]: { read_cases: true }, + navLinks: { apm: true, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + + const caseRoute = { + id: 'cases', + title: 'Cases', + order: 8003, + path: '/cases', + navLinkStatus: AppNavLinkStatus.hidden, + }; + + const deepLinks = [caseRoute]; + + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks: [ + { + ...caseRoute, + navLinkStatus: AppNavLinkStatus.visible, + }, + ], + navLinkStatus: AppNavLinkStatus.visible, + }); + }); + }); + + describe('with no case read capabilities', () => { + it('hides the cases deep link', () => { + const capabilities = { + [casesFeatureId]: { read_cases: false }, + navLinks: { apm: true, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + + const caseRoute = { + id: 'cases', + title: 'Cases', + order: 8003, + path: '/cases', + navLinkStatus: AppNavLinkStatus.hidden, + }; + + const deepLinks = [caseRoute]; + + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks: [ + { + ...caseRoute, + navLinkStatus: AppNavLinkStatus.hidden, + }, + ], + navLinkStatus: AppNavLinkStatus.visible, + }); + }); + }); + + describe('when alerts are enabled', () => { + it('shows the alerts deep link', () => { + const capabilities = { + [casesFeatureId]: { read_cases: true }, + navLinks: { apm: true, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + + const deepLinks = [ + { + id: 'alerts', + title: 'Alerts', + order: 8001, + path: '/alerts', + navLinkStatus: AppNavLinkStatus.hidden, + }, + ]; + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks: [ + { + id: 'alerts', + title: 'Alerts', + order: 8001, + path: '/alerts', + navLinkStatus: AppNavLinkStatus.visible, + }, + ], + navLinkStatus: AppNavLinkStatus.visible, + }); + }); + }); + + it("hides the slo link when the capabilities don't include it", () => { + const capabilities = { + navLinks: { apm: true, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + + const sloRoute = { + id: 'slos', + title: 'SLOs', + order: 8002, + path: '/slos', + navLinkStatus: AppNavLinkStatus.hidden, + }; + + const deepLinks = [sloRoute]; + + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks: [ + { + ...sloRoute, + navLinkStatus: AppNavLinkStatus.hidden, + }, + ], + navLinkStatus: AppNavLinkStatus.visible, + }); + }); + + describe('when slos are enabled', () => { + it('shows the slos deep link', () => { + const capabilities = { + [casesFeatureId]: { read_cases: true }, + [sloFeatureId]: { read: true }, + navLinks: { apm: false, logs: false, metrics: false, uptime: false }, + } as unknown as ApplicationStart['capabilities']; + + const sloRoute = { + id: 'slos', + title: 'SLOs', + order: 8002, + path: '/slos', + navLinkStatus: AppNavLinkStatus.hidden, + }; + + const deepLinks = [sloRoute]; + + const callback = jest.fn(); + const updater$ = { + next: (cb: AppUpdater) => callback(cb(app)), + } as unknown as Subject; + + updateGlobalNavigation({ capabilities, deepLinks, updater$ }); + + expect(callback).toHaveBeenCalledWith({ + deepLinks: [ + { + ...sloRoute, + navLinkStatus: AppNavLinkStatus.visible, + }, + ], + navLinkStatus: AppNavLinkStatus.visible, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx b/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx new file mode 100644 index 0000000000000..8908a90ff6545 --- /dev/null +++ b/x-pack/plugins/observability_shared/public/services/update_global_navigation.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Subject } from 'rxjs'; +import { AppNavLinkStatus, AppUpdater, ApplicationStart, AppDeepLink } from '@kbn/core/public'; +import { CasesDeepLinkId } from '@kbn/cases-plugin/public'; +import { casesFeatureId, sloFeatureId } from '../../common'; + +export function updateGlobalNavigation({ + capabilities, + deepLinks, + updater$, +}: { + capabilities: ApplicationStart['capabilities']; + deepLinks: AppDeepLink[]; + updater$: Subject; +}) { + const { apm, logs, metrics, uptime } = capabilities.navLinks; + const someVisible = Object.values({ + apm, + logs, + metrics, + uptime, + }).some((visible) => visible); + + const updatedDeepLinks = deepLinks.map((link) => { + switch (link.id) { + case CasesDeepLinkId.cases: + return { + ...link, + navLinkStatus: + capabilities[casesFeatureId].read_cases && someVisible + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + }; + case 'alerts': + return { + ...link, + navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + }; + case 'rules': + return { + ...link, + navLinkStatus: someVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + }; + case 'slos': + return { + ...link, + navLinkStatus: !!capabilities[sloFeatureId]?.read + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + }; + default: + return link; + } + }); + + updater$.next(() => ({ + deepLinks: updatedDeepLinks, + navLinkStatus: + someVisible || !!capabilities[sloFeatureId]?.read + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + })); +} diff --git a/x-pack/plugins/observability_shared/public/types b/x-pack/plugins/observability_shared/public/types new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/plugins/observability_shared/scripts/storybook.js b/x-pack/plugins/observability_shared/scripts/storybook.js new file mode 100644 index 0000000000000..92dc702569caa --- /dev/null +++ b/x-pack/plugins/observability_shared/scripts/storybook.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { join } from 'path'; + +require('@kbn/storybook').runStorybookCli({ + name: 'observabilityShared', + storyGlobs: [ + join(__dirname, '..', 'public', 'components', '**', '*.stories.tsx'), + join(__dirname, '..', 'public', 'pages', '**', '*.stories.tsx'), + ], +}); diff --git a/x-pack/plugins/observability_shared/tsconfig.json b/x-pack/plugins/observability_shared/tsconfig.json new file mode 100644 index 0000000000000..fba33ee93d5f4 --- /dev/null +++ b/x-pack/plugins/observability_shared/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "common/**/*", + "public/**/*", + "public/**/*.json", + "server/**/*", + "typings/**/*", + "../../../typings/**/*" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/kibana-react-plugin", + "@kbn/cases-plugin", + "@kbn/guided-onboarding-plugin", + "@kbn/i18n", + "@kbn/shared-ux-page-kibana-template", + "@kbn/i18n-react", + "@kbn/shared-ux-page-kibana-template-mocks", + ], + "exclude": ["target/**/*"] +} diff --git a/x-pack/plugins/observability_shared/typings/common.ts b/x-pack/plugins/observability_shared/typings/common.ts new file mode 100644 index 0000000000000..fcfd5db7dcbd9 --- /dev/null +++ b/x-pack/plugins/observability_shared/typings/common.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type ObservabilityApp = + | 'infra_metrics' + | 'infra_logs' + | 'apm' + // we will remove uptime in future to replace to be replace by synthetics + | 'uptime' + | 'synthetics' + | 'observability-overview' + | 'stack_monitoring' + | 'ux' + | 'fleet'; diff --git a/yarn.lock b/yarn.lock index ba61f9481e6b6..d58ecb65a9c8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4621,6 +4621,10 @@ version "0.0.0" uid "" +"@kbn/observability-shared-plugin@link:x-pack/plugins/observability_shared": + version "0.0.0" + uid "" + "@kbn/oidc-provider-plugin@link:x-pack/test/security_api_integration/plugins/oidc_provider": version "0.0.0" uid ""