[Observability] Add Observability Shared app (#154716)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Coen Warmer 2023-04-11 21:51:59 +02:00 committed by GitHub
parent 962e91556c
commit 26f65b3262
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1660 additions and 6 deletions

1
.github/CODEOWNERS vendored
View file

@ -475,6 +475,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

View file

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

View file

@ -490,6 +490,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",

View file

@ -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[\/\\]/,
],
};

View file

@ -94,6 +94,7 @@ pageLoadAssetSize:
navigation: 37269
newsfeed: 42228
observability: 95000
observabilityShared: 21266
osquery: 107090
painlessLab: 179748
presentationUtil: 58834

View file

@ -944,6 +944,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"],

View file

@ -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": [

View file

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

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.
*/
module.exports = require('@kbn/storybook').defaultConfig;

View file

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

View file

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

View file

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

View file

@ -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: ['<rootDir>/x-pack/plugins/observability_shared'],
setupFiles: ['<rootDir>/x-pack/plugins/observability_shared/.storybook/jest_setup.js'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/observability_shared',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/observability_shared/{common,public,server}/**/*.{js,ts,tsx}',
],
};

View file

@ -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"]
}
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

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

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

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

View file

@ -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 (
<>
<LabelContainer className="eui-textTruncate">
<span>{label}</span>
</LabelContainer>
{isBadgeVisible && (
<StyledBadge color="accent">
{i18n.translate('xpack.observabilityShared.navigation.newBadge', {
defaultMessage: 'NEW',
})}
</StyledBadge>
)}
</>
);
}

View file

