[serverless] Create the Serverless Plugin (#155582)

> Derived from https://github.com/elastic/kibana/pull/153274 for
production.

## Summary

This PR creates the `serverless` plugin for Kibana Serverless projects.


![image](https://user-images.githubusercontent.com/297604/233892935-b3713575-a2f7-4e82-a9dd-e8c11823683f.png)


It uses the methodology proven out in the proof-of-concept
(https://github.com/elastic/kibana/pull/153274) and prepares it for
production:

- Adds chrome style and related API to the `chrome` services.
- Creates the `serverless` plugin.
- Invokes the new chrome style API for all serverless projects.
- Alters `yarn` scripts to support all project types, and switching
between them.
- Creates the new "Project Switcher" component for use in the new chrome
header for Serverless.
- Creates a Storybook config for this and future components.
- Adds API endpoint to trigger project switching and `Watcher` restarts.

<img width="1598" alt="Screenshot 2023-04-26 at 10 44 01 AM"
src="https://user-images.githubusercontent.com/297604/234612654-fdcf38ea-8c48-4066-bc85-507f40c984aa.png">


## Next steps

- [x] Creating a PR for enabling/disabling related plugins for
Serverless. (https://github.com/elastic/kibana/pull/155583)
- [ ] Creating product plugin PR based on
https://github.com/elastic/kibana/pull/153274.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Clint Andrew Hall 2023-04-26 14:48:23 -04:00 committed by GitHub
parent 5e713fb225
commit 8e37b38417
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1623 additions and 58 deletions

View file

@ -43,6 +43,7 @@ const STORYBOOKS = [
'observability',
'presentation',
'security_solution',
'serverless',
'shared_ux',
'triggers_actions_ui',
'ui_actions_enhanced',

4
.github/CODEOWNERS vendored
View file

@ -572,6 +572,10 @@ packages/kbn-securitysolution-t-grid @elastic/security-solution-platform
packages/kbn-securitysolution-utils @elastic/security-solution-platform
packages/kbn-server-http-tools @elastic/kibana-core
packages/kbn-server-route-repository @elastic/apm-ui
x-pack/plugins/serverless @elastic/appex-sharedux
packages/serverless/project_switcher @elastic/appex-sharedux
packages/serverless/storybook/config @elastic/appex-sharedux
packages/serverless/types @elastic/appex-sharedux
test/plugin_functional/plugins/session_notifications @elastic/kibana-core
x-pack/plugins/session_view @elastic/sec-cloudnative-integrations
packages/kbn-set-map @elastic/kibana-operations

View file

@ -83,6 +83,7 @@
"share": "src/plugins/share",
"sharedUXPackages": "packages/shared-ux",
"securitySolutionPackages": "x-pack/packages/security-solution",
"serverlessPackages": "packages/serverless",
"coloring": "packages/kbn-coloring/src",
"languageDocumentationPopover": "packages/kbn-language-documentation-popover/src",
"statusPage": "src/legacy/core_plugins/status_page",

View file

@ -0,0 +1 @@
xpack.serverless.plugin.developer.projectSwitcher.currentType: 'search'

View file

@ -1 +1,2 @@
xpack.infra.logs.app_target: discover
xpack.serverless.plugin.developer.projectSwitcher.currentType: 'observability'

View file

@ -0,0 +1 @@
xpack.serverless.plugin.developer.projectSwitcher.currentType: 'security'

View file

@ -1,4 +1,6 @@
newsfeed.enabled: false
xpack.security.showNavLinks: false
xpack.serverless.plugin.enabled: true
xpack.fleet.enableExperimental: ['fleetServerStandalone']
xpack.fleet.internal.disableILMPolicies: true

View file

@ -706,6 +706,10 @@ Kibana.
|Welcome to the Kibana Security Solution plugin! This README will go over getting started with development and testing.
|{kib-repo}blob/{branch}/x-pack/plugins/serverless/README.mdx[serverless]
|
|{kib-repo}blob/{branch}/x-pack/plugins/session_view/README.md[sessionView]
|Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time.

View file

@ -54,6 +54,7 @@
"lint:es": "node scripts/eslint",
"lint:style": "node scripts/stylelint",
"makelogs": "node scripts/makelogs",
"serverless": "node scripts/kibana --dev --serverless",
"serverless-es": "node scripts/kibana --dev --serverless=es",
"serverless-oblt": "node scripts/kibana --dev --serverless=oblt",
"serverless-security": "node scripts/kibana --dev --serverless=security",
@ -573,6 +574,9 @@
"@kbn/securitysolution-utils": "link:packages/kbn-securitysolution-utils",
"@kbn/server-http-tools": "link:packages/kbn-server-http-tools",
"@kbn/server-route-repository": "link:packages/kbn-server-route-repository",
"@kbn/serverless": "link:x-pack/plugins/serverless",
"@kbn/serverless-project-switcher": "link:packages/serverless/project_switcher",
"@kbn/serverless-types": "link:packages/serverless/types",
"@kbn/session-notifications-plugin": "link:test/plugin_functional/plugins/session_notifications",
"@kbn/session-view-plugin": "link:x-pack/plugins/session_view",
"@kbn/set-map": "link:packages/kbn-set-map",
@ -1112,6 +1116,7 @@
"@kbn/repo-source-classifier": "link:packages/kbn-repo-source-classifier",
"@kbn/repo-source-classifier-cli": "link:packages/kbn-repo-source-classifier-cli",
"@kbn/security-api-integration-helpers": "link:x-pack/test/security_api_integration/packages/helpers",
"@kbn/serverless-storybook-config": "link:packages/serverless/storybook/config",
"@kbn/some-dev-log": "link:packages/kbn-some-dev-log",
"@kbn/sort-package-json": "link:packages/kbn-sort-package-json",
"@kbn/spec-to-console": "link:packages/kbn-spec-to-console",

View file

@ -126,6 +126,7 @@ describe('start', () => {
Array [
Array [
"kbnBody",
"kbnBody--classicLayout",
"kbnBody--noHeaderBanner",
"kbnBody--chromeHidden",
"kbnVersion-1-2-3",
@ -143,6 +144,7 @@ describe('start', () => {
Array [
Array [
"kbnBody",
"kbnBody--classicLayout",
"kbnBody--noHeaderBanner",
"kbnBody--chromeHidden",
"kbnVersion-8-0-0",

View file

@ -26,6 +26,7 @@ import type {
ChromeGlobalHelpExtensionMenuLink,
ChromeHelpExtension,
ChromeUserBanner,
ChromeStyle,
} from '@kbn/core-chrome-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import { KIBANA_ASK_ELASTIC_LINK } from './constants';
@ -33,7 +34,7 @@ import { DocTitleService } from './doc_title';
import { NavControlsService } from './nav_controls';
import { NavLinksService } from './nav_links';
import { RecentlyAccessedService } from './recently_accessed';
import { Header } from './ui';
import { Header, ProjectHeader } from './ui';
import type { InternalChromeStart } from './types';
const IS_LOCKED_KEY = 'core.chrome.isLocked';
@ -119,6 +120,7 @@ export class ChromeService {
const customNavLink$ = new BehaviorSubject<ChromeNavLink | undefined>(undefined);
const helpSupportUrl$ = new BehaviorSubject<string>(KIBANA_ASK_ELASTIC_LINK);
const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true');
const chromeStyle$ = new BehaviorSubject<ChromeStyle>('classic');
const getKbnVersionClass = () => {
// we assume that the version is valid and has the form 'X.X.X'
@ -131,10 +133,11 @@ export class ChromeService {
};
const headerBanner$ = new BehaviorSubject<ChromeUserBanner | undefined>(undefined);
const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe(
map(([headerBanner, isVisible]) => {
const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!, chromeStyle$]).pipe(
map(([headerBanner, isVisible, chromeStyle]) => {
return [
'kbnBody',
chromeStyle === 'project' ? 'kbnBody--projectLayout' : 'kbnBody--classicLayout',
headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner',
isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden',
getKbnVersionClass(),
@ -163,6 +166,10 @@ export class ChromeService {
const getIsNavDrawerLocked$ = isNavDrawerLocked$.pipe(takeUntil(this.stop$));
const setChromeStyle = (style: ChromeStyle) => {
chromeStyle$.next(style);
};
const isIE = () => {
const ua = window.navigator.userAgent;
const msie = ua.indexOf('MSIE '); // IE 10 or older
@ -203,41 +210,65 @@ export class ChromeService {
});
}
const getHeaderComponent = () => {
const Component = ({ style$ }: { style$: typeof chromeStyle$ }) => {
if (style$.getValue() === 'project') {
return (
<ProjectHeader
{...{
application,
globalHelpExtensionMenuLinks$,
}}
actionMenu$={application.currentActionMenu$}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
navControlsRight$={navControls.getRight$()}
kibanaDocLink={docLinks.links.kibana.guide}
kibanaVersion={injectedMetadata.getKibanaVersion()}
/>
);
}
return (
<Header
loadingCount$={http.getLoadingCount$()}
application={application}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
homeHref={http.basePath.prepend('/app/home')}
isVisible$={this.isVisible$}
kibanaVersion={injectedMetadata.getKibanaVersion()}
navLinks$={navLinks.getNavLinks$()}
recentlyAccessed$={recentlyAccessed.get$()}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
navControlsExtension$={navControls.getExtension$()}
onIsLockedUpdate={setIsNavDrawerLocked}
isLocked$={getIsNavDrawerLocked$}
customBranding$={customBranding$}
/>
);
};
return <Component {...{ style$: chromeStyle$ }} />;
};
return {
navControls,
navLinks,
recentlyAccessed,
docTitle,
getHeaderComponent: () => (
<Header
loadingCount$={http.getLoadingCount$()}
application={application}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
homeHref={http.basePath.prepend('/app/home')}
isVisible$={this.isVisible$}
kibanaVersion={injectedMetadata.getKibanaVersion()}
navLinks$={navLinks.getNavLinks$()}
recentlyAccessed$={recentlyAccessed.get$()}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
navControlsExtension$={navControls.getExtension$()}
onIsLockedUpdate={setIsNavDrawerLocked}
isLocked$={getIsNavDrawerLocked$}
customBranding$={customBranding$}
/>
),
getHeaderComponent,
getIsVisible$: () => this.isVisible$,
@ -302,6 +333,8 @@ export class ChromeService {
},
getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)),
setChromeStyle,
getChromeStyle$: () => chromeStyle$.pipe(takeUntil(this.stop$)),
};
}

View file

@ -7,5 +7,6 @@
*/
export { Header } from './header';
export { ProjectHeader } from './project';
export { LoadingIndicator } from './loading_indicator';
export type { NavType } from './header';

View file

@ -0,0 +1,90 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { Router } from 'react-router-dom';
import { EuiHeader, EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem } from '@elastic/eui';
import {
ChromeBreadcrumb,
ChromeGlobalHelpExtensionMenuLink,
ChromeHelpExtension,
ChromeNavControl,
} from '@kbn/core-chrome-browser/src';
import { Observable } from 'rxjs';
import { MountPoint } from '@kbn/core-mount-utils-browser';
import { InternalApplicationStart } from '@kbn/core-application-browser-internal';
import { HeaderBreadcrumbs } from '../header/header_breadcrumbs';
import { HeaderActionMenu } from '../header/header_action_menu';
import { HeaderHelpMenu } from '../header/header_help_menu';
import { HeaderNavControls } from '../header/header_nav_controls';
import { ProjectNavigation } from './navigation';
interface Props {
breadcrumbs$: Observable<ChromeBreadcrumb[]>;
actionMenu$: Observable<MountPoint | undefined>;
kibanaDocLink: string;
globalHelpExtensionMenuLinks$: Observable<ChromeGlobalHelpExtensionMenuLink[]>;
helpExtension$: Observable<ChromeHelpExtension | undefined>;
helpSupportUrl$: Observable<string>;
kibanaVersion: string;
application: InternalApplicationStart;
navControlsRight$: Observable<ChromeNavControl[]>;
}
export const ProjectHeader = ({
application,
kibanaDocLink,
kibanaVersion,
...observables
}: Props) => {
const renderLogo = () => (
<EuiHeaderLogo
iconType="logoElastic"
href="#"
onClick={(e) => e.preventDefault()}
aria-label="Go to home page"
/>
);
return (
<>
<EuiHeader position="fixed">
<EuiHeaderSection grow={false}>
<EuiHeaderSectionItem border="right">{renderLogo()}</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
<HeaderBreadcrumbs breadcrumbs$={observables.breadcrumbs$} />
</EuiHeaderSectionItem>
</EuiHeaderSection>
<EuiHeaderSection side="right">
<EuiHeaderSectionItem>
<HeaderHelpMenu
globalHelpExtensionMenuLinks$={observables.globalHelpExtensionMenuLinks$}
helpExtension$={observables.helpExtension$}
helpSupportUrl$={observables.helpSupportUrl$}
kibanaDocLink={kibanaDocLink}
kibanaVersion={kibanaVersion}
navigateToUrl={application.navigateToUrl}
/>
</EuiHeaderSectionItem>
<EuiHeaderSectionItem border="left">
<HeaderActionMenu actionMenu$={observables.actionMenu$} />
</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
<HeaderNavControls navControls$={observables.navControlsRight$} />
</EuiHeaderSectionItem>
</EuiHeaderSection>
</EuiHeader>
<Router history={application.history}>
<ProjectNavigation>
<span />
</ProjectNavigation>
</Router>
</>
);
};

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { ProjectHeader } from './header';

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiCollapsibleNav, EuiThemeProvider, useEuiTheme } from '@elastic/eui';
const LOCAL_STORAGE_IS_OPEN_KEY = 'PROJECT_NAVIGATION_OPEN' as const;
const SIZE_OPEN = 248;
const SIZE_CLOSED = 40;
const buttonCSS = css`
margin-left: -32px;
margin-top: 12px;
position: fixed;
z-index: 1000;
`;
const openAriaLabel = i18n.translate('core.ui.chrome.projectNav.collapsibleNavOpenAriaLabel', {
defaultMessage: 'Close navigation',
});
const closedAriaLabel = i18n.translate('core.ui.chrome.projectNav.collapsibleNavClosedAriaLabel', {
defaultMessage: 'Open navigation',
});
export const ProjectNavigation: React.FC = ({ children }) => {
const { euiTheme, colorMode } = useEuiTheme();
const [isOpen, setIsOpen] = useLocalStorage(LOCAL_STORAGE_IS_OPEN_KEY, true);
const toggleOpen = useCallback(() => {
setIsOpen(!isOpen);
}, [isOpen, setIsOpen]);
const collabsibleNavCSS = css`
border-inline-end-width: 1,
background: ${euiTheme.colors.darkestShade},
display: flex,
flex-direction: row,
`;
return (
<EuiThemeProvider colorMode={colorMode === 'DARK' ? 'LIGHT' : 'DARK'}>
<EuiCollapsibleNav
css={collabsibleNavCSS}
isOpen={true}
showButtonIfDocked={true}
onClose={toggleOpen}
isDocked={true}
size={isOpen ? SIZE_OPEN : SIZE_CLOSED}
hideCloseButton={false}
button={
<span css={buttonCSS}>
<EuiButtonIcon
iconType={isOpen ? 'menuLeft' : 'menuRight'}
aria-label={isOpen ? openAriaLabel : closedAriaLabel}
color="text"
onClick={toggleOpen}
/>
</span>
}
>
{isOpen && children}
</EuiCollapsibleNav>
</EuiThemeProvider>
);
};

View file

@ -5,7 +5,10 @@
"types": [
"jest",
"node",
"react"
"react",
"@kbn/ambient-ui-types",
"@kbn/ambient-storybook-types",
"@emotion/react/types/css-prop"
]
},
"include": [

View file

@ -61,6 +61,8 @@ const createStartContractMock = () => {
setHeaderBanner: jest.fn(),
hasHeaderBanner$: jest.fn(),
getBodyClasses$: jest.fn(),
getChromeStyle$: jest.fn(),
setChromeStyle: jest.fn(),
};
startContract.navLinks.getAll.mockReturnValue([]);
startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false));

View file

@ -7,25 +7,26 @@
*/
export type {
ChromeUserBanner,
ChromeBadge,
ChromeBreadcrumb,
ChromeHelpExtension,
ChromeHelpExtensionMenuLink,
ChromeHelpExtensionLinkBase,
ChromeHelpMenuActions,
ChromeNavLink,
ChromeBreadcrumbsAppendExtension,
ChromeNavLinks,
ChromeDocTitle,
ChromeGlobalHelpExtensionMenuLink,
ChromeHelpExtension,
ChromeHelpExtensionLinkBase,
ChromeHelpExtensionMenuCustomLink,
ChromeHelpExtensionMenuDiscussLink,
ChromeHelpExtensionMenuDocumentationLink,
ChromeHelpExtensionMenuGitHubLink,
ChromeHelpExtensionMenuLink,
ChromeHelpMenuActions,
ChromeNavControl,
ChromeNavControls,
ChromeBadge,
ChromeHelpExtensionMenuGitHubLink,
ChromeHelpExtensionMenuDocumentationLink,
ChromeHelpExtensionMenuDiscussLink,
ChromeHelpExtensionMenuCustomLink,
ChromeGlobalHelpExtensionMenuLink,
ChromeDocTitle,
ChromeStart,
ChromeNavLink,
ChromeNavLinks,
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
ChromeStart,
ChromeStyle,
ChromeUserBanner,
} from './src';

View file

@ -13,7 +13,7 @@ import type { ChromeDocTitle } from './doc_title';
import type { ChromeNavControls } from './nav_controls';
import type { ChromeHelpExtension } from './help_extension';
import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb';
import type { ChromeBadge, ChromeUserBanner } from './types';
import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types';
import { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
/**
@ -150,4 +150,15 @@ export interface ChromeStart {
* Get an observable of the current header banner presence state.
*/
hasHeaderBanner$(): Observable<boolean>;
/**
* Sets the style type of the chrome.
* @param style The style type to apply to the chrome.
*/
setChromeStyle(style: ChromeStyle): void;
/**
* Get an observable of the current style type of the chrome.
*/
getChromeStyle$(): Observable<ChromeStyle>;
}

View file

@ -26,4 +26,4 @@ export type {
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
} from './recently_accessed';
export type { ChromeBadge, ChromeUserBanner } from './types';
export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types';

View file

@ -20,3 +20,6 @@ export interface ChromeBadge {
export interface ChromeUserBanner {
content: MountPoint<HTMLDivElement>;
}
/** @public */
export type ChromeStyle = 'classic' | 'project';

View file

@ -115,6 +115,7 @@ pageLoadAssetSize:
searchprofiler: 67080
security: 65433
securitySolution: 66738
serverless: 16573
sessionView: 77750
share: 71239
snapshotRestore: 79032

View file

@ -0,0 +1,12 @@
---
id: serverless/components/ProjectSwitcher
slug: /serverless/components/project-switcher
title: Project Switcher
description: A popup which allows a developer to switch between project types on their dev server.
tags: ['serverless', 'component']
date: 2023-04-23
---
When working on Serverless instances of Kibana, developers likely want to switch between different project types to test changes. This Project Switcher is intended to be placed into the header bar by the Serverless plugin when the server is in development mode to allow "quick switching" between configurations.
The connected component uses `http` to post a selection to a given API endpoint, intended to alter the YML configuration and trigger Watcher to restart the server. To that end, it will post its message to a given API endpoint and replace the content of `document.body`. The remainder of the process is left to the Serverless plugin.

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { ProjectSwitcherProps, KibanaDependencies } from './src';
export { ProjectSwitcher, ProjectSwitcherKibanaProvider, ProjectSwitcherProvider } from './src';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/serverless/project_switcher'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/serverless-project-switcher",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Services, KibanaDependencies } from '../src/types';
export const getProjectSwitcherServicesMock: () => jest.Mocked<Services> = () => ({
setProjectType: jest.fn(),
});
export const getProjectSwitcherKibanaDependenciesMock: () => jest.Mocked<KibanaDependencies> =
() => ({
coreStart: {
http: {
post: jest.fn(() => Promise.resolve({ data: {} })),
},
},
projectChangeAPIUrl: 'serverless/change_project',
});

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { action } from '@storybook/addon-actions';
import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock';
import type { ProjectSwitcherProps, Services } from '../src/types';
type PropArguments = Pick<ProjectSwitcherProps, 'currentProjectType'>;
/**
* Storybook parameters provided from the controls addon.
*/
export type ProjectSwitcherStorybookParams = Record<keyof PropArguments, any>;
/**
* Storybook mocks for the `NoDataCard` component.
*/
export class ProjectSwitcherStorybookMock extends AbstractStorybookMock<
ProjectSwitcherProps,
Services,
PropArguments,
{}
> {
propArguments = {
currentProjectType: {
control: { type: 'radio' },
options: ['observability', 'security', 'search'],
defaultValue: 'observability',
},
};
serviceArguments = {};
dependencies = [];
getProps(params?: ProjectSwitcherStorybookParams): ProjectSwitcherProps {
return {
currentProjectType: this.getArgumentValue('currentProjectType', params),
};
}
getServices(_params: ProjectSwitcherStorybookParams): Services {
return {
setProjectType: action('setProjectType'),
};
}
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/serverless-project-switcher",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { IconType } from '@elastic/eui';
import type { ProjectType } from '@kbn/serverless-types';
export const icons: Record<ProjectType, IconType> = {
observability: 'logoObservability',
security: 'logoSecurity',
search: 'logoEnterpriseSearch',
} as const;
export const labels: Record<ProjectType, string> = {
observability: 'Observability',
security: 'Security',
search: 'Enterprise Search',
} as const;
export const projectTypes: ProjectType[] = ['security', 'observability', 'search'];

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { MouseEventHandler } from 'react';
import { EuiHeaderSectionItemButton, EuiIcon } from '@elastic/eui';
import { ProjectType } from '@kbn/serverless-types';
import { icons } from './constants';
export const TEST_ID = 'projectSwitcherButton';
export interface Props {
onClick: MouseEventHandler<HTMLButtonElement>;
currentProjectType: ProjectType;
}
export const HeaderButton = ({ onClick, currentProjectType }: Props) => (
<EuiHeaderSectionItemButton
aria-label="Developer Tools"
data-test-subj={TEST_ID}
{...{ onClick }}
>
<EuiIcon type={icons[currentProjectType]} size="m" />
</EuiHeaderSectionItemButton>
);

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { KibanaDependencies, ProjectSwitcherProps } from './types';
export { ProjectSwitcher } from './switcher';
export { ProjectSwitcherKibanaProvider, ProjectSwitcherProvider } from './services';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiIcon, EuiKeyPadMenuItem, type EuiIconProps } from '@elastic/eui';
import { ProjectType } from '@kbn/serverless-types';
import { labels, icons } from './constants';
type OnChangeType = (id: string, value?: any) => void;
interface ItemProps extends Pick<EuiIconProps, 'type'> {
type: ProjectType;
onChange: (type: ProjectType) => void;
isSelected: boolean;
}
export const SwitcherItem = ({ type: id, onChange, isSelected }: ItemProps) => (
<EuiKeyPadMenuItem
checkable="single"
name="projectSelection"
label={labels[id]}
onChange={onChange as OnChangeType}
{...{ isSelected, id }}
>
<EuiIcon type={icons[id]} size="l" />
</EuiKeyPadMenuItem>
);

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { Logo, type Props } from './logo';
export const Loader = (props: Props) => (
<div className="kbnWelcomeView" id="kbn_loading_message" data-test-subj="kbnLoadingMessage">
<div className="kbnLoaderWrap">
<Logo {...props} />
<div className="kbnWelcomeText">Loading Project</div>
<div className="kbnProgress" />
</div>
</div>
);

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiIcon } from '@elastic/eui';
import type { ProjectType } from '@kbn/serverless-types';
export interface Props {
project: ProjectType;
}
export const Logo = ({ project }: Props) => {
let type = 'logoElastic';
switch (project) {
case 'search':
type = 'logoElasticsearch';
break;
case 'security':
type = 'logoSecurity';
break;
case 'observability':
type = 'logoObservability';
break;
}
return <EuiIcon type={type} size="xxl" />;
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, useContext } from 'react';
import ReactDOM from 'react-dom';
import { Loader } from './loader';
import type { Services, KibanaDependencies } from './types';
const Context = React.createContext<Services | null>(null);
/**
* A Context Provider that provides services to the component and its dependencies.
*/
export const ProjectSwitcherProvider: FC<Services> = ({ children, ...services }) => {
return <Context.Provider value={services}>{children}</Context.Provider>;
};
/**
* Kibana-specific Provider that maps dependencies to services.
*/
export const ProjectSwitcherKibanaProvider: FC<KibanaDependencies> = ({
children,
coreStart,
projectChangeAPIUrl,
}) => {
const value: Services = {
setProjectType: (projectType) => {
coreStart.http
.post(projectChangeAPIUrl, { body: JSON.stringify({ id: projectType }) })
.then(() => {
ReactDOM.render(<Loader project={projectType} />, document.body);
// Give the watcher a couple of seconds to see the file change.
setTimeout(() => {
window.location.href = '/';
}, 2000);
});
},
};
return <Context.Provider {...{ value }}>{children}</Context.Provider>;
};
/**
* React hook for accessing pre-wired services.
*/
export function useServices() {
const context = useContext(Context);
if (!context) {
throw new Error(
'ProjectSwitcher Context is missing. Ensure your component or React root is wrapped with ProjectSwitcherContext.'
);
}
return context;
}

View file

@ -0,0 +1,73 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { css } from '@emotion/react';
import { EuiPopover, useGeneratedHtmlId, EuiPopoverTitle, EuiKeyPadMenu } from '@elastic/eui';
import { ProjectType } from '@kbn/serverless-types';
import { SwitcherItem } from './item';
import type { ProjectSwitcherComponentProps } from './types';
import { HeaderButton } from './header_button';
import { projectTypes } from './constants';
export { TEST_ID as TEST_ID_BUTTON } from './header_button';
export const TEST_ID_ITEM_GROUP = 'projectSwitcherItemGroup';
const switcherCSS = css`
min-width: 240px;
`;
export const ProjectSwitcher = ({
currentProjectType,
onProjectChange,
}: ProjectSwitcherComponentProps) => {
const [isOpen, setIsOpen] = useState(false);
const id = useGeneratedHtmlId({
prefix: 'switcherPopover',
});
const closePopover = () => {
setIsOpen(false);
};
const onButtonClick = () => {
setIsOpen(!isOpen);
};
const onChange = (projectType: ProjectType) => {
closePopover();
onProjectChange(projectType);
return false;
};
const items = projectTypes.map((type) => (
<SwitcherItem
key={type}
type={type}
onChange={onChange}
isSelected={currentProjectType === type}
/>
));
const button = <HeaderButton onClick={onButtonClick} {...{ currentProjectType }} />;
return (
<EuiPopover
{...{ id, button, isOpen, closePopover }}
anchorPosition="downRight"
repositionOnScroll
>
<EuiPopoverTitle>Switch Project Type</EuiPopoverTitle>
<EuiKeyPadMenu css={switcherCSS} data-test-subj={TEST_ID_ITEM_GROUP}>
{items}
</EuiKeyPadMenu>
</EuiPopover>
);
};

View file

@ -0,0 +1,42 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
ProjectSwitcherStorybookMock,
type ProjectSwitcherStorybookParams,
} from '../mocks/storybook.mock';
import { ProjectSwitcher as Component } from './switcher';
import { ProjectSwitcherProvider as Provider } from './services';
import mdx from '../README.mdx';
export default {
title: 'Developer/Project Switcher',
description: '',
parameters: {
docs: {
page: mdx,
},
},
};
const mock = new ProjectSwitcherStorybookMock();
const argTypes = mock.getArgumentTypes();
export const ProjectSwitcher = (params: ProjectSwitcherStorybookParams) => {
return (
<Provider {...mock.getServices(params)}>
<Component {...params} />
</Provider>
);
};
ProjectSwitcher.argTypes = argTypes;

View file

@ -0,0 +1,151 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render, RenderResult, screen, within, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProjectType } from '@kbn/serverless-types';
import { ProjectSwitcherKibanaProvider, ProjectSwitcherProvider } from './services';
import {
getProjectSwitcherKibanaDependenciesMock,
getProjectSwitcherServicesMock,
} from '../mocks/jest.mock';
import { ProjectSwitcher } from './switcher';
import {
ProjectSwitcher as ProjectSwitcherComponent,
TEST_ID_BUTTON,
TEST_ID_ITEM_GROUP,
} from './switcher.component';
import { KibanaDependencies, Services } from './types';
const renderKibanaProjectSwitcher = (
currentProjectType: ProjectType = 'observability'
): [RenderResult, jest.Mocked<KibanaDependencies>] => {
const mock = getProjectSwitcherKibanaDependenciesMock();
return [
render(
<ProjectSwitcherKibanaProvider {...mock}>
<ProjectSwitcher {...{ currentProjectType }} />
</ProjectSwitcherKibanaProvider>
),
mock,
];
};
const renderProjectSwitcher = (
currentProjectType: ProjectType = 'observability'
): [RenderResult, jest.Mocked<Services>] => {
const mock = getProjectSwitcherServicesMock();
return [
render(
<ProjectSwitcherProvider {...mock}>
<ProjectSwitcher {...{ currentProjectType }} />
</ProjectSwitcherProvider>
),
mock,
];
};
describe('ProjectSwitcher', () => {
describe('Component', () => {
test('is rendered', () => {
expect(() =>
render(
<ProjectSwitcherComponent
currentProjectType="observability"
onProjectChange={jest.fn()}
/>
)
).not.toThrowError();
});
});
describe('Connected Component', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
test("doesn't render if the Provider is missing", () => {
expect(() => render(<ProjectSwitcher currentProjectType="observability" />)).toThrowError();
});
describe('with Services', () => {
test('is rendered', () => {
renderProjectSwitcher();
const button = screen.queryByTestId(TEST_ID_BUTTON);
expect(button).not.toBeNull();
});
test('opens', async () => {
renderProjectSwitcher();
let group = screen.queryByTestId(TEST_ID_ITEM_GROUP);
expect(group).toBeNull();
const button = screen.getByTestId(TEST_ID_BUTTON);
await waitFor(() => userEvent.click(button));
group = screen.queryByTestId(TEST_ID_ITEM_GROUP);
expect(group).not.toBeNull();
});
test('calls setProjectType when clicked', async () => {
const [_, mock] = renderProjectSwitcher();
const button = screen.getByTestId(TEST_ID_BUTTON);
await waitFor(() => userEvent.click(button));
const group = screen.getByTestId(TEST_ID_ITEM_GROUP);
const project = await within(group).findByLabelText('Security');
await waitFor(() => userEvent.click(project));
expect(mock.setProjectType).toHaveBeenCalled();
});
});
});
describe('with Kibana Dependencies', () => {
beforeEach(() => {
cleanup();
});
test('is rendered', () => {
renderKibanaProjectSwitcher();
const button = screen.queryByTestId(TEST_ID_BUTTON);
expect(button).not.toBeNull();
});
test('opens', async () => {
renderKibanaProjectSwitcher();
let group = screen.queryByTestId(TEST_ID_ITEM_GROUP);
expect(group).toBeNull();
const button = screen.getByTestId(TEST_ID_BUTTON);
userEvent.click(button);
group = screen.queryByTestId(TEST_ID_ITEM_GROUP);
expect(group).not.toBeNull();
});
test('posts message to change project', async () => {
const [_, mock] = renderKibanaProjectSwitcher();
const button = screen.getByTestId(TEST_ID_BUTTON);
await waitFor(() => userEvent.click(button));
const group = screen.getByTestId(TEST_ID_ITEM_GROUP);
const project = await within(group).findByLabelText('Security');
await waitFor(() => userEvent.click(project));
expect(mock.coreStart.http.post).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { ProjectType } from '@kbn/serverless-types';
import { ProjectSwitcher as Component } from './switcher.component';
import { useServices } from './services';
import type { ProjectSwitcherProps } from './types';
export const ProjectSwitcher = (props: ProjectSwitcherProps) => {
const { setProjectType } = useServices();
const onProjectChange = (projectType: ProjectType) => setProjectType(projectType);
return <Component {...{ onProjectChange, ...props }} />;
};

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ProjectType } from '@kbn/serverless-types';
/**
* A list of services that are consumed by this component.
*/
export interface Services {
setProjectType: (projectType: ProjectType) => void;
}
/**
* An interface containing a collection of Kibana plugins and services required to
* render this component.
*/
export interface KibanaDependencies {
coreStart: {
http: {
post: (path: string, options: { body: string }) => Promise<unknown>;
};
};
projectChangeAPIUrl: string;
}
/**
* Props for the `ProjectSwitcher` pure component.
*/
export interface ProjectSwitcherComponentProps {
onProjectChange: (projectType: ProjectType) => void;
currentProjectType: ProjectType;
}
export type ProjectSwitcherProps = Pick<ProjectSwitcherComponentProps, 'currentProjectType'>;

View file

@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/shared-ux-storybook-mock",
"@kbn/serverless-types",
]
}

View file

@ -0,0 +1,5 @@
# Serverless Storybook config
This directory contains the configuration for the Storybook deployment for all Serverless component packages.
For more information, refer to the [Storybook documentation](https://storybook.js.org/docs/react/configure/overview) and the `@kbn/storybook` package.

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/** The title of the Storybook. */
export const TITLE = 'Serverless Storybook';
/** The remote URL of the root from which Storybook loads stories for Serverless. */
export const URL = 'https://github.com/elastic/kibana/tree/main/packages/serverless';

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { TITLE, URL } from './constants';

View file

@ -0,0 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/serverless-storybook-config",
"owner": "@elastic/appex-sharedux",
"devOnly": true
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { defaultConfig } from '@kbn/storybook';
module.exports = {
...defaultConfig,
stories: ['../../**/*.stories.+(tsx|mdx)'],
reactOptions: {
strictMode: true,
},
};

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { addons } from '@storybook/addons';
import { create } from '@storybook/theming';
import { PANEL_ID as selectedPanel } from '@storybook/addon-actions';
import { TITLE as brandTitle, URL as brandUrl } from './constants';
addons.setConfig({
theme: create({
base: 'light',
brandTitle,
brandUrl,
}),
selectedPanel,
showPanel: true.valueOf,
});

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/serverless-storybook-config",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,22 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/* eslint-disable @typescript-eslint/no-namespace,@typescript-eslint/no-empty-interface */
declare global {
namespace NodeJS {
interface Global {}
interface InspectOptions {}
type ConsoleConstructor = console.ConsoleConstructor;
}
}
/* eslint-enable */
import jest from 'jest-mock';
/* @ts-expect-error TS doesn't see jest as a property of window, and I don't want to edit our global config. */
window.jest = jest;

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/storybook",
]
}

View file

@ -0,0 +1,10 @@
---
id: serverless/packages/types
slug: /serverless/packages/types
title: Serverless Typescript Types
description: A package of common types for Serverless projects.
tags: ['serverless', 'package']
date: 2023-04-23
---
This package contains common types for Serverless projects.

9
packages/serverless/types/index.d.ts vendored Normal file
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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type ProjectType = 'observability' | 'security' | 'search';

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/serverless-types",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/serverless-types",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -8,7 +8,7 @@
import { set as lodashSet } from '@kbn/safer-lodash-set';
import _ from 'lodash';
import { statSync } from 'fs';
import { statSync, copyFileSync, existsSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import url from 'url';
@ -29,7 +29,7 @@ function getServerlessProjectMode(opts) {
return null;
}
if (VALID_SERVERLESS_PROJECT_MODE.includes(opts.serverless)) {
if (VALID_SERVERLESS_PROJECT_MODE.includes(opts.serverless) || opts.serverless === true) {
return opts.serverless;
}
@ -115,6 +115,38 @@ function maybeAddConfig(name, configs, method) {
}
}
/**
* @param {string} file
* @param {'es' | 'security' | 'oblt' | true} mode
* @param {string[]} configs
* @param {'push' | 'unshift'} method
*/
function maybeSetRecentConfig(file, mode, configs, method) {
const path = resolve(getConfigDirectory(), file);
try {
if (mode === true) {
if (!existsSync(path)) {
const data = readFileSync(path.replace('recent', 'es'), 'utf-8');
writeFileSync(
path,
`${data}\nxpack.serverless.plugin.developer.projectSwitcher.enabled: true\n`
);
}
} else {
copyFileSync(path.replace('recent', mode), path);
}
configs[method](path);
} catch (err) {
if (err.code === 'ENOENT') {
return;
}
throw err;
}
}
/**
* @returns {string[]}
*/
@ -255,7 +287,15 @@ export default function (program) {
}
if (isServerlessCapableDistribution()) {
command.option('--serverless <oblt|security|es>', 'Start Kibana in a serverless project mode');
command
.option(
'--serverless',
'Start Kibana in the most recent serverless project mode, (default is es)'
)
.option(
'--serverless <oblt|security|es>',
'Start Kibana in a specific serverless project mode'
);
}
if (DEV_MODE_SUPPORTED) {
@ -285,7 +325,7 @@ export default function (program) {
// we "unshift" .serverless. config so that it only overrides defaults
if (serverlessMode) {
maybeAddConfig(`serverless.yml`, configs, 'push');
maybeAddConfig(`serverless.${serverlessMode}.yml`, configs, 'unshift');
maybeSetRecentConfig('serverless.recent.yml', serverlessMode, configs, 'unshift');
}
// .dev. configs are "pushed" so that they override all other config files
@ -293,7 +333,7 @@ export default function (program) {
maybeAddConfig('kibana.dev.yml', configs, 'push');
if (serverlessMode) {
maybeAddConfig(`serverless.dev.yml`, configs, 'push');
maybeAddConfig(`serverless.${serverlessMode}.dev.yml`, configs, 'push');
maybeSetRecentConfig('serverless.recent.dev.yml', serverlessMode, configs, 'unshift');
}
}

View file

@ -75,6 +75,9 @@
&.kbnBody--chromeHidden {
@include kbnAffordForHeader(0);
}
&.kbnBody--projectLayout {
@include kbnAffordForHeader($euiHeaderHeightCompensation);
}
&.kbnBody--chromeHidden.kbnBody--hasHeaderBanner {
@include kbnAffordForHeader($kbnHeaderBannerHeight);
}

View file

@ -46,6 +46,7 @@ export const storybookAliases = {
presentation: 'src/plugins/presentation_util/storybook',
security_solution: 'x-pack/plugins/security_solution/.storybook',
security_solution_packages: 'x-pack/packages/security-solution/storybook/config',
serverless: 'packages/serverless/storybook/config',
shared_ux: 'packages/shared-ux/storybook/config',
threat_intelligence: 'x-pack/plugins/threat_intelligence/.storybook',
triggers_actions_ui: 'x-pack/plugins/triggers_actions_ui/.storybook',

View file

@ -223,6 +223,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.security.loginAssistanceMessage (string)',
'xpack.security.sameSiteCookies (alternatives)',
'xpack.security.showInsecureClusterWarning (boolean)',
'xpack.security.showNavLinks (boolean)',
'xpack.securitySolution.enableExperimental (array)',
'xpack.securitySolution.prebuiltRulesPackageVersion (string)',
'xpack.snapshot_restore.slm_ui.enabled (boolean)',
@ -270,6 +271,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.security.loginAssistanceMessage (string)',
'xpack.security.sameSiteCookies (alternatives)',
'xpack.security.showInsecureClusterWarning (boolean)',
'xpack.security.showNavLinks (boolean)',
];
// We don't assert that actualExposedConfigKeys and expectedExposedConfigKeys are equal, because test failure messages with large
// arrays are hard to grok. Instead, we take the difference between the two arrays and assert them separately, that way it's

View file

@ -1138,6 +1138,14 @@
"@kbn/server-http-tools/*": ["packages/kbn-server-http-tools/*"],
"@kbn/server-route-repository": ["packages/kbn-server-route-repository"],
"@kbn/server-route-repository/*": ["packages/kbn-server-route-repository/*"],
"@kbn/serverless": ["x-pack/plugins/serverless"],
"@kbn/serverless/*": ["x-pack/plugins/serverless/*"],
"@kbn/serverless-project-switcher": ["packages/serverless/project_switcher"],
"@kbn/serverless-project-switcher/*": ["packages/serverless/project_switcher/*"],
"@kbn/serverless-storybook-config": ["packages/serverless/storybook/config"],
"@kbn/serverless-storybook-config/*": ["packages/serverless/storybook/config/*"],
"@kbn/serverless-types": ["packages/serverless/types"],
"@kbn/serverless-types/*": ["packages/serverless/types/*"],
"@kbn/session-notifications-plugin": ["test/plugin_functional/plugins/session_notifications"],
"@kbn/session-notifications-plugin/*": ["test/plugin_functional/plugins/session_notifications/*"],
"@kbn/session-view-plugin": ["x-pack/plugins/session_view"],

View file

@ -62,6 +62,7 @@
"xpack.searchProfiler": "plugins/searchprofiler",
"xpack.security": "plugins/security",
"xpack.server": "legacy/server",
"xpack.serverless": "plugins/serverless",
"xpack.securitySolution": "plugins/security_solution",
"xpack.sessionView": "plugins/session_view",
"xpack.snapshotRestore": "plugins/snapshot_restore",

View file

@ -9,4 +9,5 @@ export interface ConfigType {
loginAssistanceMessage: string;
showInsecureClusterWarning: boolean;
sameSiteCookies: 'Strict' | 'Lax' | 'None' | undefined;
showNavLinks: boolean;
}

View file

@ -29,6 +29,7 @@ interface SetupDeps {
securityLicense: SecurityLicense;
logoutUrl: string;
securityApiClients: SecurityApiClients;
showNavLinks?: boolean;
}
interface StartDeps {
@ -54,16 +55,18 @@ export class SecurityNavControlService {
private securityApiClients!: SecurityApiClients;
private navControlRegistered!: boolean;
private showNavLinks!: boolean;
private securityFeaturesSubscription?: Subscription;
private readonly stop$ = new ReplaySubject<void>(1);
private userMenuLinks$ = new BehaviorSubject<UserMenuLink[]>([]);
public setup({ securityLicense, logoutUrl, securityApiClients }: SetupDeps) {
public setup({ securityLicense, logoutUrl, securityApiClients, showNavLinks = true }: SetupDeps) {
this.securityLicense = securityLicense;
this.logoutUrl = logoutUrl;
this.securityApiClients = securityApiClients;
this.showNavLinks = showNavLinks;
}
public start({ core, authc }: StartDeps): SecurityNavControlServiceStart {
@ -72,7 +75,7 @@ export class SecurityNavControlService {
const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname);
const shouldRegisterNavControl =
!isAnonymousPath && showLinks && !this.navControlRegistered;
this.showNavLinks && !isAnonymousPath && showLinks && !this.navControlRegistered;
if (shouldRegisterNavControl) {
this.registerSecurityNavControl(core, authc);
}

View file

@ -107,6 +107,7 @@ export class SecurityPlugin
securityLicense: license,
logoutUrl: getLogoutUrl(core.http),
securityApiClients: this.securityApiClients,
showNavLinks: this.config.showNavLinks,
});
this.analyticsService.setup({

View file

@ -60,6 +60,7 @@ describe('config schema', () => {
"selector": Object {},
},
"cookieName": "sid",
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"loginAssistanceMessage": "",
"public": Object {},
@ -70,6 +71,7 @@ describe('config schema', () => {
"lifespan": "P30D",
},
"showInsecureClusterWarning": true,
"showNavLinks": true,
}
`);
@ -113,6 +115,7 @@ describe('config schema', () => {
"selector": Object {},
},
"cookieName": "sid",
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"loginAssistanceMessage": "",
"public": Object {},
@ -123,6 +126,7 @@ describe('config schema', () => {
"lifespan": "P30D",
},
"showInsecureClusterWarning": true,
"showNavLinks": true,
}
`);
@ -166,6 +170,7 @@ describe('config schema', () => {
"selector": Object {},
},
"cookieName": "sid",
"enabled": true,
"loginAssistanceMessage": "",
"public": Object {},
"secureCookies": false,
@ -175,6 +180,7 @@ describe('config schema', () => {
"lifespan": "P30D",
},
"showInsecureClusterWarning": true,
"showNavLinks": true,
}
`);
});

View file

@ -204,6 +204,7 @@ export const ConfigSchema = schema.object({
loginAssistanceMessage: schema.string({ defaultValue: '' }),
showInsecureClusterWarning: schema.boolean({ defaultValue: true }),
loginHelp: schema.maybe(schema.string()),
showNavLinks: schema.boolean({ defaultValue: true }),
cookieName: schema.string({ defaultValue: 'sid' }),
encryptionKey: schema.conditional(
schema.contextRef('dist'),
@ -295,6 +296,7 @@ export const ConfigSchema = schema.object({
)
),
}),
enabled: schema.boolean({ defaultValue: true }),
});
export function createConfig(

View file

@ -52,6 +52,7 @@ export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
loginAssistanceMessage: true,
showInsecureClusterWarning: true,
sameSiteCookies: true,
showNavLinks: true,
},
};
export const plugin: PluginInitializer<

View file

@ -0,0 +1,22 @@
---
id: serverless/plugin
slug: /serverless/plugin
title: Serverless Plugin
description: The plugin responsible for managing Serverless settings and providing services to all product serverless plugins.
tags: ['serverless', 'plugin']
date: 2023-04-23
---
![diagram](./assets/diagram.png)
a. `serverless.yml` config enables Serverless plugin, provides settings for *all* projects, (e.g. disabling Reporting).
b. Product-specific `yml` file enables corresponding Project plugin, provides settings for a specific project, (e.g. disabling Observability).
c. Project plugin interacts with Serverless plugin to customize Serverless Kibana.
d. Serverless plugin interacts with Kibana Core to customize Classic Kibana.
e. Project plugin interacts with corresponding Solution plugin to customize the Solution experience for Serverless.
Communication occurs in a *single direction*. While it would be tempting to add a global flag to check if Serverless is enabled, doing so short-circuits the "affecting" model.

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const PLUGIN_ID = 'serverless';
export const PLUGIN_NAME = 'serverless';
/** Internal API route responsible for switching between project configurations. */
export const API_SWITCH_PROJECT = '/internal/serverless/switch_project';

View file

@ -0,0 +1,21 @@
{
"type": "plugin",
"id": "@kbn/serverless",
"owner": "@elastic/appex-sharedux",
"description": "The core Serverless plugin, providing APIs to Serverless Project plugins.",
"plugin": {
"id": "serverless",
"server": true,
"browser": true,
"configPath": [
"xpack",
"serverless",
"plugin",
],
"requiredPlugins": [
"kibanaReact",
],
"optionalPlugins": [],
"requiredBundles": []
}
}

View file

@ -0,0 +1,11 @@
{
"name": "@kbn/serverless",
"version": "1.0.0",
"license": "Elastic License 2.0",
"private": true,
"scripts": {
"build": "yarn plugin-helpers build",
"plugin-helpers": "node ../../scripts/plugin_helpers",
"kbn": "node ../../scripts/kbn"
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface ServerlessConfig {
developer?: {
projectSwitcher?: {
enabled: boolean;
currentType: 'security' | 'observability' | 'search';
};
};
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/public';
import { ServerlessPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new ServerlessPlugin(initializerContext);
}
export type { ServerlessPluginSetup, ServerlessPluginStart } from './types';

View file

@ -0,0 +1,65 @@
/*
* 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 ReactDOM from 'react-dom';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { ProjectSwitcher, ProjectSwitcherKibanaProvider } from '@kbn/serverless-project-switcher';
import { ProjectType } from '@kbn/serverless-types';
import { ServerlessPluginSetup, ServerlessPluginStart } from './types';
import { ServerlessConfig } from './config';
import { API_SWITCH_PROJECT as projectChangeAPIUrl } from '../common';
export class ServerlessPlugin implements Plugin<ServerlessPluginSetup, ServerlessPluginStart> {
private readonly config: ServerlessConfig;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ServerlessConfig>();
}
public setup(_core: CoreSetup): ServerlessPluginSetup {
return {};
}
public start(core: CoreStart): ServerlessPluginStart {
const { developer } = this.config;
if (developer && developer.projectSwitcher && developer.projectSwitcher.enabled) {
const { currentType } = developer.projectSwitcher;
core.chrome.navControls.registerRight({
mount: (target) => this.mountProjectSwitcher(target, core, currentType),
});
}
core.chrome.setChromeStyle('project');
return {};
}
public stop() {}
private mountProjectSwitcher(
targetDomElement: HTMLElement,
coreStart: CoreStart,
currentProjectType: ProjectType
) {
ReactDOM.render(
<KibanaThemeProvider theme$={coreStart.theme.theme$}>
<ProjectSwitcherKibanaProvider {...{ coreStart, projectChangeAPIUrl }}>
<ProjectSwitcher {...{ currentProjectType }} />
</ProjectSwitcherKibanaProvider>
</KibanaThemeProvider>,
targetDomElement
);
return () => ReactDOM.unmountComponentAtNode(targetDomElement);
}
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginStart {}

View file

@ -0,0 +1,58 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
export * from './types';
const configSchema = schema.object({
// Is this plugin enabled?
enabled: schema.boolean({ defaultValue: false }),
// Config namespace for developer-specific settings.
developer: schema.maybe(
schema.object({
// Settings for the project switcher.
projectSwitcher: schema.maybe(
schema.object({
// Should the switcher be enabled?
enabled: schema.conditional(
schema.contextRef('dev'),
false,
schema.boolean({
validate: (rawValue) => {
if (rawValue === true) {
return 'Switcher can only be enabled in development mode';
}
},
defaultValue: false,
}),
schema.boolean({ defaultValue: true })
),
// Which project is currently selected?
currentType: schema.oneOf([
schema.literal('security'),
schema.literal('observability'),
schema.literal('search'),
]),
})
),
})
),
});
type ConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ConfigType> = {
schema: configSchema,
exposeToBrowser: {
developer: true,
},
};
export type ServerlessConfig = TypeOf<typeof configSchema>;

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 { PluginInitializerContext } from '@kbn/core/server';
import { ServerlessPlugin } from './plugin';
export { config } from './config';
export const plugin = (initializerContext: PluginInitializerContext) => {
return new ServerlessPlugin(initializerContext);
};
export type { ServerlessPluginSetup, ServerlessPluginStart } from './types';

View file

@ -0,0 +1,92 @@
/*
* 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 { existsSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
import { schema, TypeOf } from '@kbn/config-schema';
import { getConfigDirectory } from '@kbn/utils';
import { ProjectType } from '@kbn/serverless-types';
import { ServerlessPluginSetup, ServerlessPluginStart } from './types';
import { ServerlessConfig } from './config';
import { API_SWITCH_PROJECT } from '../common';
const switchBodySchema = schema.object({
id: schema.oneOf([
schema.literal('observability'),
schema.literal('security'),
schema.literal('search'),
]),
});
type SwitchReqBody = TypeOf<typeof switchBodySchema>;
const typeToIdMap: Record<ProjectType, string> = {
observability: 'oblt',
security: 'security',
search: 'es',
};
export class ServerlessPlugin implements Plugin<ServerlessPluginSetup, ServerlessPluginStart> {
private readonly config: ServerlessConfig;
constructor(private readonly context: PluginInitializerContext) {
this.config = this.context.config.get<ServerlessConfig>();
}
public setup(core: CoreSetup) {
const router = core.http.createRouter();
const { developer } = this.config;
// If we're in development mode, and the switcher is enabled, register the
// API endpoint responsible for switching projects.
if (process.env.NODE_ENV !== 'production' && developer?.projectSwitcher?.enabled) {
router.post<void, void, SwitchReqBody>(
{
path: API_SWITCH_PROJECT,
validate: {
body: switchBodySchema,
},
},
async (_context, request, response) => {
const { id } = request.body;
const path = resolve(getConfigDirectory(), `serverless.${typeToIdMap[id]}.yml`);
try {
if (existsSync(path)) {
const data = readFileSync(path, 'utf8');
// The switcher is not enabled by default, in cases where one has started Serverless
// with a specific config. So in this case, to ensure the switcher remains enabled,
// erite the selected config to `recent` and tack on the setting to enable the switcher.
writeFileSync(
resolve(getConfigDirectory(), 'serverless.recent.yml'),
`${data}\nxpack.serverless.plugin.developer.projectSwitcher.enabled: true\n`
);
return response.ok({ body: id });
}
} catch (e) {
return response.badRequest({ body: e });
}
return response.badRequest();
}
);
}
return {};
}
public start(_core: CoreStart) {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginStart {}

View file

@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"common/**/*.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/config-schema",
"@kbn/kibana-react-plugin",
"@kbn/utils",
"@kbn/serverless-project-switcher",
"@kbn/serverless-types",
]
}

View file

@ -5013,6 +5013,22 @@
version "0.0.0"
uid ""
"@kbn/serverless-project-switcher@link:packages/serverless/project_switcher":
version "0.0.0"
uid ""
"@kbn/serverless-storybook-config@link:packages/serverless/storybook/config":
version "0.0.0"
uid ""
"@kbn/serverless-types@link:packages/serverless/types":
version "0.0.0"
uid ""
"@kbn/serverless@link:x-pack/plugins/serverless":
version "0.0.0"
uid ""
"@kbn/session-notifications-plugin@link:test/plugin_functional/plugins/session_notifications":
version "0.0.0"
uid ""