[fleet][integrations] Provide Deployment Details on Cloud (#114287)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Clint Andrew Hall 2021-10-13 12:50:20 -05:00 committed by GitHub
parent eaf25d64e4
commit 6d5354a99d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 436 additions and 45 deletions

View file

@ -240,6 +240,7 @@ readonly links: {
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;

View file

@ -482,6 +482,7 @@ export class DocLinksService {
upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`,
upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`,
learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`,
apiKeysLearnMore: `${KIBANA_DOCS}api-keys.html`,
},
ecs: {
guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/current/index.html`,
@ -741,6 +742,7 @@ export interface DocLinksStart {
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;

View file

@ -709,6 +709,7 @@ export interface DocLinksStart {
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;

View file

@ -8,7 +8,7 @@
"server": true,
"ui": true,
"configPath": ["xpack", "fleet"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share"],
"optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"],
"extraPublicDirs": ["common"],
"requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"]

View file

@ -40,6 +40,7 @@ import { EPMApp } from './sections/epm';
import { DefaultLayout } from './layouts';
import { PackageInstallProvider } from './hooks';
import { useBreadcrumbs, UIExtensionsContext } from './hooks';
import { IntegrationsHeader } from './components/header';
const ErrorLayout = ({ children }: { children: JSX.Element }) => (
<EuiErrorBoundary>
@ -127,41 +128,53 @@ export const IntegrationsAppContext: React.FC<{
history: AppMountParameters['history'];
kibanaVersion: string;
extensions: UIExtensionsStorage;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
/** For testing purposes only */
routerHistory?: History<any>; // TODO remove
}> = memo(({ children, startServices, config, history, kibanaVersion, extensions }) => {
const isDarkMode = useObservable<boolean>(startServices.uiSettings.get$('theme:darkMode'));
}> = memo(
({
children,
startServices,
config,
history,
kibanaVersion,
extensions,
setHeaderActionMenu,
}) => {
const isDarkMode = useObservable<boolean>(startServices.uiSettings.get$('theme:darkMode'));
return (
<RedirectAppLinks application={startServices.application}>
<startServices.i18n.Context>
<KibanaContextProvider services={{ ...startServices }}>
<EuiErrorBoundary>
<ConfigContext.Provider value={config}>
<KibanaVersionContext.Provider value={kibanaVersion}>
<EuiThemeProvider darkMode={isDarkMode}>
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<startServices.customIntegrations.ContextProvider>
<Router history={history}>
<AgentPolicyContextProvider>
<PackageInstallProvider notifications={startServices.notifications}>
{children}
</PackageInstallProvider>
</AgentPolicyContextProvider>
</Router>
</startServices.customIntegrations.ContextProvider>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
</EuiThemeProvider>
</KibanaVersionContext.Provider>
</ConfigContext.Provider>
</EuiErrorBoundary>
</KibanaContextProvider>
</startServices.i18n.Context>
</RedirectAppLinks>
);
});
return (
<RedirectAppLinks application={startServices.application}>
<startServices.i18n.Context>
<KibanaContextProvider services={{ ...startServices }}>
<EuiErrorBoundary>
<ConfigContext.Provider value={config}>
<KibanaVersionContext.Provider value={kibanaVersion}>
<EuiThemeProvider darkMode={isDarkMode}>
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<startServices.customIntegrations.ContextProvider>
<Router history={history}>
<AgentPolicyContextProvider>
<PackageInstallProvider notifications={startServices.notifications}>
<IntegrationsHeader {...{ setHeaderActionMenu }} />
{children}
</PackageInstallProvider>
</AgentPolicyContextProvider>
</Router>
</startServices.customIntegrations.ContextProvider>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
</EuiThemeProvider>
</KibanaVersionContext.Provider>
</ConfigContext.Provider>
</EuiErrorBoundary>
</KibanaContextProvider>
</startServices.i18n.Context>
</RedirectAppLinks>
);
}
);
export const AppRoutes = memo(() => {
const { modal, setModal } = useUrlModal();

View file

@ -0,0 +1,120 @@
/*
* 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 styled from 'styled-components';
import {
EuiPopover,
EuiText,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiCopy,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiLink,
EuiHeaderLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export interface Props {
endpointUrl: string;
cloudId: string;
managementUrl?: string;
learnMoreUrl: string;
}
const Description = styled(EuiText)`
margin-bottom: ${({ theme }) => theme.eui.euiSizeL};
`;
export const DeploymentDetails = ({ endpointUrl, cloudId, learnMoreUrl, managementUrl }: Props) => {
const [isOpen, setIsOpen] = React.useState(false);
const button = (
<EuiHeaderLink onClick={() => setIsOpen(!isOpen)} iconType="iInCircle" iconSide="left" isActive>
{i18n.translate('xpack.fleet.integrations.deploymentButton', {
defaultMessage: 'View deployment details',
})}
</EuiHeaderLink>
);
const management = managementUrl ? (
<EuiFormRow label="API keys" fullWidth>
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem>
<EuiButton href={managementUrl}>Create and manage API keys</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink external href={learnMoreUrl} target="_blank">
Learn more
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
) : null;
return (
<EuiPopover
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
button={button}
anchorPosition="downCenter"
>
<div style={{ width: 450 }}>
<Description>
Send data to Elastic from your applications by referencing your deployment and
Elasticsearch information.
</Description>
<EuiForm component="div">
<EuiFormRow label="Elasticsearch endpoint" fullWidth isDisabled>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldText value={endpointUrl} fullWidth disabled />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={endpointUrl}>
{(copy) => (
<EuiButtonIcon
onClick={copy}
iconType="copyClipboard"
display="base"
size="m"
/>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiFormRow label="Cloud ID" fullWidth>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldText value={cloudId} fullWidth disabled />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={cloudId}>
{(copy) => (
<EuiButtonIcon
onClick={copy}
iconType="copyClipboard"
display="base"
size="m"
/>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
{management}
</EuiForm>
</div>
</EuiPopover>
);
};

View file

@ -0,0 +1,56 @@
/*
* 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 { Meta } from '@storybook/react';
import { EuiHeader } from '@elastic/eui';
import { DeploymentDetails as ConnectedComponent } from './deployment_details';
import type { Props as PureComponentProps } from './deployment_details.component';
import { DeploymentDetails as PureComponent } from './deployment_details.component';
export default {
title: 'Sections/EPM/Deployment Details',
description: '',
decorators: [
(storyFn) => {
const sections = [{ items: [] }, { items: [storyFn()] }];
return <EuiHeader sections={sections} />;
},
],
} as Meta;
export const DeploymentDetails = () => {
return <ConnectedComponent />;
};
DeploymentDetails.args = {
isCloudEnabled: true,
};
DeploymentDetails.argTypes = {
isCloudEnabled: {
type: {
name: 'boolean',
},
defaultValue: true,
control: {
type: 'boolean',
},
},
};
export const Component = (props: PureComponentProps) => {
return <PureComponent {...props} />;
};
Component.args = {
cloudId: 'cloud-id',
endpointUrl: 'https://endpoint-url',
learnMoreUrl: 'https://learn-more-url',
managementUrl: 'https://management-url',
};

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useStartServices } from '../../hooks';
import { DeploymentDetails as Component } from './deployment_details.component';
export const DeploymentDetails = () => {
const { share, cloud, docLinks } = useStartServices();
// If the cloud plugin isn't enabled, we can't display the flyout.
if (!cloud) {
return null;
}
const { isCloudEnabled, cloudId, cname } = cloud;
// If cloud isn't enabled, we don't have a cloudId or a cname, we can't display the flyout.
if (!isCloudEnabled || !cloudId || !cname) {
return null;
}
// If the cname doesn't start with a known prefix, we can't display the flyout.
// TODO: dover - this is a short term solution, see https://github.com/elastic/kibana/pull/114287#issuecomment-940111026
if (
!(
cname.endsWith('elastic-cloud.com') ||
cname.endsWith('found.io') ||
cname.endsWith('found.no')
)
) {
return null;
}
const cnameNormalized = cname.startsWith('.') ? cname.substring(1) : cname;
const endpointUrl = `https://${cloudId}.${cnameNormalized}`;
const managementUrl = share.url.locators
.get('MANAGEMENT_APP_LOCATOR')
?.useUrl({ sectionId: 'security', appId: 'api_keys' });
const learnMoreUrl = docLinks.links.fleet.apiKeysLearnMore;
return <Component {...{ cloudId, endpointUrl, managementUrl, learnMoreUrl }} />;
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiHeaderSectionItem, EuiHeaderSection, EuiHeaderLinks } from '@elastic/eui';
import type { AppMountParameters } from 'kibana/public';
import { HeaderPortal } from './header_portal';
import { DeploymentDetails } from './deployment_details';
export const IntegrationsHeader = ({
setHeaderActionMenu,
}: {
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
}) => {
return (
<HeaderPortal {...{ setHeaderActionMenu }}>
<EuiHeaderSection grow={false}>
<EuiHeaderSectionItem>
<EuiHeaderLinks>
<DeploymentDetails />
</EuiHeaderLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>
</HeaderPortal>
);
};

View file

@ -0,0 +1,35 @@
/*
* 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 { AppMountParameters } from 'kibana/public';
import type { FC } from 'react';
import React, { useEffect, useMemo } from 'react';
import { createPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public';
export interface Props {
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
}
export const HeaderPortal: FC<Props> = ({ children, setHeaderActionMenu }) => {
const portalNode = useMemo(() => createPortalNode(), []);
useEffect(() => {
setHeaderActionMenu((element) => {
const mount = toMountPoint(<OutPortal node={portalNode} />);
return mount(element);
});
return () => {
portalNode.unmount();
setHeaderActionMenu(undefined);
};
}, [portalNode, setHeaderActionMenu]);
return <InPortal node={portalNode}>{children}</InPortal>;
};

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 { IntegrationsHeader } from './header';

View file

@ -37,6 +37,7 @@ interface IntegrationsAppProps {
history: AppMountParameters['history'];
kibanaVersion: string;
extensions: UIExtensionsStorage;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
}
const IntegrationsApp = ({
basepath,
@ -45,6 +46,7 @@ const IntegrationsApp = ({
history,
kibanaVersion,
extensions,
setHeaderActionMenu,
}: IntegrationsAppProps) => {
return (
<IntegrationsAppContext
@ -54,6 +56,7 @@ const IntegrationsApp = ({
history={history}
kibanaVersion={kibanaVersion}
extensions={extensions}
setHeaderActionMenu={setHeaderActionMenu}
>
<WithPermissionsAndSetup>
<AppRoutes />
@ -64,7 +67,7 @@ const IntegrationsApp = ({
export function renderApp(
startServices: FleetStartServices,
{ element, appBasePath, history }: AppMountParameters,
{ element, appBasePath, history, setHeaderActionMenu }: AppMountParameters,
config: FleetConfigType,
kibanaVersion: string,
extensions: UIExtensionsStorage
@ -77,6 +80,7 @@ export function renderApp(
history={history}
kibanaVersion={kibanaVersion}
extensions={extensions}
setHeaderActionMenu={setHeaderActionMenu}
/>,
element
);

View file

@ -41,6 +41,7 @@ export interface TestRenderer {
kibanaVersion: string;
AppWrapper: React.FC<any>;
render: UiRender;
setHeaderActionMenu: Function;
}
export const createFleetTestRendererMock = (): TestRenderer => {
@ -55,6 +56,7 @@ export const createFleetTestRendererMock = (): TestRenderer => {
config: createConfigurationMock(),
startInterface: createStartMock(extensions),
kibanaVersion: '8.0.0',
setHeaderActionMenu: jest.fn(),
AppWrapper: memo(({ children }) => {
return (
<FleetAppContext
@ -96,6 +98,7 @@ export const createIntegrationsTestRendererMock = (): TestRenderer => {
config: createConfigurationMock(),
startInterface: createStartMock(extensions),
kibanaVersion: '8.0.0',
setHeaderActionMenu: jest.fn(),
AppWrapper: memo(({ children }) => {
return (
<IntegrationsAppContext
@ -106,6 +109,7 @@ export const createIntegrationsTestRendererMock = (): TestRenderer => {
kibanaVersion={testRendererMocks.kibanaVersion}
extensions={extensions}
routerHistory={testRendererMocks.history}
setHeaderActionMenu={() => {}}
>
{children}
</IntegrationsAppContext>

View file

@ -10,6 +10,7 @@ import { licensingMock } from '../../../licensing/public/mocks';
import { homePluginMock } from '../../../../../src/plugins/home/public/mocks';
import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks';
import { customIntegrationsMock } from '../../../../../src/plugins/custom_integrations/public/mocks';
import { sharePluginMock } from '../../../../../src/plugins/share/public/mocks';
import type { MockedFleetSetupDeps, MockedFleetStartDeps } from './types';
@ -27,5 +28,6 @@ export const createStartDepsMock = (): MockedFleetStartDeps => {
data: dataPluginMock.createStartContract(),
navigation: navigationPluginMock.createStartContract(),
customIntegrations: customIntegrationsMock.createStart(),
share: sharePluginMock.createStartContract(),
};
};

View file

@ -21,6 +21,8 @@ import type {
CustomIntegrationsSetup,
} from 'src/plugins/custom_integrations/public';
import type { SharePluginStart } from 'src/plugins/share/public';
import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public';
import type {
@ -81,10 +83,12 @@ export interface FleetStartDeps {
data: DataPublicPluginStart;
navigation: NavigationPublicPluginStart;
customIntegrations: CustomIntegrationsStart;
share: SharePluginStart;
}
export interface FleetStartServices extends CoreStart, FleetStartDeps {
storage: Storage;
share: SharePluginStart;
cloud?: CloudSetup;
}
@ -134,6 +138,7 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
...coreStartServices,
...startDepsServices,
storage: this.storage,
cloud: deps.cloud,
};
const { renderApp, teardownIntegrations } = await import('./applications/integrations');
const unmount = renderApp(startServices, params, config, kibanaVersion, extensions);

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CloudSetup } from '../../../cloud/public';
export const getCloud = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => {
const cloud: CloudSetup = {
isCloudEnabled,
baseUrl: 'https://base.url',
cloudId: 'cloud-id',
cname: 'found.io',
deploymentUrl: 'https://deployment.url',
organizationUrl: 'https://organization.url',
profileUrl: 'https://profile.url',
snapshotsUrl: 'https://snapshots.url',
};
return cloud;
};

View file

@ -15,6 +15,7 @@ export const getDocLinks = () => {
fleet: {
learnMoreBlog:
'https://www.elastic.co/blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic',
apiKeysLearnMore: 'https://www.elastic.co/guide/en/kibana/master/api-keys.html',
},
},
} as unknown as DocLinksStart;

View file

@ -28,6 +28,8 @@ import { getUiSettings } from './ui_settings';
import { getNotifications } from './notifications';
import { stubbedStartServices } from './stubs';
import { getDocLinks } from './doc_links';
import { getCloud } from './cloud';
import { getShare } from './share';
// TODO: clintandrewhall - this is not ideal, or complete. The root context of Fleet applications
// requires full start contracts of its dependencies. As a result, we have to mock all of those contracts
@ -36,6 +38,7 @@ import { getDocLinks } from './doc_links';
//
// Expect this to grow as components that are given Stories need access to mocked services.
export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
storyContext,
children: storyChildren,
}) => {
const basepath = '';
@ -46,10 +49,12 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
...stubbedStartServices,
application: getApplication(),
chrome: getChrome(),
cloud: getCloud({ isCloudEnabled: storyContext?.args.isCloudEnabled }),
customIntegrations: {
ContextProvider: getStorybookContextProvider(),
},
docLinks: getDocLinks(),
http: getHttp(),
notifications: getNotifications(),
uiSettings: getUiSettings(),
i18n: {
Context: function I18nContext({ children }) {
return <I18nProvider>{children}</I18nProvider>;
@ -58,9 +63,9 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
injectedMetadata: {
getInjectedVar: () => null,
},
customIntegrations: {
ContextProvider: getStorybookContextProvider(),
},
notifications: getNotifications(),
share: getShare(),
uiSettings: getUiSettings(),
};
setHttpClient(startServices.http);
@ -81,12 +86,20 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
} as unknown as FleetConfigType;
const extensions = {};
const kibanaVersion = '1.2.3';
const setHeaderActionMenu = () => {};
return (
<IntegrationsAppContext
{...{ kibanaVersion, basepath, config, history, startServices, extensions }}
{...{
kibanaVersion,
basepath,
config,
history,
startServices,
extensions,
setHeaderActionMenu,
}}
>
{storyChildren}
</IntegrationsAppContext>

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SharePluginStart } from 'src/plugins/share/public';
export const getShare = () => {
const share: SharePluginStart = {
url: {
locators: {
get: () => ({
useUrl: () => 'https://locator.url',
}),
},
},
} as unknown as SharePluginStart;
return share;
};

View file

@ -14,8 +14,7 @@ type Stubs =
| 'fatalErrors'
| 'navigation'
| 'overlays'
| 'savedObjects'
| 'cloud';
| 'savedObjects';
type StubbedStartServices = Pick<FleetStartServices, Stubs>;
@ -27,5 +26,4 @@ export const stubbedStartServices: StubbedStartServices = {
navigation: {} as FleetStartServices['navigation'],
overlays: {} as FleetStartServices['overlays'],
savedObjects: {} as FleetStartServices['savedObjects'],
cloud: {} as FleetStartServices['cloud'],
};

View file

@ -11,5 +11,5 @@ import type { DecoratorFn } from '@storybook/react';
import { StorybookContext } from './context';
export const decorator: DecoratorFn = (story, storybook) => {
return <StorybookContext>{story()}</StorybookContext>;
return <StorybookContext storyContext={storybook}>{story()}</StorybookContext>;
};