From 707dbcd2b1b7b5ea20b92e40d081255086476a66 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 30 Nov 2020 11:12:39 -0500 Subject: [PATCH] [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 --- .../plugins/fleet/common/types/models/epm.ts | 2 +- .../fleet/public/applications/fleet/app.tsx | 261 ++++++++++++ .../fleet/hooks/use_url_pagination.ts | 10 +- .../fleet/public/applications/fleet/index.tsx | 254 ++---------- .../fleet/mock/create_test_renderer.tsx | 79 ++++ .../fleet/mock/fleet_start_services.tsx | 56 +++ .../public/applications/fleet/mock/index.ts | 12 + .../fleet/mock/plugin_configuration.ts | 31 ++ .../fleet/mock/plugin_dependencies.ts | 24 ++ .../fleet/mock/plugin_interfaces.ts | 16 + .../public/applications/fleet/mock/types.ts | 16 + .../sections/epm/screens/detail/content.tsx | 15 + .../epm/screens/detail/index.test.tsx | 380 ++++++++++++++++++ .../sections/epm/screens/detail/index.tsx | 28 +- x-pack/plugins/fleet/public/mocks.ts | 11 + .../mock/endpoint/dependencies_start_mock.ts | 6 +- 16 files changed, 957 insertions(+), 244 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/app.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/create_test_renderer.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/index.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/plugin_dependencies.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/plugin_interfaces.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/mock/types.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx create mode 100644 x-pack/plugins/fleet/public/mocks.ts diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 53e507f6fb4948..7fd65ecdf238f8 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -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; diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx new file mode 100644 index 00000000000000..766ad961674af5 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -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 }) => ( + + + {children} + + +); + +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(false); + const [permissionsError, setPermissionsError] = useState(); + const [isInitialized, setIsInitialized] = useState(false); + const [initializationError, setInitializationError] = useState(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 ( + + {isPermissionsLoading ? ( + + ) : permissionsError === 'REQUEST_ERROR' ? ( + + } + error={i18n.translate('xpack.fleet.permissionsRequestErrorMessageDescription', { + defaultMessage: 'There was a problem checking Fleet permissions', + })} + /> + ) : ( + + + {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( + + ) : ( + + )} + + } + body={ +

+ {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( + superuser }} + /> + ) : ( + + )} +

+ } + /> +
+ )} +
+ ); + } + + if (!isInitialized || initializationError) { + return ( + + {initializationError ? ( + + } + error={initializationError} + /> + ) : ( + + )} + + ); + } + + 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; +}> = memo( + ({ + children, + startServices, + config, + history, + kibanaVersion, + extensions, + routerHistory = createHashHistory(), + }) => { + const isDarkMode = useObservable(startServices.uiSettings.get$('theme:darkMode')); + + return ( + + + + + + + + + + + + {children} + + + + + + + + + + + + ); + } +); + +export const AppRoutes = memo(() => { + const { agents } = useConfig(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts index f9c351899fe0a2..40539ed749285a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts @@ -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 diff --git a/x-pack/plugins/fleet/public/applications/fleet/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/index.tsx index 61a5f1eabc2afe..35abda1490dce0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/index.tsx @@ -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 = ({ return isAllowed ? : ; }; -const Panel = styled(EuiPanel)` - max-width: 500px; - margin-right: auto; - margin-left: auto; -`; - -const ErrorLayout = ({ children }: { children: JSX.Element }) => ( - - - {children} - - -); - -const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>( - ({ history, ...rest }) => { - useBreadcrumbs('base'); - const { agents } = useConfig(); - - const { notifications } = useStartServices(); - - const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); - const [permissionsError, setPermissionsError] = useState(); - const [isInitialized, setIsInitialized] = useState(false); - const [initializationError, setInitializationError] = useState(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 ( - - {isPermissionsLoading ? ( - - ) : permissionsError === 'REQUEST_ERROR' ? ( - - } - error={i18n.translate('xpack.fleet.permissionsRequestErrorMessageDescription', { - defaultMessage: 'There was a problem checking Fleet permissions', - })} - /> - ) : ( - - - {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( - - ) : ( - - )} - - } - body={ -

- {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( - superuser }} - /> - ) : ( - - )} -

- } - /> -
- )} -
- ); - } - - if (!isInitialized || initializationError) { - return ( - - {initializationError ? ( - - } - error={initializationError} - /> - ) : ( - - )} - - ); - } - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } -); - -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(startServices.uiSettings.get$('theme:darkMode')); +} +const FleetApp = ({ + basepath, + startServices, + config, + history, + kibanaVersion, + extensions, +}: FleetAppProps) => { return ( - - - - - - - - - - - - - + + + + + ); }; @@ -265,7 +65,7 @@ export function renderApp( extensions: UIExtensionsStorage ) { ReactDOM.render( - 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; + /** 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; + 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 ( + + {children} + + ); + }), + render: (ui, options) => { + let renderResponse: RenderResult; + act(() => { + renderResponse = reactRender(ui, { + wrapper: testRendererMocks.AppWrapper, + ...options, + }); + }); + return renderResponse!; + }, + }; + + return testRendererMocks; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx b/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx new file mode 100644 index 00000000000000..cc24d946e46af9 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx @@ -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 => { + let store: Record = {}; + 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 }) => ( + {children} + )); +}; + +export const createStartServices = (basePath: string = '/mock'): MockedFleetStartServices => { + const startServices: MockedFleetStartServices = { + ...coreMock.createStart({ basePath }), + ...createStartDepsMock(), + storage: new Storage(createMockStore()) as jest.Mocked, + }; + + configureStartServices(startServices); + + return startServices; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/index.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/index.ts new file mode 100644 index 00000000000000..6202f2cfee1fb2 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/index.ts @@ -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'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts new file mode 100644 index 00000000000000..735c1d11a9837d --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts @@ -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, + }, + }; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_dependencies.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_dependencies.ts new file mode 100644 index 00000000000000..51de2970f9ba69 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_dependencies.ts @@ -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(), + }; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_interfaces.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_interfaces.ts new file mode 100644 index 00000000000000..786702863b0d6a --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_interfaces.ts @@ -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), + }; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts new file mode 100644 index 00000000000000..c5830b0a2a5605 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts @@ -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; + +export type MockedFleetSetupDeps = MockedKeys; + +export type MockedFleetStartDeps = MockedKeys; + +export type MockedFleetStart = MockedKeys; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx index 62adad14a028cc..b19a82d3100c53 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx @@ -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; @@ -49,6 +53,9 @@ export function Content(props: ContentProps) { type ContentPanelProps = PackageInfo & Pick; 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 ; + case 'custom': + return CustomView ? ( + + + + ) : ( + + ); case 'overview': default: return ; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx new file mode 100644 index 00000000000000..9dfc1b55815337 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx @@ -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; + const render = () => + (renderResult = testRenderer.render( + + + + )); + + 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; + + beforeEach(() => { + let setWasRendered: () => void; + lazyComponentWasRendered = new Promise((resolve) => { + setWasRendered = resolve; + }); + + const CustomComponent = lazy(async () => { + return { + default: memo(() => { + setWasRendered(); + return
hello
; + }), + }; + }); + + 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; + } + }); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index aad8f9701923ef..ba667200571ba3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -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 = { 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(); 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 ( & { indexPatterns: Omit & { @@ -56,9 +57,6 @@ export const depsStartMock: () => DepsStartMock = () => { return { data: dataMock, - fleet: { - isInitialized: () => Promise.resolve(true), - registerExtension: jest.fn(), - }, + fleet: fleetMock.createStartMock(), }; };