[Logs UI] Add shared observability page template and navigation (#99380)

Co-authored-by: Kerry Gallagher <471693+Kerry350@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Felix Stürmer 2021-05-27 16:58:15 +02:00 committed by GitHub
parent 7fc4a1f80f
commit 06d276e060
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 680 additions and 234 deletions

View file

@ -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`.

View file

@ -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();

View file

@ -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<LazyObservabilityPageTemplateProps>;
}) => {
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(
<KibanaContextProvider services={{ ...core, ...plugins, storage: new Storage(localStorage) }}>
<PluginContext.Provider
value={{ appMountParameters, config, core, plugins, observabilityRuleTypeRegistry }}
value={{
appMountParameters,
config,
core,
plugins,
observabilityRuleTypeRegistry,
ObservabilityPageTemplate,
}}
>
<Router history={history}>
<EuiThemeProvider darkMode={isDarkMode}>
<i18nCore.Context>
<RedirectAppLinks application={core.application}>
<RedirectAppLinks application={core.application} className={APP_WRAPPER_CLASS}>
<HasDataContextProvider>
<App />
</HasDataContextProvider>

View file

@ -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 (
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu}>
<EuiHeaderLinks>
<EuiHeaderLink
color="primary"
href={prepend('/app/home#/tutorial_directory/logging')}
iconType="indexOpen"
>
{addDataLinkText}
</EuiHeaderLink>
</EuiHeaderLinks>
</HeaderMenuPortal>
);
}
const addDataLinkText = i18n.translate('xpack.observability.home.addData', {
defaultMessage: 'Add data',
});

View file

@ -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(<Header color="#fff" />);
expect(getByTestId('observability-logo')).toBeInTheDocument();
expect(getByText('Observability')).toBeInTheDocument();
});
});

View file

@ -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 (
<Container color={color}>
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu}>
<EuiHeaderLinks>
<EuiHeaderLink
color="primary"
href={prepend('/app/home#/tutorial_directory/logging')}
iconType="indexOpen"
>
{i18n.translate('xpack.observability.home.addData', { defaultMessage: 'Add data' })}
</EuiHeaderLink>
</EuiHeaderLinks>
</HeaderMenuPortal>
<Wrapper restrictWidth={restrictWidth}>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiIcon type="logoObservability" size="xxl" data-test-subj="observability-logo" />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiTitle>
<h1>
{i18n.translate('xpack.observability.home.title', {
defaultMessage: 'Observability',
})}
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{datePicker}</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
</Wrapper>
</Container>
);
}
export * from './header_menu';

View file

@ -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)<EuiPageProps>`
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 (
<Container color={bodyColor}>
<Header color={headerColor} datePicker={datePicker} restrictWidth={restrictWidth} />
<Page restrictWidth={restrictWidth}>
<EuiPageBody>{children}</EuiPageBody>
</Page>
</Container>
);
}

View file

@ -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,
}));
});

View file

@ -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', () => {

View file