@ -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 (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<span className="eui-textTruncate">
<span>{label}</span>
</span>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ height: 20 }}>
{isTechnicalPreview ? (
<EuiBetaBadge
color="hollow"
size="s"
label={i18n.translate('xpack.observabilityShared.navigation.experimentalBadgeLabel', {
defaultMessage: 'Technical preview',
})}
iconType={iconType}
/>
) : (
<EuiBetaBadge
color="hollow"
size="s"
label={i18n.translate('xpack.observabilityShared.navigation.betaBadge', {
defaultMessage: 'Beta',
})}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

View file

@ -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(
<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>],
}}
getPageTemplateServices={getPageTemplateServices}
guidedOnboardingApi={guidedOnboardingMock.createStart().guidedOnboardingApi}
>
<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>],
}}
getPageTemplateServices={getPageTemplateServices}
guidedOnboardingApi={guidedOnboardingMock.createStart().guidedOnboardingApi}
>
<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,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<HTMLElement | HTMLButtonElement, 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<string | undefined>;
getUrlForApp: ApplicationStart['getUrlForApp'];
navigateToApp: ApplicationStart['navigateToApp'];
navigationSections$: Observable<NavigationSection[]>;
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<Array<EuiSideNavItemType<unknown>>>(
() =>
sections.map(({ label, entries, isBetaFeature }, sectionIndex) => ({
id: `${sectionIndex}`,
name: isBetaFeature ? <NavNameWithBetaBadge label={label} /> : 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 ? (
<NavNameWithBetaBadge label={entry.label} />
) : entry.isNewFeature ? (
<NavNameWithBadge label={entry.label} localStorageId={badgeLocalStorageId} />
) : entry.isTechnicalPreview ? (
<NavNameWithBetaBadge
label={entry.label}
iconType="beaker"
isTechnicalPreview={true}
/>
) : (
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 (
<KibanaPageTemplateKibanaProvider {...getPageTemplateServices()}>
<ObservabilityTour
navigateToApp={navigateToApp}
prependBasePath={services?.http?.basePath.prepend}
guidedOnboardingApi={guidedOnboardingApi}
isPageDataLoaded={isPageDataLoaded}
// The tour is dependent on the solution nav, and should not render if it is not visible
showTour={showSolutionNav}
>
{({ isTourVisible }) => {
return (
<KibanaPageTemplate
restrictWidth={false}
{...pageTemplateProps}
solutionNav={
showSolutionNav
? {
icon: 'logoObservability',
items: sideNavItems,
name: sideNavTitle,
// Only false if tour is active
canBeCollapsed: isTourVisible === false,
}
: undefined
}
>
<EuiErrorBoundary>
<KibanaPageTemplate.Section
component="div"
alignment={pageTemplateProps.isEmptyState ? 'center' : 'top'}
{...pageSectionProps}
>
{children}
</KibanaPageTemplate.Section>
</EuiErrorBoundary>
{bottomBar && (
<KibanaPageTemplate.BottomBar {...bottomBarProps}>
{bottomBar}
</KibanaPageTemplate.BottomBar>
)}
</KibanaPageTemplate>
);
}}
</ObservabilityTour>
</KibanaPageTemplateKibanaProvider>
);
}
// 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) => (
<React.Suspense fallback={null}>
<LazyObservabilityPageTemplate {...pageTemplateProps} {...injectedDeps} />
</React.Suspense>
);
}

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 { ObservabilityTour, observTourStepStorageKey, useObservabilityTourContext } from './tour';

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 { 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,
},
];

View file

@ -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 = (
<EuiFlexGroup gutterSize="s" alignItems="baseline">
<EuiFlexItem>
<EuiButtonEmpty
onClick={() => 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',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
onClick={() => 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',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
const lastStepFooterAction = (
// data-test-subj is used for testing and to track FS usage
<EuiButtonEmpty
size="xs"
color="text"
onClick={() => endTour()}
data-test-subj="onboarding--observTourEndButton"
>
{i18n.translate('xpack.observabilityShared.tour.endButtonLabel', {
defaultMessage: 'End tour',
})}
</EuiButtonEmpty>
);
return tourStepsConfig.map((stepConfig, index) => {
const step = index + 1;
const { dataTestSubj, content, offset: stepOffset, imageConfig, ...tourStepProps } = stepConfig;
return (
<EuiTourStep
{...tourStepProps}
key={step}
step={step}
minWidth={minWidth}
maxWidth={maxWidth}
offset={stepOffset ?? offset}
repositionOnScroll={repositionOnScroll}
stepsTotal={tourStepsConfig.length}
isStepOpen={step === activeStep}
onFinish={() => endTour()}
footerAction={activeStep === tourStepsConfig.length ? lastStepFooterAction : footerAction}
panelProps={{
'data-test-subj': dataTestSubj,
}}
content={
<>
<EuiText size="s">
<p>{content}</p>
</EuiText>
{imageConfig && prependBasePath && (
<>
<EuiSpacer size="m" />
<EuiImage
alt={imageConfig.altText}
src={prependBasePath(`/plugins/observability/assets/${imageConfig.name}`)}
size="fullWidth"
/>
</>
)}
</>
}
/>
);
});
};
export interface ObservabilityTourContextValue {
endTour: () => void;
isTourVisible: boolean;
}
const ObservabilityTourContext = createContext<ObservabilityTourContextValue>({
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 (
<ObservabilityTourContext.Provider value={context}>
<>
{children({ isTourVisible })}
{isTourVisible && getSteps({ activeStep, incrementStep, endTour, prependBasePath })}
</>
</ObservabilityTourContext.Provider>
);
}
export const useObservabilityTourContext = (): ObservabilityTourContextValue => {
const ctx = useContext(ObservabilityTourContext);
if (!ctx) {
throw new Error('useObservabilityTourContext can only be called inside of TourContext');
}
return ctx;
};

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

View file

@ -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<ObservabilitySharedPlugin['setup']>;
export type ObservabilitySharedPluginStart = ReturnType<ObservabilitySharedPlugin['start']>;
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() {}
}

View file

@ -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<AppUpdater>;
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<AppUpdater>;
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<AppUpdater>;
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<AppUpdater>;
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<AppUpdater>;
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<AppUpdater>;
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<AppUpdater>;
updateGlobalNavigation({ capabilities, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
...sloRoute,
navLinkStatus: AppNavLinkStatus.visible,
},
],
navLinkStatus: AppNavLinkStatus.visible,
});
});
});
});
});

View file

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

View file

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

View file

@ -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/**/*"]
}

View file

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

View file

@ -4625,6 +4625,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 ""