[Fleet] Support for showing an Integration Detail Custom (UI Extension) tab (#83805)

* Support for rendering a custom component in Integration Details
* Refactor Fleet app initialization contexts in order to support testing setup
* New test rendering helper tool
* refactor Endpoint to use mock builder from Fleet
This commit is contained in:
Paul Tavares 2020-11-30 11:12:39 -05:00 committed by GitHub
parent eee05ad82a
commit 707dbcd2b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 957 additions and 244 deletions

View file

@ -30,7 +30,7 @@ export type InstallSource = 'registry' | 'upload';
export type EpmPackageInstallStatus = 'installed' | 'installing';
export type DetailViewPanelName = 'overview' | 'policies' | 'settings';
export type DetailViewPanelName = 'overview' | 'policies' | 'settings' | 'custom';
export type ServiceName = 'kibana' | 'elasticsearch';
export type AgentAssetType = typeof agentAssetTypes;
export type AssetType = KibanaAssetType | ElasticsearchAssetType | ValueOf<AgentAssetType>;

View file

@ -0,0 +1,261 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useEffect, useState } from 'react';
import { AppMountParameters } from 'kibana/public';
import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel } from '@elastic/eui';
import { createHashHistory, History } from 'history';
import { Router, Redirect, Route, Switch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import useObservable from 'react-use/lib/useObservable';
import {
ConfigContext,
FleetStatusProvider,
KibanaVersionContext,
sendGetPermissionsCheck,
sendSetup,
useBreadcrumbs,
useConfig,
} from './hooks';
import { Error, Loading } from './components';
import { IntraAppStateProvider } from './hooks/use_intra_app_state';
import { PackageInstallProvider } from './sections/epm/hooks';
import { PAGE_ROUTING_PATHS } from './constants';
import { DefaultLayout, WithoutHeaderLayout } from './layouts';
import { EPMApp } from './sections/epm';
import { AgentPolicyApp } from './sections/agent_policy';
import { DataStreamApp } from './sections/data_stream';
import { FleetApp } from './sections/agents';
import { IngestManagerOverview } from './sections/overview';
import { ProtectedRoute } from './index';
import { FleetConfigType, FleetStartServices } from '../../plugin';
import { UIExtensionsStorage } from './types';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { EuiThemeProvider } from '../../../../xpack_legacy/common';
import { UIExtensionsContext } from './hooks/use_ui_extension';
const ErrorLayout = ({ children }: { children: JSX.Element }) => (
<EuiErrorBoundary>
<DefaultLayout showSettings={false}>
<WithoutHeaderLayout>{children}</WithoutHeaderLayout>
</DefaultLayout>
</EuiErrorBoundary>
);
const Panel = styled(EuiPanel)`
max-width: 500px;
margin-right: auto;
margin-left: auto;
`;
export const WithPermissionsAndSetup: React.FC = memo(({ children }) => {
useBreadcrumbs('base');
const [isPermissionsLoading, setIsPermissionsLoading] = useState<boolean>(false);
const [permissionsError, setPermissionsError] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const [initializationError, setInitializationError] = useState<Error | null>(null);
useEffect(() => {
(async () => {
setIsPermissionsLoading(false);
setPermissionsError(undefined);
setIsInitialized(false);
setInitializationError(null);
try {
setIsPermissionsLoading(true);
const permissionsResponse = await sendGetPermissionsCheck();
setIsPermissionsLoading(false);
if (permissionsResponse.data?.success) {
try {
const setupResponse = await sendSetup();
if (setupResponse.error) {
setInitializationError(setupResponse.error);
}
} catch (err) {
setInitializationError(err);
}
setIsInitialized(true);
} else {
setPermissionsError(permissionsResponse.data?.error || 'REQUEST_ERROR');
}
} catch (err) {
setPermissionsError('REQUEST_ERROR');
}
})();
}, []);
if (isPermissionsLoading || permissionsError) {
return (
<ErrorLayout>
{isPermissionsLoading ? (
<Loading />
) : permissionsError === 'REQUEST_ERROR' ? (
<Error
title={
<FormattedMessage
id="xpack.fleet.permissionsRequestErrorMessageTitle"
defaultMessage="Unable to check permissions"
/>
}
error={i18n.translate('xpack.fleet.permissionsRequestErrorMessageDescription', {
defaultMessage: 'There was a problem checking Fleet permissions',
})}
/>
) : (
<Panel>
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
{permissionsError === 'MISSING_SUPERUSER_ROLE' ? (
<FormattedMessage
id="xpack.fleet.permissionDeniedErrorTitle"
defaultMessage="Permission denied"
/>
) : (
<FormattedMessage
id="xpack.fleet.securityRequiredErrorTitle"
defaultMessage="Security is not enabled"
/>
)}
</h2>
}
body={
<p>
{permissionsError === 'MISSING_SUPERUSER_ROLE' ? (
<FormattedMessage
id="xpack.fleet.permissionDeniedErrorMessage"
defaultMessage="You are not authorized to access Fleet. Fleet requires {roleName} privileges."
values={{ roleName: <EuiCode>superuser</EuiCode> }}
/>
) : (
<FormattedMessage
id="xpack.fleet.securityRequiredErrorMessage"
defaultMessage="You must enable security in Kibana and Elasticsearch to use Fleet."
/>
)}
</p>
}
/>
</Panel>
)}
</ErrorLayout>
);
}
if (!isInitialized || initializationError) {
return (
<ErrorLayout>
{initializationError ? (
<Error
title={
<FormattedMessage
id="xpack.fleet.initializationErrorMessageTitle"
defaultMessage="Unable to initialize Fleet"
/>
}
error={initializationError}
/>
) : (
<Loading />
)}
</ErrorLayout>
);
}
return <>{children}</>;
});
/**
* Fleet Application context all the way down to the Router, but with no permissions or setup checks
* and no routes defined
*/
export const FleetAppContext: React.FC<{
basepath: string;
startServices: FleetStartServices;
config: FleetConfigType;
history: AppMountParameters['history'];
kibanaVersion: string;
extensions: UIExtensionsStorage;
/** For testing purposes only */
routerHistory?: History<any>;
}> = memo(
({
children,
startServices,
config,
history,
kibanaVersion,
extensions,
routerHistory = createHashHistory(),
}) => {
const isDarkMode = useObservable<boolean>(startServices.uiSettings.get$('theme:darkMode'));
return (
<startServices.i18n.Context>
<KibanaContextProvider services={{ ...startServices }}>
<EuiErrorBoundary>
<ConfigContext.Provider value={config}>
<KibanaVersionContext.Provider value={kibanaVersion}>
<EuiThemeProvider darkMode={isDarkMode}>
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<IntraAppStateProvider kibanaScopedHistory={history}>
<Router history={routerHistory}>
<PackageInstallProvider notifications={startServices.notifications}>
{children}
</PackageInstallProvider>
</Router>
</IntraAppStateProvider>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
</EuiThemeProvider>
</KibanaVersionContext.Provider>
</ConfigContext.Provider>
</EuiErrorBoundary>
</KibanaContextProvider>
</startServices.i18n.Context>
);
}
);
export const AppRoutes = memo(() => {
const { agents } = useConfig();
return (
<Switch>
<Route path={PAGE_ROUTING_PATHS.integrations}>
<DefaultLayout section="epm">
<EPMApp />
</DefaultLayout>
</Route>
<Route path={PAGE_ROUTING_PATHS.policies}>
<DefaultLayout section="agent_policy">
<AgentPolicyApp />
</DefaultLayout>
</Route>
<Route path={PAGE_ROUTING_PATHS.data_streams}>
<DefaultLayout section="data_stream">
<DataStreamApp />
</DefaultLayout>
</Route>
<ProtectedRoute path={PAGE_ROUTING_PATHS.fleet} isAllowed={agents.enabled}>
<DefaultLayout section="fleet">
<FleetApp />
</DefaultLayout>
</ProtectedRoute>
<Route exact path={PAGE_ROUTING_PATHS.overview}>
<DefaultLayout section="overview">
<IngestManagerOverview />
</DefaultLayout>
</Route>
<Redirect to="/" />
</Switch>
);
});