@ -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) {

View file

@ -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
<ObservabilityPageTemplate
pageHeader={{
pageTitle: SolutionPageTitle,
rightSideItems: [
// Just an example
<DatePicker
rangeFrom={relativeTime.start}
rangeTo={relativeTime.end}
refreshInterval={refreshInterval}
refreshPaused={refreshPaused}
/>,
],
}}
>
// Render anything you like here, this is just an example.
<EuiFlexGroup>
<EuiFlexItem>
// Content
</EuiFlexItem>
<EuiFlexItem>
// Content
</EuiFlexItem>
</EuiFlexGroup>
</ObservabilityPageTemplate>
```
The `<ObservabilityPageTemplate />` component is a wrapper around the `<KibanaPageTemplate />` component (which in turn is a wrapper around the `<EuiPageTemplate>` 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)

View file

@ -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';

View file

@ -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) => (
<React.Suspense fallback={null}>
<LazyObservabilityPageTemplate {...pageTemplateProps} {...injectedDeps} />
</React.Suspense>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

View file

@ -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(
<LazyObservabilityPageTemplate
pageHeader={{
pageTitle: 'Test title',
rightSideItems: [<span>Test side item</span>],
}}
>
<div>Test structure</div>
</LazyObservabilityPageTemplate>
);
expect(component.exists('lazy')).toBe(true);
});
it('Utilises the KibanaPageTemplate for rendering', () => {
const component = shallow(
<ObservabilityPageTemplate
currentAppId$={of('Test app ID')}
getUrlForApp={() => '/test-url'}
navigateToApp={async () => {}}
navigationSections$={navigationRegistry.sections$}
pageHeader={{
pageTitle: 'Test title',
rightSideItems: [<span>Test side item</span>],
}}
>
<div>Test structure</div>
</ObservabilityPageTemplate>
);
expect(component.is('KibanaPageTemplate'));
});
it('Handles outputting the registered navigation structures within a side nav', () => {
const { container } = render(
<I18nProvider>
<ObservabilityPageTemplate
currentAppId$={of('Test app ID')}
getUrlForApp={() => '/test-url'}
navigateToApp={async () => {}}
navigationSections$={navigationRegistry.sections$}
pageHeader={{
pageTitle: 'Test title',
rightSideItems: [<span>Test side item</span>],
}}
>
<div>Test structure</div>
</ObservabilityPageTemplate>
</I18nProvider>
);
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');
});
});

View file

@ -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<KibanaPageTemplateProps, 'bottomBar' | 'bottomBarProps'>,
{ template: KibanaPageTemplateProps['template'] }
>;
export interface ObservabilityPageTemplateDependencies {
currentAppId$: Observable<string | undefined>;
getUrlForApp: ApplicationStart['getUrlForApp'];
navigateToApp: ApplicationStart['navigateToApp'];
navigationSections$: Observable<NavigationSection[]>;
}
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<Array<EuiSideNavItemType<unknown>>>(
() =>
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 (
<KibanaPageTemplate
restrictWidth={false}
{...pageTemplateProps}
solutionNav={{
icon: 'logoObservability',
items: sideNavItems,
name: sideNavTitle,
}}
>
{children}
</KibanaPageTemplate>
);
}
// for lazy import
// eslint-disable-next-line import/no-default-export
export default ObservabilityPageTemplate;
const sideNavTitle = i18n.translate('xpack.observability.pageLayout.sideNavTitle', {
defaultMessage: 'Observability',
});

View file

@ -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<LazyObservabilityPageTemplateProps>;
}
export const PluginContext = createContext({} as PluginContextValue);

View file

@ -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', () => {

View file

@ -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 (
<EuiPageTemplate
<ObservabilityPageTemplate
pageHeader={{
pageTitle: (
<>
@ -139,7 +131,6 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
<ExperimentalBadge />
</>
),
rightSideItems: [
<EuiButton fill href={manageDetectionRulesHref} iconType="gear">
{i18n.translate('xpack.observability.alerts.manageDetectionRulesButtonLabel', {
@ -206,6 +197,6 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiPageTemplate>
</ObservabilityPageTemplate>
);
}

View file

@ -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 (
<EuiPageTemplate
<ObservabilityPageTemplate
pageHeader={{
pageTitle: (
<>
@ -44,6 +46,6 @@ export function CasesPage(props: CasesProps) {
</EuiCallOut>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageTemplate>
</ObservabilityPageTemplate>
);
}

View file

@ -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 (
<WithHeaderLayout
restrictWidth={1200}
headerColor={theme.eui.euiPageBackgroundColor}
bodyColor={theme.eui.euiColorEmptyShade}
>
<ObservabilityPageTemplate restrictWidth={1200}>
<ObservabilityHeaderMenu />
<EuiFlexGroup direction="column">
{/* title and description */}
<EuiFlexItem className="obsLanding__title">
@ -128,6 +125,6 @@ export function LandingPage() {
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</WithHeaderLayout>
</ObservabilityPageTemplate>
);
}

View file

@ -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 (
<WithHeaderLayout
headerColor={theme.eui.euiColorEmptyShade}
bodyColor={theme.eui.euiPageBackgroundColor}
datePicker={
<DatePicker
rangeFrom={relativeTime.start}
rangeTo={relativeTime.end}
refreshInterval={refreshInterval}
refreshPaused={refreshPaused}
/>
}
<ObservabilityPageTemplate
pageHeader={{
pageTitle: overviewPageTitle,
rightSideItems: [
<DatePicker
rangeFrom={relativeTime.start}
rangeTo={relativeTime.end}
refreshInterval={refreshInterval}
refreshPaused={refreshPaused}
/>,
],
}}
>
<ObservabilityHeaderMenu />
<EuiFlexGroup>
<EuiFlexItem grow={6}>
{/* Data sections */}
@ -107,6 +108,10 @@ export function OverviewPage({ routeParams }: Props) {
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</WithHeaderLayout>
</ObservabilityPageTemplate>
);
}
const overviewPageTitle = i18n.translate('xpack.observability.overview.pageTitle', {
defaultMessage: 'Overview',
});

View file

@ -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 (
<WithHeaderLayout
headerColor={theme.eui.euiColorEmptyShade}
bodyColor={theme.eui.euiPageBackgroundColor}
>
<CentralizedFlexGroup>
<ObservabilityPageTemplate template="centeredContent">
<ObservabilityHeaderMenu />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ justifyContent: 'center' }}>
<EuiText>
{i18n.translate('xpack.observability.overview.loadingObservability', {
defaultMessage: 'Loading Observability',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
</CentralizedFlexGroup>
</WithHeaderLayout>
<EuiFlexItem grow={false} style={{ justifyContent: 'center' }}>
<EuiText>{observabilityLoadingMessage}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</ObservabilityPageTemplate>
);
}
const observabilityLoadingMessage = i18n.translate(
'xpack.observability.overview.loadingObservability',
{
defaultMessage: 'Loading Observability',
}
);

View file

@ -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,
}}
>
<EuiThemeProvider>

View file

@ -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<Plugin['setup']>;
@ -50,7 +52,7 @@ export interface ObservabilityPublicPluginsStart {
lens: LensPublicStart;
}
export type ObservabilityPublicStart = void;
export type ObservabilityPublicStart = ReturnType<Plugin['start']>;
export class Plugin
implements
@ -61,13 +63,14 @@ export class Plugin
ObservabilityPublicPluginsStart
> {
private readonly appUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
private readonly navigationRegistry = createNavigationRegistry();
constructor(private readonly initializerContext: PluginInitializerContext<ConfigSchema>) {
this.initializerContext = initializerContext;
}
public setup(
coreSetup: CoreSetup<ObservabilityPublicPluginsStart>,
coreSetup: CoreSetup<ObservabilityPublicPluginsStart, ObservabilityPublicStart>,
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,
},
};
}
}

View file

@ -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',
},
],
},
]);
});
});

View file

@ -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<NavigationSection[]>) => void;
sections$: Observable<NavigationSection[]>;
}
export const createNavigationRegistry = (): NavigationRegistry => {
const registeredSections$ = new ReplaySubject<Observable<NavigationSection[]>>();
const registerSections = (sections$: Observable<NavigationSection[]>) => {
registeredSections$.next(sections$);
};
const sections$: Observable<NavigationSection[]> = registeredSections$.pipe(
scan(
(accumulatedSections$, newSections) => accumulatedSections$.add(newSections),
new Set<Observable<NavigationSection[]>>()
),
switchMap((registeredSections) => combineLatest([...registeredSections])),
map((registeredSections) =>
registeredSections.flat().sort((first, second) => first.sortKey - second.sortKey)
),
shareReplay(1)
);
return {
registerSections,
sections$,
};
};

View file

@ -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) => {
<IntlProvider locale="en-US" messages={translations.messages}>
<KibanaContextProvider services={{ ...core }}>
<PluginContext.Provider
value={{ appMountParameters, config, core, plugins, observabilityRuleTypeRegistry }}
value={{
appMountParameters,
config,
core,
plugins,
observabilityRuleTypeRegistry,
ObservabilityPageTemplate: KibanaPageTemplate,
}}
>
<EuiThemeProvider>{component}</EuiThemeProvider>
</PluginContext.Provider>

View file

@ -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": "新機能",

View file

@ -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": "最新动态",