diff --git a/x-pack/plugins/observability/README.md b/x-pack/plugins/observability/README.md index cfa133157341..8d87bacc431e 100644 --- a/x-pack/plugins/observability/README.md +++ b/x-pack/plugins/observability/README.md @@ -32,6 +32,10 @@ xpack.ruleRegistry.write.enabled: true When both of the these are set to `true`, your alerts should show on the alerts page. +## 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](./components/shared/page_template/README.md) for more information on registering your solution's navigation structure, and rendering the navigation via the shared component. + ## Unit testing Note: Run the following commands from `kibana/x-pack/plugins/observability`. diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index 76b8eb5c7fd0..3b276df08e5a 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -9,6 +9,7 @@ import { createMemoryHistory } from 'history'; import React from 'react'; import { Observable } from 'rxjs'; import { AppMountParameters, CoreStart } from 'src/core/public'; +import { KibanaPageTemplate } from '../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../plugin'; import { createObservabilityRuleTypeRegistryMock } from '../rules/observability_rule_type_registry_mock'; import { renderApp } from './'; @@ -59,6 +60,7 @@ describe('renderApp', () => { plugins, appMountParameters: params, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), + ObservabilityPageTemplate: KibanaPageTemplate, }); unmount(); }).not.toThrowError(); diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 460aa6c35bdb..f8dce3ce1d48 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -10,7 +10,7 @@ import React, { MouseEvent, useEffect } from 'react'; import ReactDOM from 'react-dom'; import { Route, Router, Switch } from 'react-router-dom'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; -import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; +import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '../../../../../src/core/public'; import { KibanaContextProvider, RedirectAppLinks, @@ -19,6 +19,7 @@ import { PluginContext } from '../context/plugin_context'; import { usePluginContext } from '../hooks/use_plugin_context'; import { useRouteParams } from '../hooks/use_route_params'; import { ObservabilityPublicPluginsStart } from '../plugin'; +import type { LazyObservabilityPageTemplateProps } from '../components/shared/page_template/lazy_page_template'; import { HasDataContextProvider } from '../context/has_data_context'; import { Breadcrumbs, routes } from '../routes'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -74,12 +75,14 @@ export const renderApp = ({ plugins, appMountParameters, observabilityRuleTypeRegistry, + ObservabilityPageTemplate, }: { config: ConfigSchema; core: CoreStart; plugins: ObservabilityPublicPluginsStart; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; appMountParameters: AppMountParameters; + ObservabilityPageTemplate: React.ComponentType; }) => { const { element, history } = appMountParameters; const i18nCore = core.i18n; @@ -92,15 +95,25 @@ export const renderApp = ({ links: [{ linkType: 'discuss', href: 'https://ela.st/observability-discuss' }], }); + // ensure all divs are .kbnAppWrappers + element.classList.add(APP_WRAPPER_CLASS); + ReactDOM.render( - + diff --git a/x-pack/plugins/observability/public/components/app/header/header_menu.tsx b/x-pack/plugins/observability/public/components/app/header/header_menu.tsx new file mode 100644 index 000000000000..707cb241501f --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/header/header_menu.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink, EuiHeaderLinks } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; +import HeaderMenuPortal from '../../shared/header_menu_portal'; + +export function ObservabilityHeaderMenu(): React.ReactElement | null { + const { + appMountParameters: { setHeaderActionMenu }, + core: { + http: { + basePath: { prepend }, + }, + }, + } = usePluginContext(); + + return ( + + + + {addDataLinkText} + + + + ); +} + +const addDataLinkText = i18n.translate('xpack.observability.home.addData', { + defaultMessage: 'Add data', +}); diff --git a/x-pack/plugins/observability/public/components/app/header/index.test.tsx b/x-pack/plugins/observability/public/components/app/header/index.test.tsx deleted file mode 100644 index 65724dd74598..000000000000 --- a/x-pack/plugins/observability/public/components/app/header/index.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render } from '../../../utils/test_helper'; -import { Header } from './'; - -describe('Header', () => { - it('renders', () => { - const { getByText, getByTestId } = render(
); - expect(getByTestId('observability-logo')).toBeInTheDocument(); - expect(getByText('Observability')).toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/observability/public/components/app/header/index.tsx b/x-pack/plugins/observability/public/components/app/header/index.tsx index 8b86e0b25379..f7f69c22173f 100644 --- a/x-pack/plugins/observability/public/components/app/header/index.tsx +++ b/x-pack/plugins/observability/public/components/app/header/index.tsx @@ -5,81 +5,4 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiHeaderLink, - EuiHeaderLinks, - EuiIcon, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ReactNode } from 'react'; -import styled from 'styled-components'; -import { usePluginContext } from '../../../hooks/use_plugin_context'; -import HeaderMenuPortal from '../../shared/header_menu_portal'; - -const Container = styled.div<{ color: string }>` - background: ${(props) => props.color}; - border-bottom: ${(props) => props.theme.eui.euiBorderThin}; -`; - -const Wrapper = styled.div<{ restrictWidth?: number }>` - width: 100%; - max-width: ${(props) => `${props.restrictWidth}px`}; - margin: 0 auto; - overflow: hidden; - padding: 0 16px; -`; - -interface Props { - color: string; - datePicker?: ReactNode; - restrictWidth?: number; -} - -export function Header({ color, datePicker = null, restrictWidth }: Props) { - const { appMountParameters, core } = usePluginContext(); - const { setHeaderActionMenu } = appMountParameters; - const { prepend } = core.http.basePath; - - return ( - - - - - {i18n.translate('xpack.observability.home.addData', { defaultMessage: 'Add data' })} - - - - - - - - - - - - - -

- {i18n.translate('xpack.observability.home.title', { - defaultMessage: 'Observability', - })} -

-
-
-
-
- {datePicker} -
- -
-
- ); -} +export * from './header_menu'; diff --git a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx deleted file mode 100644 index f2d50539395b..000000000000 --- a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiPage, EuiPageBody, EuiPageProps } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import styled from 'styled-components'; -import { Header } from '../header/index'; - -const Page = styled(EuiPage)` - background: transparent; -`; - -const Container = styled.div<{ color?: string }>` - overflow-y: hidden; - min-height: calc( - 100vh - ${(props) => props.theme.eui.euiHeaderHeight + props.theme.eui.euiHeaderHeight} - ); - background: ${(props) => props.color}; -`; - -interface Props { - datePicker?: ReactNode; - headerColor: string; - bodyColor: string; - children?: ReactNode; - restrictWidth?: number; -} - -export function WithHeaderLayout({ - datePicker, - headerColor, - bodyColor, - children, - restrictWidth, -}: Props) { - return ( - -
- - {children} - - - ); -} diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index aa83c49c9f52..ad3ecd274080 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -16,6 +16,7 @@ import { HasDataContextValue } from '../../../../context/has_data_context'; import { AppMountParameters, CoreStart } from 'kibana/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { createObservabilityRuleTypeRegistryMock } from '../../../../rules/observability_rule_type_registry_mock'; +import { KibanaPageTemplate } from '../../../../../../../../src/plugins/kibana_react/public'; jest.mock('react-router-dom', () => ({ useLocation: () => ({ @@ -57,6 +58,7 @@ describe('APMSection', () => { }, }, } as unknown) as ObservabilityPublicPluginsStart, + ObservabilityPageTemplate: KibanaPageTemplate, })); }); diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx index 5c237bfbc31e..fab461476e71 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx @@ -16,6 +16,7 @@ import { render } from '../../../../utils/test_helper'; import { UXSection } from './'; import { response } from './mock_data/ux.mock'; import { createObservabilityRuleTypeRegistryMock } from '../../../../rules/observability_rule_type_registry_mock'; +import { KibanaPageTemplate } from '../../../../../../../../src/plugins/kibana_react/public'; jest.mock('react-router-dom', () => ({ useLocation: () => ({ @@ -56,6 +57,7 @@ describe('UXSection', () => { }, } as unknown) as ObservabilityPublicPluginsStart, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), + ObservabilityPageTemplate: KibanaPageTemplate, })); }); it('renders with core web vitals', () => { diff --git a/x-pack/plugins/observability/public/components/shared/index.tsx b/x-pack/plugins/observability/public/components/shared/index.tsx index d4d7521a6b09..f04ca5b85791 100644 --- a/x-pack/plugins/observability/public/components/shared/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/index.tsx @@ -9,6 +9,8 @@ import React, { lazy, Suspense } from 'react'; import type { CoreVitalProps, HeaderMenuPortalProps } from './types'; import type { FieldValueSuggestionsProps } from './field_value_suggestions/types'; +export { createLazyObservabilityPageTemplate } from './page_template'; + const CoreVitalsLazy = lazy(() => import('./core_web_vitals/index')); export function getCoreVitalsComponent(props: CoreVitalProps) { diff --git a/x-pack/plugins/observability/public/components/shared/page_template/README.md b/x-pack/plugins/observability/public/components/shared/page_template/README.md new file mode 100644 index 000000000000..e360e6d95a9d --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/page_template/README.md @@ -0,0 +1,104 @@ +## 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 observability plugin specified as a dependency in your `kibana.json` file, e.g. + +```json +"requiredPlugins": [ + "observability" +], +``` + +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. E.g. + +```typescript +// x-pack/plugins/example_plugin/public/plugin.ts + +export class Plugin implements PluginClass { + constructor(_context: PluginInitializerContext) {} + + setup(core: CoreSetup, plugins: PluginsSetup) { + plugins.observability.navigation.registerSections( + of([ + { + label: 'A solution section', + sortKey: 200, + entries: [ + { 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 pageTemplateComponent = pluginsStart.observability.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) \ No newline at end of file diff --git a/x-pack/plugins/observability/public/components/shared/page_template/index.ts b/x-pack/plugins/observability/public/components/shared/page_template/index.ts new file mode 100644 index 000000000000..14793ba40225 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/page_template/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 { createLazyObservabilityPageTemplate } from './lazy_page_template'; diff --git a/x-pack/plugins/observability/public/components/shared/page_template/lazy_page_template.tsx b/x-pack/plugins/observability/public/components/shared/page_template/lazy_page_template.tsx new file mode 100644 index 000000000000..7c61cae4f2c7 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/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/public/components/shared/page_template/page_template.png b/x-pack/plugins/observability/public/components/shared/page_template/page_template.png new file mode 100644 index 000000000000..7dc88b937c27 Binary files /dev/null and b/x-pack/plugins/observability/public/components/shared/page_template/page_template.png differ diff --git a/x-pack/plugins/observability/public/components/shared/page_template/page_template.test.tsx b/x-pack/plugins/observability/public/components/shared/page_template/page_template.test.tsx new file mode 100644 index 000000000000..42d520786afc --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/page_template/page_template.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { I18nProvider } from '@kbn/i18n/react'; +import { render } from '@testing-library/react'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { of } from 'rxjs'; +import { createNavigationRegistry } from '../../../services/navigation_registry'; +import { createLazyObservabilityPageTemplate } from './lazy_page_template'; +import { ObservabilityPageTemplate } from './page_template'; + +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$, + }); + + 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], + }} + > +
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], + }} + > +
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/public/components/shared/page_template/page_template.tsx b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx new file mode 100644 index 000000000000..8025c6d65869 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx @@ -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 { EuiSideNavItemType, ExclusiveUnion } from '@elastic/eui'; +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 '../../../../../../../src/core/public'; +import { + KibanaPageTemplate, + KibanaPageTemplateProps, +} from '../../../../../../../src/plugins/kibana_react/public'; +import type { NavigationSection } from '../../../services/navigation_registry'; + +export type WrappedPageTemplateProps = Pick< + KibanaPageTemplateProps, + | 'children' + | 'data-test-subj' + | 'paddingSize' + | 'pageBodyProps' + | 'pageContentBodyProps' + | 'pageContentProps' + | 'pageHeader' + | 'restrictWidth' +> & + // recreate the exclusivity of bottomBar-related props + ExclusiveUnion< + { template?: 'default' } & Pick, + { template: KibanaPageTemplateProps['template'] } + >; + +export interface ObservabilityPageTemplateDependencies { + currentAppId$: Observable; + getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToApp: ApplicationStart['navigateToApp']; + navigationSections$: Observable; +} + +export type ObservabilityPageTemplateProps = ObservabilityPageTemplateDependencies & + WrappedPageTemplateProps; + +export function ObservabilityPageTemplate({ + children, + currentAppId$, + getUrlForApp, + navigateToApp, + navigationSections$, + ...pageTemplateProps +}: ObservabilityPageTemplateProps): React.ReactElement | null { + const sections = useObservable(navigationSections$, []); + const currentAppId = useObservable(currentAppId$, undefined); + const { pathname: currentPath } = useLocation(); + + const sideNavItems = useMemo>>( + () => + sections.map(({ label, entries }, sectionIndex) => ({ + id: `${sectionIndex}`, + name: label, + items: entries.map((entry, entryIndex) => { + const href = getUrlForApp(entry.app, { + path: entry.path, + }); + + const isSelected = + entry.app === currentAppId && + matchPath(currentPath, { + path: entry.path, + }) != null; + + return { + id: `${sectionIndex}.${entryIndex}`, + name: entry.label, + href, + isSelected, + onClick: (event) => { + 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 ( + + {children} + + ); +} + +// for lazy import +// eslint-disable-next-line import/no-default-export +export default ObservabilityPageTemplate; + +const sideNavTitle = i18n.translate('xpack.observability.pageLayout.sideNavTitle', { + defaultMessage: 'Observability', +}); diff --git a/x-pack/plugins/observability/public/context/plugin_context.tsx b/x-pack/plugins/observability/public/context/plugin_context.tsx index 9b0bacc4c117..7c710d1d84c3 100644 --- a/x-pack/plugins/observability/public/context/plugin_context.tsx +++ b/x-pack/plugins/observability/public/context/plugin_context.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import { createContext } from 'react'; import { AppMountParameters, CoreStart } from 'kibana/public'; +import { createContext } from 'react'; import { ObservabilityPublicPluginsStart } from '../plugin'; import { ConfigSchema } from '..'; import { ObservabilityRuleTypeRegistry } from '../rules/create_observability_rule_type_registry'; +import type { LazyObservabilityPageTemplateProps } from '../components/shared/page_template/lazy_page_template'; export interface PluginContextValue { appMountParameters: AppMountParameters; @@ -17,6 +18,7 @@ export interface PluginContextValue { core: CoreStart; plugins: ObservabilityPublicPluginsStart; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; + ObservabilityPageTemplate: React.ComponentType; } export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts index 3b0bdb8dc960..7a241401722c 100644 --- a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts +++ b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts @@ -40,6 +40,7 @@ describe('useTimeRange', () => { }, } as unknown) as ObservabilityPublicPluginsStart, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), + ObservabilityPageTemplate: () => null, })); jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({ from: '2020-10-08T05:00:00.000Z', @@ -82,6 +83,7 @@ describe('useTimeRange', () => { }, } as unknown) as ObservabilityPublicPluginsStart, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), + ObservabilityPageTemplate: () => null, })); }); it('returns ranges and absolute times from kibana default settings', () => { diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 7b8055c82f07..b76e9f82d8df 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -5,15 +5,7 @@ * 2.0. */ -import { - EuiButton, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPageTemplate, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ALERT_START, @@ -56,7 +48,7 @@ interface AlertsPageProps { } export function AlertsPage({ routeParams }: AlertsPageProps) { - const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const { core, observabilityRuleTypeRegistry, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; const history = useHistory(); const { @@ -131,7 +123,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { } return ( - @@ -139,7 +131,6 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { ), - rightSideItems: [ {i18n.translate('xpack.observability.alerts.manageDetectionRulesButtonLabel', { @@ -206,6 +197,6 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { - +
); } diff --git a/x-pack/plugins/observability/public/pages/cases/index.tsx b/x-pack/plugins/observability/public/pages/cases/index.tsx index dd7f7875b568..7f6bce7d486f 100644 --- a/x-pack/plugins/observability/public/pages/cases/index.tsx +++ b/x-pack/plugins/observability/public/pages/cases/index.tsx @@ -5,19 +5,21 @@ * 2.0. */ -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPageTemplate } from '@elastic/eui'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { RouteParams } from '../../routes'; +import { usePluginContext } from '../../hooks/use_plugin_context'; interface CasesProps { routeParams: RouteParams<'/cases'>; } export function CasesPage(props: CasesProps) { + const { ObservabilityPageTemplate } = usePluginContext(); return ( - @@ -44,6 +46,6 @@ export function CasesPage(props: CasesProps) { - + ); } diff --git a/x-pack/plugins/observability/public/pages/landing/index.tsx b/x-pack/plugins/observability/public/pages/landing/index.tsx index 73693e14d6ac..46c99bffbcc6 100644 --- a/x-pack/plugins/observability/public/pages/landing/index.tsx +++ b/x-pack/plugins/observability/public/pages/landing/index.tsx @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; import styled, { ThemeContext } from 'styled-components'; import { FleetPanel } from '../../components/app/fleet_panel'; -import { WithHeaderLayout } from '../../components/app/layout/with_header'; +import { ObservabilityHeaderMenu } from '../../components/app/header'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useTrackPageview } from '../../hooks/use_track_metric'; import { appsSection } from '../home/section'; @@ -34,15 +34,12 @@ export function LandingPage() { useTrackPageview({ app: 'observability-overview', path: 'landing' }); useTrackPageview({ app: 'observability-overview', path: 'landing', delay: 15000 }); - const { core } = usePluginContext(); + const { core, ObservabilityPageTemplate } = usePluginContext(); const theme = useContext(ThemeContext); return ( - + + {/* title and description */} @@ -128,6 +125,6 @@ export function LandingPage() { - + ); } diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 7aa473d0ebee..4cb6792d5019 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -6,12 +6,12 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useContext } from 'react'; -import { ThemeContext } from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; import { useTrackPageview } from '../..'; import { Alert } from '../../../../alerting/common'; import { EmptySections } from '../../components/app/empty_sections'; -import { WithHeaderLayout } from '../../components/app/layout/with_header'; +import { ObservabilityHeaderMenu } from '../../components/app/header'; import { NewsFeed } from '../../components/app/news_feed'; import { Resources } from '../../components/app/resources'; import { AlertsSection } from '../../components/app/section/alerts'; @@ -39,8 +39,7 @@ function calculateBucketSize({ start, end }: { start?: number; end?: number }) { export function OverviewPage({ routeParams }: Props) { useTrackPageview({ app: 'observability-overview', path: 'overview' }); useTrackPageview({ app: 'observability-overview', path: 'overview', delay: 15000 }); - const { core } = usePluginContext(); - const theme = useContext(ThemeContext); + const { core, ObservabilityPageTemplate } = usePluginContext(); const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); @@ -65,18 +64,20 @@ export function OverviewPage({ routeParams }: Props) { }); return ( - - } + , + ], + }} > + {/* Data sections */} @@ -107,6 +108,10 @@ export function OverviewPage({ routeParams }: Props) { - + ); } + +const overviewPageTitle = i18n.translate('xpack.observability.overview.pageTitle', { + defaultMessage: 'Overview', +}); diff --git a/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx b/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx index 36c9589e7e16..e2d691c647ac 100644 --- a/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx +++ b/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx @@ -5,49 +5,33 @@ * 2.0. */ -import React, { useContext } from 'react'; -import styled, { ThemeContext } from 'styled-components'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiPanel } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { WithHeaderLayout } from '../../components/app/layout/with_header'; - -const CentralizedFlexGroup = styled(EuiFlexGroup)` - justify-content: center; - align-items: center; - // place the element in the center of the page - min-height: calc(100vh - ${(props) => props.theme.eui.euiHeaderChildSize}); -`; +import React from 'react'; +import { ObservabilityHeaderMenu } from '../../components/app/header'; +import { usePluginContext } from '../../hooks/use_plugin_context'; export function LoadingObservability() { - const theme = useContext(ThemeContext); + const { ObservabilityPageTemplate } = usePluginContext(); return ( - - + + + - - - - - - - - {i18n.translate('xpack.observability.overview.loadingObservability', { - defaultMessage: 'Loading Observability', - })} - - - - + - - + + {observabilityLoadingMessage} + + + ); } + +const observabilityLoadingMessage = i18n.translate( + 'xpack.observability.overview.loadingObservability', + { + defaultMessage: 'Loading Observability', + } +); diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 12f8900034eb..2482ae7a8e7a 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -24,6 +24,7 @@ import { emptyResponse as emptyMetricsResponse, fetchMetricsData } from './mock/ import { newsFeedFetchData } from './mock/news_feed.mock'; import { emptyResponse as emptyUptimeResponse, fetchUptimeData } from './mock/uptime.mock'; import { createObservabilityRuleTypeRegistryMock } from '../../rules/observability_rule_type_registry_mock'; +import { KibanaPageTemplate } from '../../../../../../src/plugins/kibana_react/public'; function unregisterAll() { unregisterDataHandler({ appName: 'apm' }); @@ -55,6 +56,7 @@ const withCore = makeDecorator({ }, } as unknown) as ObservabilityPublicPluginsStart, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), + ObservabilityPageTemplate: KibanaPageTemplate, }} > diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 16363d4181c5..c1b18e37faee 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, @@ -31,9 +31,11 @@ import type { import type { LensPublicStart } from '../../lens/public'; import { registerDataHandler } from './data_handler'; import { createCallObservabilityApi } from './services/call_observability_api'; +import { createNavigationRegistry } from './services/navigation_registry'; import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; import { ConfigSchema } from '.'; import { createObservabilityRuleTypeRegistry } from './rules/create_observability_rule_type_registry'; +import { createLazyObservabilityPageTemplate } from './components/shared'; export type ObservabilityPublicSetup = ReturnType; @@ -50,7 +52,7 @@ export interface ObservabilityPublicPluginsStart { lens: LensPublicStart; } -export type ObservabilityPublicStart = void; +export type ObservabilityPublicStart = ReturnType; export class Plugin implements @@ -61,13 +63,14 @@ export class Plugin ObservabilityPublicPluginsStart > { private readonly appUpdater$ = new BehaviorSubject(() => ({})); + private readonly navigationRegistry = createNavigationRegistry(); constructor(private readonly initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; } public setup( - coreSetup: CoreSetup, + coreSetup: CoreSetup, pluginsSetup: ObservabilityPublicPluginsSetup ) { const category = DEFAULT_APP_CATEGORIES.observability; @@ -84,7 +87,7 @@ export class Plugin // Load application bundle const { renderApp } = await import('./application'); // Get start services - const [coreStart, pluginsStart] = await coreSetup.getStartServices(); + const [coreStart, pluginsStart, { navigation }] = await coreSetup.getStartServices(); return renderApp({ config, @@ -92,6 +95,7 @@ export class Plugin plugins: pluginsStart, appMountParameters: params, observabilityRuleTypeRegistry, + ObservabilityPageTemplate: navigation.PageTemplate, }); }; @@ -164,13 +168,39 @@ export class Plugin }); } + this.navigationRegistry.registerSections( + of([ + { + label: '', + sortKey: 100, + entries: [{ label: 'Overview', app: 'observability-overview', path: '/overview' }], + }, + ]) + ); + return { dashboard: { register: registerDataHandler }, observabilityRuleTypeRegistry, isAlertingExperienceEnabled: () => config.unsafe.alertingExperience.enabled, + navigation: { + registerSections: this.navigationRegistry.registerSections, + }, }; } public start({ application }: CoreStart) { toggleOverviewLinkInNav(this.appUpdater$, application); + + const PageTemplate = createLazyObservabilityPageTemplate({ + currentAppId$: application.currentAppId$, + getUrlForApp: application.getUrlForApp, + navigateToApp: application.navigateToApp, + navigationSections$: this.navigationRegistry.sections$, + }); + + return { + navigation: { + PageTemplate, + }, + }; } } diff --git a/x-pack/plugins/observability/public/services/navigation_registry.test.ts b/x-pack/plugins/observability/public/services/navigation_registry.test.ts new file mode 100644 index 000000000000..8e46ed8aacab --- /dev/null +++ b/x-pack/plugins/observability/public/services/navigation_registry.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom } from '@kbn/std'; +import { of } from 'rxjs'; +import { createNavigationRegistry } from './navigation_registry'; + +describe('Navigation registry', () => { + it('Allows the registration of, and access to, navigation sections', async () => { + const navigationRegistry = createNavigationRegistry(); + + navigationRegistry.registerSections( + of([ + { + label: 'Test A', + sortKey: 100, + entries: [ + { label: 'Url A', app: 'TestA', path: '/url-a' }, + { label: 'Url B', app: 'TestA', path: '/url-b' }, + ], + }, + { + label: 'Test B', + sortKey: 200, + entries: [ + { label: 'Url A', app: 'TestB', path: '/url-a' }, + { label: 'Url B', app: 'TestB', path: '/url-b' }, + ], + }, + ]) + ); + + const sections = await firstValueFrom(navigationRegistry.sections$); + + expect(sections).toEqual([ + { + label: 'Test A', + sortKey: 100, + entries: [ + { + label: 'Url A', + app: 'TestA', + path: '/url-a', + }, + { + label: 'Url B', + app: 'TestA', + path: '/url-b', + }, + ], + }, + { + label: 'Test B', + sortKey: 200, + entries: [ + { + label: 'Url A', + app: 'TestB', + path: '/url-a', + }, + { + label: 'Url B', + app: 'TestB', + path: '/url-b', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/observability/public/services/navigation_registry.ts b/x-pack/plugins/observability/public/services/navigation_registry.ts new file mode 100644 index 000000000000..f42f34fcfe9b --- /dev/null +++ b/x-pack/plugins/observability/public/services/navigation_registry.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { combineLatest, Observable, ReplaySubject } from 'rxjs'; +import { map, scan, shareReplay, switchMap } from 'rxjs/operators'; + +export interface NavigationSection { + label: string | undefined; + sortKey: number; + entries: NavigationEntry[]; +} + +export interface NavigationEntry { + label: string; + app: string; + path: string; +} + +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/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index 2434f0eec10e..feacb011e070 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -10,7 +10,10 @@ import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; import { IntlProvider } from 'react-intl'; import { of } from 'rxjs'; -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + KibanaPageTemplate, +} from '../../../../../src/plugins/kibana_react/public'; import translations from '../../../translations/translations/ja-JP.json'; import { PluginContext } from '../context/plugin_context'; import { ObservabilityPublicPluginsStart } from '../plugin'; @@ -44,7 +47,14 @@ export const render = (component: React.ReactNode) => { {component} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 845f4b2fb864..f694b2b39c60 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17657,7 +17657,6 @@ "xpack.observability.home.getStatedButton": "使ってみる", "xpack.observability.home.sectionsubtitle": "ログ、メトリック、トレースを大規模に、1つのスタックにまとめて、環境内のあらゆる場所で生じるイベントの監視、分析、対応を行います。", "xpack.observability.home.sectionTitle": "エコシステム全体の一元的な可視性", - "xpack.observability.home.title": "オブザーバビリティ", "xpack.observability.landing.breadcrumb": "はじめて使う", "xpack.observability.news.readFullStory": "詳細なストーリーを読む", "xpack.observability.news.title": "新機能", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d223ba08185b..36985a729ec2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17894,7 +17894,6 @@ "xpack.observability.home.getStatedButton": "开始使用", "xpack.observability.home.sectionsubtitle": "通过根据需要将日志、指标和跟踪都置于单个堆栈上,来监测、分析和响应环境中任何位置发生的事件。", "xpack.observability.home.sectionTitle": "整个生态系统的统一可见性", - "xpack.observability.home.title": "可观测性", "xpack.observability.landing.breadcrumb": "入门", "xpack.observability.news.readFullStory": "详细了解", "xpack.observability.news.title": "最新动态",