View file

@ -69,15 +69,13 @@ const paginationFromUrlParams = (urlParams: UrlPaginationParams): Pagination =>
// Search params can appear multiple times in the URL, in which case the value for them,
// once parsed, would be an array. In these case, we take the last value defined
pagination.currentPage = Number(
(Array.isArray(urlParams.currentPage)
? urlParams.currentPage[urlParams.currentPage.length - 1]
: urlParams.currentPage) ?? pagination.currentPage
(Array.isArray(urlParams.currentPage) ? urlParams.currentPage.pop() : urlParams.currentPage) ??
pagination.currentPage
);
pagination.pageSize =
Number(
(Array.isArray(urlParams.pageSize)
? urlParams.pageSize[urlParams.pageSize.length - 1]
: urlParams.pageSize) ?? pagination.pageSize
(Array.isArray(urlParams.pageSize) ? urlParams.pageSize.pop() : urlParams.pageSize) ??
pagination.pageSize
) ?? pagination.pageSize;
// If Current Page is not a valid positive integer, set it to 1

View file

@ -3,36 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useEffect, useState } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import { HashRouter as Router, Redirect, Switch, Route, RouteProps } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import styled from 'styled-components';
import { EuiErrorBoundary, EuiPanel, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { Redirect, Route, RouteProps } from 'react-router-dom';
import { CoreStart, AppMountParameters } from 'src/core/public';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { EuiThemeProvider } from '../../../../xpack_legacy/common';
import { FleetConfigType, FleetStartServices } from '../../plugin';
import { PAGE_ROUTING_PATHS } from './constants';
import { DefaultLayout, WithoutHeaderLayout } from './layouts';
import { Loading, Error } from './components';
import { IngestManagerOverview, EPMApp, AgentPolicyApp, FleetApp, DataStreamApp } from './sections';
import {
ConfigContext,
useConfig,
useStartServices,
sendSetup,
sendGetPermissionsCheck,
licenseService,
KibanaVersionContext,
} from './hooks';
import { PackageInstallProvider } from './sections/epm/hooks';
import { FleetStatusProvider, useBreadcrumbs } from './hooks';
import { IntraAppStateProvider } from './hooks/use_intra_app_state';
import { licenseService } from './hooks';
import { UIExtensionsStorage } from './types';
import { UIExtensionsContext } from './hooks/use_ui_extension';
import { AppRoutes, FleetAppContext, WithPermissionsAndSetup } from './app';
export interface ProtectedRouteProps extends RouteProps {
isAllowed?: boolean;
@ -47,213 +25,35 @@ export const ProtectedRoute: React.FunctionComponent<ProtectedRouteProps> = ({
return isAllowed ? <Route {...routeProps} /> : <Redirect to={{ pathname: restrictedPath }} />;
};
const Panel = styled(EuiPanel)`
max-width: 500px;
margin-right: auto;
margin-left: auto;
`;
const ErrorLayout = ({ children }: { children: JSX.Element }) => (
<EuiErrorBoundary>
<DefaultLayout showSettings={false}>
<WithoutHeaderLayout>{children}</WithoutHeaderLayout>
</DefaultLayout>
</EuiErrorBoundary>
);
const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>(
({ history, ...rest }) => {
useBreadcrumbs('base');
const { agents } = useConfig();
const { notifications } = useStartServices();
const [isPermissionsLoading, setIsPermissionsLoading] = useState<boolean>(false);
const [permissionsError, setPermissionsError] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const [initializationError, setInitializationError] = useState<Error | null>(null);
useEffect(() => {
(async () => {
setIsPermissionsLoading(false);
setPermissionsError(undefined);
setIsInitialized(false);
setInitializationError(null);
try {
setIsPermissionsLoading(true);
const permissionsResponse = await sendGetPermissionsCheck();
setIsPermissionsLoading(false);
if (permissionsResponse.data?.success) {
try {
const setupResponse = await sendSetup();
if (setupResponse.error) {
setInitializationError(setupResponse.error);
}
} catch (err) {
setInitializationError(err);
}
setIsInitialized(true);
} else {
setPermissionsError(permissionsResponse.data?.error || 'REQUEST_ERROR');
}
} catch (err) {
setPermissionsError('REQUEST_ERROR');
}
})();
}, []);
if (isPermissionsLoading || permissionsError) {
return (
<ErrorLayout>
{isPermissionsLoading ? (
<Loading />
) : permissionsError === 'REQUEST_ERROR' ? (
<Error
title={
<FormattedMessage
id="xpack.fleet.permissionsRequestErrorMessageTitle"
defaultMessage="Unable to check permissions"
/>
}
error={i18n.translate('xpack.fleet.permissionsRequestErrorMessageDescription', {
defaultMessage: 'There was a problem checking Fleet permissions',
})}
/>
) : (
<Panel>
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
{permissionsError === 'MISSING_SUPERUSER_ROLE' ? (
<FormattedMessage
id="xpack.fleet.permissionDeniedErrorTitle"
defaultMessage="Permission denied"
/>
) : (
<FormattedMessage
id="xpack.fleet.securityRequiredErrorTitle"
defaultMessage="Security is not enabled"
/>
)}
</h2>
}
body={
<p>
{permissionsError === 'MISSING_SUPERUSER_ROLE' ? (
<FormattedMessage
id="xpack.fleet.permissionDeniedErrorMessage"
defaultMessage="You are not authorized to access Fleet. Fleet requires {roleName} privileges."
values={{ roleName: <EuiCode>superuser</EuiCode> }}
/>
) : (
<FormattedMessage
id="xpack.fleet.securityRequiredErrorMessage"
defaultMessage="You must enable security in Kibana and Elasticsearch to use Fleet."
/>
)}
</p>
}
/>
</Panel>
)}
</ErrorLayout>
);
}
if (!isInitialized || initializationError) {
return (
<ErrorLayout>
{initializationError ? (
<Error
title={
<FormattedMessage
id="xpack.fleet.initializationErrorMessageTitle"
defaultMessage="Unable to initialize Fleet"
/>
}
error={initializationError}
/>
) : (
<Loading />
)}
</ErrorLayout>
);
}
return (
<EuiErrorBoundary>
<FleetStatusProvider>
<IntraAppStateProvider kibanaScopedHistory={history}>
<Router {...rest}>
<PackageInstallProvider notifications={notifications}>
<Switch>
<Route path={PAGE_ROUTING_PATHS.integrations}>
<DefaultLayout section="epm">
<EPMApp />
</DefaultLayout>
</Route>
<Route path={PAGE_ROUTING_PATHS.policies}>
<DefaultLayout section="agent_policy">
<AgentPolicyApp />
</DefaultLayout>
</Route>
<Route path={PAGE_ROUTING_PATHS.data_streams}>
<DefaultLayout section="data_stream">
<DataStreamApp />
</DefaultLayout>
</Route>
<ProtectedRoute path={PAGE_ROUTING_PATHS.fleet} isAllowed={agents.enabled}>
<DefaultLayout section="fleet">
<FleetApp />
</DefaultLayout>
</ProtectedRoute>
<Route exact path={PAGE_ROUTING_PATHS.overview}>
<DefaultLayout section="overview">
<IngestManagerOverview />
</DefaultLayout>
</Route>
<Redirect to="/" />
</Switch>
</PackageInstallProvider>
</Router>
</IntraAppStateProvider>
</FleetStatusProvider>
</EuiErrorBoundary>
);
}
);
const IngestManagerApp = ({
basepath,
startServices,
config,
history,
kibanaVersion,
extensions,
}: {
interface FleetAppProps {
basepath: string;
startServices: FleetStartServices;
config: FleetConfigType;
history: AppMountParameters['history'];
kibanaVersion: string;
extensions: UIExtensionsStorage;
}) => {
const isDarkMode = useObservable<boolean>(startServices.uiSettings.get$('theme:darkMode'));
}
const FleetApp = ({
basepath,
startServices,
config,
history,
kibanaVersion,
extensions,
}: FleetAppProps) => {
return (
<startServices.i18n.Context>
<KibanaContextProvider services={{ ...startServices }}>
<ConfigContext.Provider value={config}>
<KibanaVersionContext.Provider value={kibanaVersion}>
<EuiThemeProvider darkMode={isDarkMode}>
<UIExtensionsContext.Provider value={extensions}>
<IngestManagerRoutes history={history} basepath={basepath} />
</UIExtensionsContext.Provider>
</EuiThemeProvider>
</KibanaVersionContext.Provider>
</ConfigContext.Provider>
</KibanaContextProvider>
</startServices.i18n.Context>
<FleetAppContext
basepath={basepath}
startServices={startServices}
config={config}
history={history}
kibanaVersion={kibanaVersion}
extensions={extensions}
>
<WithPermissionsAndSetup>
<AppRoutes />
</WithPermissionsAndSetup>
</FleetAppContext>
);
};
@ -265,7 +65,7 @@ export function renderApp(
extensions: UIExtensionsStorage
) {
ReactDOM.render(
<IngestManagerApp
<FleetApp
basepath={appBasePath}
startServices={startServices}
config={config}

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createMemoryHistory, History, createHashHistory } from 'history';
import React, { memo } from 'react';
import { render as reactRender, RenderOptions, RenderResult, act } from '@testing-library/react';
import { ScopedHistory } from '../../../../../../../src/core/public';
import { FleetAppContext } from '../app';
import { FleetConfigType } from '../../../plugin';
import { createConfigurationMock } from './plugin_configuration';
import { UIExtensionsStorage } from '../types';
import { createStartMock } from './plugin_interfaces';
import { createStartServices } from './fleet_start_services';
import { MockedFleetStart, MockedFleetStartServices } from './types';
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
/**
* Test Renderer that includes mocked services and interfaces used during Fleet applicaiton rendering.
* Any of the properties in this interface can be manipulated prior to `render()` if wanting to customize
* the rendering context.
*/
export interface TestRenderer {
/** History instance currently used by the Fleet UI Hash Router */
history: History<any>;
/** history instance provided to the Fleet plugin during application `mount()` */
mountHistory: ScopedHistory;
startServices: MockedFleetStartServices;
config: FleetConfigType;
/** The Interface returned by the Fleet plugin `start()` phase */
startInterface: MockedFleetStart;
kibanaVersion: string;
AppWrapper: React.FC<any>;
render: UiRender;
}
export const createTestRendererMock = (): TestRenderer => {
const basePath = '/mock';
const extensions: UIExtensionsStorage = {};
const startServices = createStartServices(basePath);
const testRendererMocks: TestRenderer = {
history: createHashHistory(),
mountHistory: new ScopedHistory(createMemoryHistory({ initialEntries: [basePath] }), basePath),
startServices,
config: createConfigurationMock(),
startInterface: createStartMock(extensions),
kibanaVersion: '8.0.0',
AppWrapper: memo(({ children }) => {
return (
<FleetAppContext
basepath={basePath}
startServices={testRendererMocks.startServices}
config={testRendererMocks.config}
history={testRendererMocks.mountHistory}
kibanaVersion={testRendererMocks.kibanaVersion}
extensions={extensions}
routerHistory={testRendererMocks.history}
>
{children}
</FleetAppContext>
);
}),
render: (ui, options) => {
let renderResponse: RenderResult;
act(() => {
renderResponse = reactRender(ui, {
wrapper: testRendererMocks.AppWrapper,
...options,
});
});
return renderResponse!;
},
};
return testRendererMocks;
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { createStartDepsMock } from './plugin_dependencies';
import { IStorage, Storage } from '../../../../../../../src/plugins/kibana_utils/public';
import { MockedKeys } from '../../../../../../../packages/kbn-utility-types/jest/index';
import { setHttpClient } from '../hooks/use_request';
import { MockedFleetStartServices } from './types';
// Taken from core. See: src/plugins/kibana_utils/public/storage/storage.test.ts
const createMockStore = (): MockedKeys<IStorage> => {
let store: Record<string, any> = {};
return {
getItem: jest.fn().mockImplementation((key) => store[key]),
setItem: jest.fn().mockImplementation((key, value) => (store[key] = value)),
removeItem: jest.fn().mockImplementation((key: string) => delete store[key]),
clear: jest.fn().mockImplementation(() => (store = {})),
};
};
const configureStartServices = (services: MockedFleetStartServices): void => {
// Store the http for use by useRequest
setHttpClient(services.http);
// Set Fleet available capabilities
services.application.capabilities = {
...services.application.capabilities,
fleet: {
read: true,
write: true,
},
};
// Setup the `i18n.Context` component
services.i18n.Context.mockImplementation(({ children }: { children: React.ReactNode }) => (
<I18nProvider>{children}</I18nProvider>
));
};
export const createStartServices = (basePath: string = '/mock'): MockedFleetStartServices => {
const startServices: MockedFleetStartServices = {
...coreMock.createStart({ basePath }),
...createStartDepsMock(),
storage: new Storage(createMockStore()) as jest.Mocked<Storage>,
};
configureStartServices(startServices);
return startServices;
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './create_test_renderer';
export * from './plugin_configuration';
export * from './plugin_dependencies';
export * from './plugin_interfaces';
export * from './fleet_start_services';
export * from './types';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { FleetConfigType } from '../../../plugin';
export const createConfigurationMock = (): FleetConfigType => {
return {
enabled: true,
registryUrl: '',
registryProxyUrl: '',
agents: {
enabled: true,
tlsCheckDisabled: true,
pollingRequestTimeout: 1000,
maxConcurrentConnections: 100,
kibana: {
host: '',
ca_sha256: '',
},
elasticsearch: {
host: '',
ca_sha256: '',
},
agentPolicyRolloutRateLimitIntervalMs: 100,
agentPolicyRolloutRateLimitRequestPerInterval: 1000,
},
};
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { licensingMock } from '../../../../../licensing/public/mocks';
import { homePluginMock } from '../../../../../../../src/plugins/home/public/mocks';
import { MockedFleetSetupDeps, MockedFleetStartDeps } from './types';
export const createSetupDepsMock = (): MockedFleetSetupDeps => {
return {
licensing: licensingMock.createSetup(),
data: dataPluginMock.createSetupContract(),
home: homePluginMock.createSetupContract(),
};
};
export const createStartDepsMock = (): MockedFleetStartDeps => {
return {
data: dataPluginMock.createStartContract(),
};
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { UIExtensionsStorage } from '../types';
import { createExtensionRegistrationCallback } from '../services/ui_extensions';
import { MockedFleetStart } from './types';
export const createStartMock = (extensionsStorage: UIExtensionsStorage = {}): MockedFleetStart => {
return {
isInitialized: jest.fn().mockResolvedValue(true),
registerExtension: createExtensionRegistrationCallback(extensionsStorage),
};
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { MockedKeys } from '../../../../../../../packages/kbn-utility-types/jest/index';
import { FleetSetupDeps, FleetStart, FleetStartDeps, FleetStartServices } from '../../../plugin';
export type MockedFleetStartServices = MockedKeys<FleetStartServices>;
export type MockedFleetSetupDeps = MockedKeys<FleetSetupDeps>;
export type MockedFleetStartDeps = MockedKeys<FleetStartDeps>;
export type MockedFleetStart = MockedKeys<FleetStart>;

View file

@ -7,6 +7,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { Redirect } from 'react-router-dom';
import { DetailParams } from '.';
import { PackageInfo } from '../../../../types';
import { AssetsFacetGroup } from '../../components/assets_facet_group';
@ -14,6 +15,9 @@ import { CenterColumn, LeftColumn, RightColumn } from './layout';
import { OverviewPanel } from './overview_panel';
import { PackagePoliciesPanel } from './package_policies_panel';
import { SettingsPanel } from './settings_panel';
import { useUIExtension } from '../../../../hooks/use_ui_extension';
import { ExtensionWrapper } from '../../../../components/extension_wrapper';
import { useLink } from '../../../../hooks';
type ContentProps = PackageInfo & Pick<DetailParams, 'panel'>;
@ -49,6 +53,9 @@ export function Content(props: ContentProps) {
type ContentPanelProps = PackageInfo & Pick<DetailParams, 'panel'>;
export function ContentPanel(props: ContentPanelProps) {
const { panel, name, version, assets, title, removable, latestVersion } = props;
const CustomView = useUIExtension(name, 'package-detail-custom');
const { getPath } = useLink();
switch (panel) {
case 'settings':
return (
@ -63,6 +70,14 @@ export function ContentPanel(props: ContentPanelProps) {
);
case 'policies':
return <PackagePoliciesPanel name={name} version={version} />;
case 'custom':
return CustomView ? (
<ExtensionWrapper>
<CustomView />
</ExtensionWrapper>
) : (
<Redirect to={getPath('integration_details', { pkgkey: `${name}-${version}` })} />
);
case 'overview':
default:
return <OverviewPanel {...props} />;

View file

@ -0,0 +1,380 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createTestRendererMock, MockedFleetStartServices, TestRenderer } from '../../../../mock';
import { Detail } from './index';
import React, { lazy, memo } from 'react';
import { PAGE_ROUTING_PATHS, pagePathGetters } from '../../../../constants';
import { Route } from 'react-router-dom';
import {
GetFleetStatusResponse,
GetInfoResponse,
} from '../../../../../../../common/types/rest_spec';
import { DetailViewPanelName, KibanaAssetType } from '../../../../../../../common/types/models';
import { epmRouteService, fleetSetupRouteService } from '../../../../../../../common/services';
import { act } from '@testing-library/react';
describe('when on integration detail', () => {
const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7' });
let testRenderer: TestRenderer;
let renderResult: ReturnType<typeof testRenderer.render>;
const render = () =>
(renderResult = testRenderer.render(
<Route path={PAGE_ROUTING_PATHS.integration_details}>
<Detail />
</Route>
));
beforeEach(() => {
testRenderer = createTestRendererMock();
mockApiCalls(testRenderer.startServices.http);
testRenderer.history.push(detailPageUrlPath);
});
describe('and a custom UI extension is NOT registered', () => {
beforeEach(() => render());
it('should show overview and settings tabs', () => {
const tabs: DetailViewPanelName[] = ['overview', 'settings'];
for (const tab of tabs) {
expect(renderResult.getByTestId(`tab-${tab}`));
}
});
it('should not show a custom tab', () => {
expect(renderResult.queryByTestId('tab-custom')).toBeNull();
});
it('should redirect if custom url is accessed', () => {
act(() => {
testRenderer.history.push(
pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7', panel: 'custom' })
);
});
expect(testRenderer.history.location.pathname).toEqual(detailPageUrlPath);
});
});
describe('and a custom UI extension is registered', () => {
// Because React Lazy components are loaded async (Promise), we setup this "watcher" Promise
// that is `resolved` once the lazy components actually renders.
let lazyComponentWasRendered: Promise<void>;
beforeEach(() => {
let setWasRendered: () => void;
lazyComponentWasRendered = new Promise((resolve) => {
setWasRendered = resolve;
});
const CustomComponent = lazy(async () => {
return {
default: memo(() => {
setWasRendered();
return <div data-test-subj="custom-hello">hello</div>;
}),
};
});
testRenderer.startInterface.registerExtension({
package: 'nginx',
view: 'package-detail-custom',
component: CustomComponent,
});
render();
});
afterEach(() => {
// @ts-ignore
lazyComponentWasRendered = undefined;
});
it('should display "custom" tab in navigation', () => {
expect(renderResult.getByTestId('tab-custom'));
});
it('should display custom content when tab is clicked', async () => {
act(() => {
testRenderer.history.push(
pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7', panel: 'custom' })
);
});
await lazyComponentWasRendered;
expect(renderResult.getByTestId('custom-hello'));
});
});
});
const mockApiCalls = (http: MockedFleetStartServices['http']) => {
// @ts-ignore
const epmPackageResponse: GetInfoResponse = {
response: {
name: 'nginx',
title: 'Nginx',
version: '0.3.7',
release: 'experimental',
description: 'Nginx Integration',
type: 'integration',
download: '/epr/nginx/nginx-0.3.7.zip',
path: '/package/nginx/0.3.7',
icons: [
{
src: '/img/logo_nginx.svg',
path: '/package/nginx/0.3.7/img/logo_nginx.svg',
title: 'logo nginx',
size: '32x32',
type: 'image/svg+xml',
},
],
format_version: '1.0.0',
readme: '/package/nginx/0.3.7/docs/README.md',
license: 'basic',
categories: ['web', 'security'],
conditions: { 'kibana.version': '^7.9.0' },
screenshots: [
{
src: '/img/kibana-nginx.png',
path: '/package/nginx/0.3.7/img/kibana-nginx.png',
title: 'kibana nginx',
size: '1218x1266',
type: 'image/png',
},
{
src: '/img/metricbeat-nginx.png',
path: '/package/nginx/0.3.7/img/metricbeat-nginx.png',
title: 'metricbeat nginx',
size: '2560x2100',
type: 'image/png',
},
],
assets: {
kibana: {
dashboard: [
{
pkgkey: 'nginx-0.3.7',
service: 'kibana',
type: 'dashboard' as KibanaAssetType,
file: 'nginx-023d2930-f1a5-11e7-a9ef-93c69af7b129.json',
},
],
search: [
{
pkgkey: 'nginx-0.3.7',
service: 'kibana',
type: 'search' as KibanaAssetType,
file: 'nginx-6d9e66d0-a1f0-11e7-928f-5dbe6f6f5519.json',
},
],
visualization: [
{
pkgkey: 'nginx-0.3.7',
service: 'kibana',
type: 'visualization' as KibanaAssetType,
file: 'nginx-0dd6f320-a29f-11e7-928f-5dbe6f6f5519.json',
},
],
},
},
policy_templates: [
{
name: 'nginx',
title: 'Nginx logs and metrics',
description: 'Collect logs and metrics from Nginx instances',
inputs: [
{
type: 'logfile',
title: 'Collect logs from Nginx instances',
description: 'Collecting Nginx access, error and ingress controller logs',
},
{
type: 'nginx/metrics',
vars: [
{
name: 'hosts',
type: 'text',
title: 'Hosts',
multi: true,
required: true,
show_user: true,
default: ['http://127.0.0.1:80'],
},
],
title: 'Collect metrics from Nginx instances',
description: 'Collecting Nginx stub status metrics',
},
],
multiple: true,
},
],
data_streams: [
{
type: 'logs',
dataset: 'nginx.access',
title: 'Nginx access logs',
release: 'experimental',
ingest_pipeline: 'default',
streams: [
{
input: 'logfile',
vars: [
{
name: 'paths',
type: 'text',
title: 'Paths',
multi: true,
required: true,
show_user: true,
default: ['/var/log/nginx/access.log*'],
},
],
template_path: 'stream.yml.hbs',
title: 'Nginx access logs',
description: 'Collect Nginx access logs',
enabled: true,
},
],
package: 'nginx',
path: 'access',
},
{
type: 'logs',
dataset: 'nginx.error',
title: 'Nginx error logs',
release: 'experimental',
ingest_pipeline: 'default',
streams: [
{
input: 'logfile',
vars: [
{
name: 'paths',
type: 'text',
title: 'Paths',
multi: true,
required: true,
show_user: true,
default: ['/var/log/nginx/error.log*'],
},
],
template_path: 'stream.yml.hbs',
title: 'Nginx error logs',
description: 'Collect Nginx error logs',
enabled: true,
},
],
package: 'nginx',
path: 'error',
},
{
type: 'logs',
dataset: 'nginx.ingress_controller',
title: 'Nginx ingress_controller logs',
release: 'experimental',
ingest_pipeline: 'default',
streams: [
{
input: 'logfile',
vars: [
{
name: 'paths',
type: 'text',
title: 'Paths',
multi: true,
required: true,
show_user: true,
default: ['/var/log/nginx/ingress.log*'],
},
],
template_path: 'stream.yml.hbs',
title: 'Nginx ingress controller logs',
description: 'Collect Nginx ingress controller logs',
enabled: false,
},
],
package: 'nginx',
path: 'ingress_controller',
},
{
type: 'metrics',
dataset: 'nginx.stubstatus',
title: 'Nginx stubstatus metrics',
release: 'experimental',
streams: [
{
input: 'nginx/metrics',
vars: [
{
name: 'period',
type: 'text',
title: 'Period',
multi: false,
required: true,
show_user: true,
default: '10s',
},
{
name: 'server_status_path',
type: 'text',
title: 'Server Status Path',
multi: false,
required: true,
show_user: false,
default: '/nginx_status',
},
],
template_path: 'stream.yml.hbs',
title: 'Nginx stub status metrics',
description: 'Collect Nginx stub status metrics',
enabled: true,
},
],
package: 'nginx',
path: 'stubstatus',
},
],
owner: { github: 'elastic/integrations-services' },
latestVersion: '0.3.7',
removable: true,
status: 'not_installed',
},
} as GetInfoResponse;
const packageReadMe = `
# Nginx Integration
This integration periodically fetches metrics from [Nginx](https://nginx.org/) servers. It can parse access and error
logs created by the HTTP server.
## Compatibility
The Nginx \`stubstatus\` metrics was tested with Nginx 1.9 and are expected to work with all version >= 1.9.
The logs were tested with version 1.10.
On Windows, the module was tested with Nginx installed from the Chocolatey repository.
`;
const agentsSetupResponse: GetFleetStatusResponse = { isReady: true, missing_requirements: [] };
http.get.mockImplementation(async (path) => {
if (typeof path === 'string') {
if (path === epmRouteService.getInfoPath(`nginx-0.3.7`)) {
return epmPackageResponse;
}
if (path === epmRouteService.getFilePath('/package/nginx/0.3.7/docs/README.md')) {
return packageReadMe;
}
if (path === fleetSetupRouteService.getFleetSetupPath()) {
return agentsSetupResponse;
}
const err = new Error(`API [GET ${path}] is not MOCKED!`);
// eslint-disable-next-line no-console
console.log(err);
throw err;
}
});
};

View file

@ -35,6 +35,7 @@ import { RELEASE_BADGE_LABEL, RELEASE_BADGE_DESCRIPTION } from '../../components
import { UpdateIcon } from '../../components/icons';
import { Content } from './content';
import './index.scss';
import { useUIExtension } from '../../../../hooks/use_ui_extension';
export const DEFAULT_PANEL: DetailViewPanelName = 'overview';
@ -53,6 +54,9 @@ const PanelDisplayNames: Record<DetailViewPanelName, string> = {
settings: i18n.translate('xpack.fleet.epm.packageDetailsNav.settingsLinkText', {
defaultMessage: 'Settings',
}),
custom: i18n.translate('xpack.fleet.epm.packageDetailsNav.packageCustomLinkText', {
defaultMessage: 'Custom',
}),
};
const Divider = styled.div`
@ -72,8 +76,7 @@ function Breadcrumbs({ packageTitle }: { packageTitle: string }) {
}
export function Detail() {
// TODO: fix forced cast if possible
const { pkgkey, panel = DEFAULT_PANEL } = useParams() as DetailParams;
const { pkgkey, panel = DEFAULT_PANEL } = useParams<DetailParams>();
const { getHref } = useLink();
const hasWriteCapabilites = useCapabilities().write;
@ -91,6 +94,10 @@ export function Detail() {
pkgkey
);
const packageInstallStatus = packageInfoData?.response.status;
const showCustomTab =
useUIExtension(packageInfoData?.response.name ?? '', 'package-detail-custom') !== undefined;
// Track install status state
useEffect(() => {
if (packageInfoData?.response) {
@ -236,22 +243,31 @@ export function Detail() {
return (entries(PanelDisplayNames)
.filter(([panelId]) => {
return (
panelId !== 'policies' || packageInfoData?.response.status === InstallStatus.installed
);
// Don't show `Policies` tab if package is not installed
if (panelId === 'policies' && packageInstallStatus !== InstallStatus.installed) {
return false;
}
// Don't show `custom` tab if a custom component is not registered
if (panelId === 'custom' && !showCustomTab) {
return false;
}
return true;
})
.map(([panelId, display]) => {
return {
id: panelId,
name: display,
isSelected: panelId === panel,
'data-test-subj': `tab-${panelId}`,
href: getHref('integration_details', {
pkgkey: `${packageInfo?.name}-${packageInfo?.version}`,
panel: panelId,
}),
};
}) as unknown) as WithHeaderLayoutProps['tabs'];
}, [getHref, packageInfo, packageInfoData?.response?.status, panel]);
}, [getHref, packageInfo, panel, showCustomTab, packageInstallStatus]);
return (
<WithHeaderLayout

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { createStartMock } from './applications/fleet/mock';
export const fleetMock = {
createStartMock,
};

View file

@ -9,6 +9,7 @@ import {
dataPluginMock,
Start as DataPublicStartMock,
} from '../../../../../../../src/plugins/data/public/mocks';
import { fleetMock } from '../../../../../fleet/public/mocks';
type DataMock = Omit<DataPublicStartMock, 'indexPatterns' | 'query'> & {
indexPatterns: Omit<DataPublicStartMock['indexPatterns'], 'getFieldsForWildcard'> & {
@ -56,9 +57,6 @@ export const depsStartMock: () => DepsStartMock = () => {
return {
data: dataMock,
fleet: {
isInitialized: () => Promise.resolve(true),
registerExtension: jest.fn(),
},
fleet: fleetMock.createStartMock(),
};
};