CSP Viewing states (#128215)

This commit is contained in:
Jordan 2022-03-29 12:32:19 +03:00 committed by GitHub
parent 7cb72014aa
commit 23586d2e4f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 646 additions and 412 deletions

View file

@ -35,7 +35,7 @@ export interface Cluster {
resourcesTypes: ResourceType[]; resourcesTypes: ResourceType[];
} }
export interface CloudPostureStats { export interface ComplianceDashboardData {
stats: Stats; stats: Stats;
resourcesTypes: ResourceType[]; resourcesTypes: ResourceType[];
clusters: Cluster[]; clusters: Cluster[];

View file

@ -5,4 +5,4 @@
* 2.0. * 2.0.
*/ */
export * from './use_cloud_posture_stats_api'; export * from './use_compliance_dashboard_data_api';

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from 'react-query';
import {
epmRouteService,
type GetInfoResponse,
type DefaultPackagesInstallationError,
} from '../../../../fleet/common';
import { CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants';
import { useKibana } from '../hooks/use_kibana';
export const CIS_KUBERNETES_INTEGRATION_VERSION = '0.0.1';
/**
* This hook will find our cis integration and return its PackageInfo
* */
export const useCisKubernetesIntegration = () => {
const { http } = useKibana().services;
return useQuery<GetInfoResponse, DefaultPackagesInstallationError>(['integrations'], () =>
http.get<GetInfoResponse>(
epmRouteService.getInfoPath(CIS_KUBERNETES_PACKAGE_NAME, CIS_KUBERNETES_INTEGRATION_VERSION),
{ query: { experimental: true } }
)
);
};

View file

@ -7,12 +7,12 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { CloudPostureStats } from '../../../common/types'; import { ComplianceDashboardData } from '../../../common/types';
import { STATS_ROUTE_PATH } from '../../../common/constants'; import { STATS_ROUTE_PATH } from '../../../common/constants';
const getStatsKey = 'csp_dashboard_stats'; const getStatsKey = 'csp_dashboard_stats';
export const useCloudPostureStatsApi = () => { export const useComplianceDashboardDataApi = () => {
const { http } = useKibana().services; const { http } = useKibana().services;
return useQuery([getStatsKey], () => http!.get<CloudPostureStats>(STATS_ROUTE_PATH)); return useQuery([getStatsKey], () => http!.get<ComplianceDashboardData>(STATS_ROUTE_PATH));
}; };

View file

@ -19,8 +19,8 @@ import { CHART_PANEL_TEST_SUBJECTS } from './constants';
interface ChartPanelProps { interface ChartPanelProps {
title?: string; title?: string;
hasBorder?: boolean; hasBorder?: boolean;
isLoading: boolean; isLoading?: boolean;
isError: boolean; isError?: boolean;
} }
const Loading = () => ( const Loading = () => (

View file

@ -0,0 +1,258 @@
/*
* 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, { type ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import Chance from 'chance';
import { coreMock } from '../../../../../src/core/public/mocks';
import { createNavigationItemFixture } from '../test/fixtures/navigation_item';
import { createReactQueryResponse } from '../test/fixtures/react_query';
import { TestProvider } from '../test/test_provider';
import { CspPageTemplate, getSideNavItems, isCommonError } from './csp_page_template';
import { LOADING, PACKAGE_NOT_INSTALLED_TEXT, DEFAULT_NO_DATA_TEXT } from './translations';
import { useCisKubernetesIntegration } from '../common/api/use_cis_kubernetes_integration';
import { UseQueryResult } from 'react-query';
const chance = new Chance();
// Synchronized to the error message in the formatted message in `csp_page_template.tsx`
const ERROR_LOADING_DATA_DEFAULT_MESSAGE = "We couldn't fetch your cloud security posture data";
const packageNotInstalledUniqueTexts = [
PACKAGE_NOT_INSTALLED_TEXT.PAGE_TITLE,
PACKAGE_NOT_INSTALLED_TEXT.DESCRIPTION,
];
jest.mock('../common/api/use_cis_kubernetes_integration');
describe('getSideNavItems', () => {
it('maps navigation items to side navigation items', () => {
const navigationItem = createNavigationItemFixture();
const id = chance.word();
const sideNavItems = getSideNavItems({ [id]: navigationItem });
expect(sideNavItems).toHaveLength(1);
expect(sideNavItems[0]).toMatchObject({
id,
name: navigationItem.name,
renderItem: expect.any(Function),
});
});
it('does not map disabled navigation items to side navigation items', () => {
const navigationItem = createNavigationItemFixture({ disabled: true });
const id = chance.word();
const sideNavItems = getSideNavItems({ [id]: navigationItem });
expect(sideNavItems).toHaveLength(0);
});
});
describe('<CspPageTemplate />', () => {
beforeEach(() => {
jest.resetAllMocks();
// if package installation status is 'not_installed', CspPageTemplate will render a noDataConfig prompt
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
data: { item: { status: 'installed' } },
}));
});
const renderCspPageTemplate = (props: ComponentProps<typeof CspPageTemplate> = {}) => {
const mockCore = coreMock.createStart();
render(
<TestProvider
core={{
...mockCore,
application: {
...mockCore.application,
capabilities: {
...mockCore.application.capabilities,
// This is required so that the `noDataConfig` view will show the action button
navLinks: { integrations: true },
},
},
}}
>
<CspPageTemplate {...props} />
</TestProvider>
);
};
it('renders children if integration is installed', () => {
const children = chance.sentence();
renderCspPageTemplate({ children });
expect(screen.getByText(children)).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('renders integrations installation prompt if integration is not installed', () => {
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
isSuccess: true,
data: { item: { status: 'not_installed' } },
}));
const children = chance.sentence();
renderCspPageTemplate({ children });
Object.values(PACKAGE_NOT_INSTALLED_TEXT).forEach((text) =>
expect(screen.getByText(text)).toBeInTheDocument()
);
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
});
it('renders default loading text when query isLoading', () => {
const query = createReactQueryResponse({
status: 'loading',
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({ children, query });
expect(screen.getByText(LOADING)).toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('renders default error texts when query isError', () => {
const error = chance.sentence();
const message = chance.sentence();
const statusCode = chance.integer();
const query = createReactQueryResponse({
status: 'error',
error: {
body: {
error,
message,
statusCode,
},
},
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({ children, query });
[ERROR_LOADING_DATA_DEFAULT_MESSAGE, error, message, statusCode].forEach((text) =>
expect(screen.getByText(text, { exact: false })).toBeInTheDocument()
);
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('prefers custom error render', () => {
const error = chance.sentence();
const message = chance.sentence();
const statusCode = chance.integer();
const query = createReactQueryResponse({
status: 'error',
error: {
body: {
error,
message,
statusCode,
},
},
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({
children,
query,
errorRender: (err) => <div>{isCommonError(err) && err.body.message}</div>,
});
expect(screen.getByText(message)).toBeInTheDocument();
[ERROR_LOADING_DATA_DEFAULT_MESSAGE, error, statusCode].forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('prefers custom loading render', () => {
const loading = chance.sentence();
const query = createReactQueryResponse({
status: 'loading',
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({
children,
query,
loadingRender: () => <div>{loading}</div>,
});
expect(screen.getByText(loading)).toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('renders noDataConfig prompt when query data is undefined', () => {
const query = createReactQueryResponse({
status: 'success',
data: undefined,
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({ children, query });
expect(screen.getByText(DEFAULT_NO_DATA_TEXT.PAGE_TITLE)).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('prefers custom noDataConfig prompt', () => {
const pageTitle = chance.sentence();
const solution = chance.sentence();
const docsLink = chance.sentence();
const query = createReactQueryResponse({
status: 'success',
data: undefined,
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({
children,
query,
noDataConfig: { pageTitle, solution, docsLink, actions: {} },
});
expect(screen.getByText(pageTitle)).toBeInTheDocument();
expect(screen.getByText(solution, { exact: false })).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
});

View file

@ -0,0 +1,202 @@
/*
* 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 { UseQueryResult } from 'react-query';
import { NavLink } from 'react-router-dom';
import { EuiEmptyPrompt, EuiErrorBoundary, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
KibanaPageTemplate,
type KibanaPageTemplateProps,
} from '../../../../../src/plugins/kibana_react/public';
import { allNavigationItems } from '../common/navigation/constants';
import type { CspNavigationItem } from '../common/navigation/types';
import { CLOUD_SECURITY_POSTURE } from '../common/translations';
import { CspLoadingState } from './csp_loading_state';
import { DEFAULT_NO_DATA_TEXT, LOADING, PACKAGE_NOT_INSTALLED_TEXT } from './translations';
import { useCisKubernetesIntegration } from '../common/api/use_cis_kubernetes_integration';
import { useCISIntegrationLink } from '../common/navigation/use_navigate_to_cis_integration';
export interface CommonError {
body: {
error: string;
message: string;
statusCode: number;
};
}
export const isCommonError = (x: any): x is CommonError => {
if (!('body' in x)) return false;
const {
body: { error, message, statusCode },
} = x;
return !!(error && message && statusCode);
};
const activeItemStyle = { fontWeight: 700 };
export const getSideNavItems = (
navigationItems: Record<string, CspNavigationItem>
): NonNullable<KibanaPageTemplateProps['solutionNav']>['items'] =>
Object.entries(navigationItems)
.filter(([_, navigationItem]) => !navigationItem.disabled)
.map(([id, navigationItem]) => ({
id,
name: navigationItem.name,
renderItem: () => (
<NavLink to={navigationItem.path} activeStyle={activeItemStyle}>
{navigationItem.name}
</NavLink>
),
}));
const DEFAULT_PAGE_PROPS: KibanaPageTemplateProps = {
solutionNav: {
name: CLOUD_SECURITY_POSTURE,
items: getSideNavItems({
dashboard: allNavigationItems.dashboard,
findings: allNavigationItems.findings,
benchmark: allNavigationItems.benchmarks,
}),
},
restrictWidth: false,
};
export const DEFAULT_NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = {
pageTitle: DEFAULT_NO_DATA_TEXT.PAGE_TITLE,
solution: DEFAULT_NO_DATA_TEXT.SOLUTION,
// TODO: Add real docs link once we have it
docsLink: 'https://www.elastic.co/guide/index.html',
logo: 'logoSecurity',
actions: {},
};
const getPackageNotInstalledNoDataConfig = (
cisIntegrationLink: string
): KibanaPageTemplateProps['noDataConfig'] => ({
pageTitle: PACKAGE_NOT_INSTALLED_TEXT.PAGE_TITLE,
solution: PACKAGE_NOT_INSTALLED_TEXT.SOLUTION,
// TODO: Add real docs link once we have it
docsLink: 'https://www.elastic.co/guide/index.html',
logo: 'logoSecurity',
actions: {
elasticAgent: {
href: cisIntegrationLink,
title: PACKAGE_NOT_INSTALLED_TEXT.BUTTON_TITLE,
description: PACKAGE_NOT_INSTALLED_TEXT.DESCRIPTION,
},
},
});
const DefaultLoading = () => <CspLoadingState>{LOADING}</CspLoadingState>;
const DefaultError = (error: unknown) => (
<EuiEmptyPrompt
color="danger"
iconType="alert"
title={
<>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.csp.pageTemplate.loadErrorMessage"
defaultMessage="We couldn't fetch your cloud security posture data"
/>
</h2>
</EuiTitle>
{isCommonError(error) && (
<>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.csp.pageTemplate.errorDetails.errorCodeTitle"
defaultMessage="{error} {statusCode}"
values={{
error: error.body.error,
statusCode: error.body.statusCode,
}}
/>
</h5>
</EuiTitle>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.csp.pageTemplate.errorDetails.errorBodyTitle"
defaultMessage="{body}"
values={{
body: error.body.message,
}}
/>
</h5>
</EuiTitle>
</>
)}
</>
}
/>
);
export const CspPageTemplate = <TData, TError>({
query,
children,
loadingRender = DefaultLoading,
errorRender = DefaultError,
...kibanaPageTemplateProps
}: KibanaPageTemplateProps & {
loadingRender?: () => React.ReactNode;
errorRender?: (error: TError) => React.ReactNode;
query?: UseQueryResult<TData, TError>;
}) => {
const cisKubernetesPackageInfo = useCisKubernetesIntegration();
const cisIntegrationLink = useCISIntegrationLink();
const getNoDataConfig = (): KibanaPageTemplateProps['noDataConfig'] => {
if (
cisKubernetesPackageInfo?.isSuccess &&
cisKubernetesPackageInfo.data.item.status === 'not_installed'
) {
return getPackageNotInstalledNoDataConfig(cisIntegrationLink);
}
// when query was successful, but data is undefined
if (query?.isSuccess && !query?.data) {
return kibanaPageTemplateProps.noDataConfig || DEFAULT_NO_DATA_CONFIG;
}
// when the consumer didn't pass a query, most likely to handle the render on his own
if (!query) return kibanaPageTemplateProps.noDataConfig;
};
const getTemplate = (): KibanaPageTemplateProps['template'] => {
if (query?.isLoading || query?.isError) return 'centeredContent';
return kibanaPageTemplateProps.template || 'default';
};
const render = () => {
if (query?.isLoading) return loadingRender();
if (query?.isError) return errorRender(query.error);
if (query?.isSuccess) return children;
return children;
};
return (
<KibanaPageTemplate
{...DEFAULT_PAGE_PROPS}
{...kibanaPageTemplateProps}
template={getTemplate()}
noDataConfig={getNoDataConfig()}
>
<EuiErrorBoundary>
{cisKubernetesPackageInfo?.data?.item.status === 'installed' && render()}
</EuiErrorBoundary>
</KibanaPageTemplate>
);
};

View file

@ -1,158 +0,0 @@
/*
* 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, { type ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import Chance from 'chance';
import { coreMock } from '../../../../../src/core/public/mocks';
import { createStubDataView } from '../../../../../src/plugins/data_views/public/data_views/data_view.stub';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../common/constants';
import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view';
import { createNavigationItemFixture } from '../test/fixtures/navigation_item';
import { createReactQueryResponse } from '../test/fixtures/react_query';
import { TestProvider } from '../test/test_provider';
import { CspPageTemplate, getSideNavItems } from './page_template';
import {
LOADING,
NO_DATA_CONFIG_BUTTON,
NO_DATA_CONFIG_DESCRIPTION,
NO_DATA_CONFIG_TITLE,
} from './translations';
const chance = new Chance();
const BLANK_PAGE_GRAPHIC_TEXTS = [
NO_DATA_CONFIG_TITLE,
NO_DATA_CONFIG_DESCRIPTION,
NO_DATA_CONFIG_BUTTON,
];
// Synchronized to the error message in the formatted message in `page_template.tsx`
const ERROR_LOADING_DATA_DEFAULT_MESSAGE = "We couldn't fetch your cloud security posture data";
jest.mock('../common/api/use_kubebeat_data_view');
describe('getSideNavItems', () => {
it('maps navigation items to side navigation items', () => {
const navigationItem = createNavigationItemFixture();
const id = chance.word();
const sideNavItems = getSideNavItems({ [id]: navigationItem });
expect(sideNavItems).toHaveLength(1);
expect(sideNavItems[0]).toMatchObject({
id,
name: navigationItem.name,
renderItem: expect.any(Function),
});
});
it('does not map disabled navigation items to side navigation items', () => {
const navigationItem = createNavigationItemFixture({ disabled: true });
const id = chance.word();
const sideNavItems = getSideNavItems({ [id]: navigationItem });
expect(sideNavItems).toHaveLength(0);
});
});
describe('<CspPageTemplate />', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const renderCspPageTemplate = (props: ComponentProps<typeof CspPageTemplate> = {}) => {
const mockCore = coreMock.createStart();
render(
<TestProvider
core={{
...mockCore,
application: {
...mockCore.application,
capabilities: {
...mockCore.application.capabilities,
// This is required so that the `noDataConfig` view will show the action button
navLinks: { integrations: true },
},
},
}}
>
<CspPageTemplate {...props} />
</TestProvider>
);
};
it('renders children when data view is found', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({
status: 'success',
data: createStubDataView({
spec: {
id: CSP_KUBEBEAT_INDEX_PATTERN,
},
}),
})
);
const children = chance.sentence();
renderCspPageTemplate({ children });
expect(screen.getByText(children)).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) =>
expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument()
);
});
it('renders loading text when data view is loading', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({ status: 'loading' })
);
const children = chance.sentence();
renderCspPageTemplate({ children });
expect(screen.getByText(LOADING)).toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) =>
expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument()
);
});
it('renders an error view when data view fetching has an error', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({ status: 'error', error: new Error('') })
);
const children = chance.sentence();
renderCspPageTemplate({ children });
expect(screen.getByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
BLANK_PAGE_GRAPHIC_TEXTS.forEach((blankPageGraphicText) =>
expect(screen.queryByText(blankPageGraphicText)).not.toBeInTheDocument()
);
});
it('renders the blank page graphic when data view is missing', () => {
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({
status: 'success',
data: undefined,
})
);
const children = chance.sentence();
renderCspPageTemplate({ children });
BLANK_PAGE_GRAPHIC_TEXTS.forEach((text) => expect(screen.getByText(text)).toBeInTheDocument());
expect(screen.queryByText(ERROR_LOADING_DATA_DEFAULT_MESSAGE)).not.toBeInTheDocument();
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
});
});

View file

@ -1,117 +0,0 @@
/*
* 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 { NavLink } from 'react-router-dom';
import { EuiEmptyPrompt, EuiErrorBoundary, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
KibanaPageTemplate,
type KibanaPageTemplateProps,
} from '../../../../../src/plugins/kibana_react/public';
import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view';
import { allNavigationItems } from '../common/navigation/constants';
import type { CspNavigationItem } from '../common/navigation/types';
import { useCISIntegrationLink } from '../common/navigation/use_navigate_to_cis_integration';
import { CLOUD_SECURITY_POSTURE } from '../common/translations';
import { CspLoadingState } from './csp_loading_state';
import {
LOADING,
NO_DATA_CONFIG_BUTTON,
NO_DATA_CONFIG_DESCRIPTION,
NO_DATA_CONFIG_SOLUTION_NAME,
NO_DATA_CONFIG_TITLE,
} from './translations';
const activeItemStyle = { fontWeight: 700 };
export const getSideNavItems = (
navigationItems: Record<string, CspNavigationItem>
): NonNullable<KibanaPageTemplateProps['solutionNav']>['items'] =>
Object.entries(navigationItems)
.filter(([_, navigationItem]) => !navigationItem.disabled)
.map(([id, navigationItem]) => ({
id,
name: navigationItem.name,
renderItem: () => (
<NavLink to={navigationItem.path} activeStyle={activeItemStyle}>
{navigationItem.name}
</NavLink>
),
}));
const DEFAULT_PROPS: KibanaPageTemplateProps = {
solutionNav: {
name: CLOUD_SECURITY_POSTURE,
items: getSideNavItems({
dashboard: allNavigationItems.dashboard,
findings: allNavigationItems.findings,
benchmark: allNavigationItems.benchmarks,
}),
},
restrictWidth: false,
};
const getNoDataConfig = (cisIntegrationLink: string): KibanaPageTemplateProps['noDataConfig'] => ({
pageTitle: NO_DATA_CONFIG_TITLE,
solution: NO_DATA_CONFIG_SOLUTION_NAME,
// TODO: Add real docs link once we have it
docsLink: 'https://www.elastic.co/guide/index.html',
logo: 'logoSecurity',
actions: {
elasticAgent: {
href: cisIntegrationLink,
title: NO_DATA_CONFIG_BUTTON,
description: NO_DATA_CONFIG_DESCRIPTION,
},
},
});
export const CspPageTemplate: React.FC<KibanaPageTemplateProps> = ({ children, ...props }) => {
// TODO: Consider using more sophisticated logic to find out if our integration is installed
const kubeBeatQuery = useKubebeatDataView();
const cisIntegrationLink = useCISIntegrationLink();
let noDataConfig: KibanaPageTemplateProps['noDataConfig'];
if (kubeBeatQuery.status === 'success' && !kubeBeatQuery.data) {
noDataConfig = getNoDataConfig(cisIntegrationLink);
}
let template: KibanaPageTemplateProps['template'] = 'default';
if (kubeBeatQuery.status === 'error' || kubeBeatQuery.status === 'loading') {
template = 'centeredContent';
}
return (
<KibanaPageTemplate
template={template}
{...DEFAULT_PROPS}
{...props}
noDataConfig={noDataConfig}
>
<EuiErrorBoundary>
{kubeBeatQuery.status === 'loading' && <CspLoadingState>{LOADING}</CspLoadingState>}
{kubeBeatQuery.status === 'error' && (
<EuiEmptyPrompt
color="danger"
iconType="alert"
title={
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.csp.pageTemplate.loadErrorMessage"
defaultMessage="We couldn't fetch your cloud security posture data"
/>
</h2>
</EuiTitle>
}
/>
)}
{kubeBeatQuery.status === 'success' && children}
</EuiErrorBoundary>
</KibanaPageTemplate>
);
};

View file

@ -40,28 +40,27 @@ export const CSP_EVALUATION_BADGE_PASSED = i18n.translate(
} }
); );
export const NO_DATA_CONFIG_TITLE = i18n.translate('xpack.csp.pageTemplate.noDataConfigTitle', { export const PACKAGE_NOT_INSTALLED_TEXT = {
defaultMessage: 'Understand your cloud security posture', PAGE_TITLE: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.pageTitle', {
}); defaultMessage: 'Install Integration to get started',
}),
export const NO_DATA_CONFIG_SOLUTION_NAME = i18n.translate( SOLUTION: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.solutionNameLabel', {
'xpack.csp.pageTemplate.noDataConfig.solutionNameLabel',
{
defaultMessage: 'Cloud Security Posture', defaultMessage: 'Cloud Security Posture',
} }),
); BUTTON_TITLE: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.buttonLabel', {
defaultMessage: 'Add a CIS integration',
export const NO_DATA_CONFIG_DESCRIPTION = i18n.translate( }),
'xpack.csp.pageTemplate.noDataConfigDescription', DESCRIPTION: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.description', {
{
defaultMessage: defaultMessage:
'Use our CIS Kubernetes Benchmark integration to measure your Kubernetes cluster setup against the CIS recommendations.', 'Use our CIS Kubernetes Benchmark integration to measure your Kubernetes cluster setup against the CIS recommendations.',
} }),
); };
export const NO_DATA_CONFIG_BUTTON = i18n.translate( export const DEFAULT_NO_DATA_TEXT = {
'xpack.csp.pageTemplate.noDataConfigButtonLabel', PAGE_TITLE: i18n.translate('xpack.csp.cspPageTemplate.defaultNoDataConfig.pageTitle', {
{ defaultMessage: 'No data found',
defaultMessage: 'Add a CIS integration', }),
} SOLUTION: i18n.translate('xpack.csp.cspPageTemplate.defaultNoDataConfig.solutionNameLabel', {
); defaultMessage: 'Cloud Security Posture',
}),
};

View file

@ -7,7 +7,7 @@
import React from 'react'; import React from 'react';
import { EuiEmptyPrompt } from '@elastic/eui'; import { EuiEmptyPrompt } from '@elastic/eui';
import { CspPageTemplate } from './page_template'; import { CspPageTemplate } from './csp_page_template';
import * as TEXT from './translations'; import * as TEXT from './translations';
export const UnknownRoute = React.memo(() => ( export const UnknownRoute = React.memo(() => (

View file

@ -21,13 +21,20 @@ import {
TABLE_COLUMN_HEADERS, TABLE_COLUMN_HEADERS,
} from './translations'; } from './translations';
import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations'; import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
jest.mock('./use_csp_benchmark_integrations'); jest.mock('./use_csp_benchmark_integrations');
jest.mock('../../common/api/use_kubebeat_data_view'); jest.mock('../../common/api/use_kubebeat_data_view');
jest.mock('../../common/api/use_cis_kubernetes_integration');
describe('<Benchmarks />', () => { describe('<Benchmarks />', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
// if package installation status is 'not_installed', CspPageTemplate will render a noDataConfig prompt
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
data: { item: { status: 'installed' } },
}));
// Required for the page template to render the benchmarks page // Required for the page template to render the benchmarks page
(useKubebeatDataView as jest.Mock).mockImplementation(() => (useKubebeatDataView as jest.Mock).mockImplementation(() =>
createReactQueryResponse({ createReactQueryResponse({

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import React, { useState } from 'react';
import { import {
EuiFieldSearch, EuiFieldSearch,
EuiFieldSearchProps, EuiFieldSearchProps,
@ -15,13 +17,12 @@ import {
EuiTextColor, EuiTextColor,
EuiText, EuiText,
} from '@elastic/eui'; } from '@elastic/eui';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import useDebounce from 'react-use/lib/useDebounce'; import useDebounce from 'react-use/lib/useDebounce';
import { allNavigationItems } from '../../common/navigation/constants'; import { allNavigationItems } from '../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
import { useCISIntegrationLink } from '../../common/navigation/use_navigate_to_cis_integration'; import { useCISIntegrationLink } from '../../common/navigation/use_navigate_to_cis_integration';
import { CspPageTemplate } from '../../components/page_template'; import { CspPageTemplate } from '../../components/csp_page_template';
import { BenchmarksTable } from './benchmarks_table'; import { BenchmarksTable } from './benchmarks_table';
import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations'; import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations';
import { import {

View file

@ -14,7 +14,7 @@ import {
EuiLink, EuiLink,
EuiText, EuiText,
} from '@elastic/eui'; } from '@elastic/eui';
import { CloudPostureStats, ResourceType } from '../../../../common/types'; import { ComplianceDashboardData, ResourceType } from '../../../../common/types';
import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number';
import * as TEXT from '../translations'; import * as TEXT from '../translations';
import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants';
@ -59,14 +59,14 @@ const mockData = [
]; ];
export interface RisksTableProps { export interface RisksTableProps {
data: CloudPostureStats['resourcesTypes']; data: ComplianceDashboardData['resourcesTypes'];
maxItems: number; maxItems: number;
onCellClick: (resourceTypeName: string) => void; onCellClick: (resourceTypeName: string) => void;
onViewAllClick: () => void; onViewAllClick: () => void;
} }
export const getTopRisks = ( export const getTopRisks = (
resourcesTypes: CloudPostureStats['resourcesTypes'], resourcesTypes: ComplianceDashboardData['resourcesTypes'],
maxItems: number maxItems: number
) => { ) => {
const filtered = resourcesTypes.filter((x) => x.totalFailed > 0); const filtered = resourcesTypes.filter((x) => x.totalFailed > 0);

View file

@ -6,40 +6,51 @@
*/ */
import React from 'react'; import React from 'react';
import { EuiSpacer } from '@elastic/eui'; import { EuiSpacer, EuiIcon } from '@elastic/eui';
import { allNavigationItems } from '../../common/navigation/constants'; import { allNavigationItems } from '../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
import { SummarySection } from './dashboard_sections/summary_section'; import { SummarySection } from './dashboard_sections/summary_section';
import { BenchmarksSection } from './dashboard_sections/benchmarks_section'; import { BenchmarksSection } from './dashboard_sections/benchmarks_section';
import { useCloudPostureStatsApi } from '../../common/api'; import { useComplianceDashboardDataApi } from '../../common/api';
import { CspPageTemplate } from '../../components/page_template'; import { CspPageTemplate } from '../../components/csp_page_template';
import * as TEXT from './translations'; import { type KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public';
import { CLOUD_POSTURE, NO_DATA_CONFIG_TEXT } from './translations';
const CompliancePage = () => { const getNoDataConfig = (onClick: () => void): KibanaPageTemplateProps['noDataConfig'] => ({
const getStats = useCloudPostureStatsApi(); pageTitle: NO_DATA_CONFIG_TEXT.PAGE_TITLE,
if (getStats.isLoading) return null; solution: NO_DATA_CONFIG_TEXT.SOLUTION,
// TODO: Add real docs link once we have it
return ( docsLink: 'https://www.elastic.co/guide/index.html',
<> logo: 'logoSecurity',
<SummarySection /> actions: {
<EuiSpacer /> dashboardNoDataCard: {
<BenchmarksSection /> icon: <EuiIcon type="refresh" size="xxl" />,
<EuiSpacer /> onClick,
</> title: NO_DATA_CONFIG_TEXT.BUTTON_TITLE,
); description: NO_DATA_CONFIG_TEXT.DESCRIPTION,
}; },
},
});
export const ComplianceDashboard = () => { export const ComplianceDashboard = () => {
const getDashboardDataQuery = useComplianceDashboardDataApi();
useCspBreadcrumbs([allNavigationItems.dashboard]); useCspBreadcrumbs([allNavigationItems.dashboard]);
return ( return (
<CspPageTemplate <CspPageTemplate
pageHeader={{ pageHeader={{ pageTitle: CLOUD_POSTURE }}
pageTitle: TEXT.CLOUD_POSTURE,
}}
restrictWidth={1600} restrictWidth={1600}
query={getDashboardDataQuery}
noDataConfig={getNoDataConfig(getDashboardDataQuery.refetch)}
> >
<CompliancePage /> {getDashboardDataQuery.data && (
<>
<SummarySection complianceData={getDashboardDataQuery.data} />
<EuiSpacer />
<BenchmarksSection complianceData={getDashboardDataQuery.data} />
<EuiSpacer />
</>
)}
</CspPageTemplate> </CspPageTemplate>
); );
}; };

View file

@ -17,34 +17,25 @@ import {
useEuiTheme, useEuiTheme,
} from '@elastic/eui'; } from '@elastic/eui';
import moment from 'moment'; import moment from 'moment';
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import { PartitionElementEvent } from '@elastic/charts'; import { PartitionElementEvent } from '@elastic/charts';
import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types'; import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart';
import { useCloudPostureStatsApi } from '../../../common/api/use_cloud_posture_stats_api';
import { ChartPanel } from '../../../components/chart_panel'; import { ChartPanel } from '../../../components/chart_panel';
import * as TEXT from '../translations'; import * as TEXT from '../translations';
import { Evaluation } from '../../../../common/types'; import type { ComplianceDashboardData, Evaluation } from '../../../../common/types';
import { RisksTable } from '../compliance_charts/risks_table'; import { RisksTable } from '../compliance_charts/risks_table';
import { INTERNAL_FEATURE_FLAGS, RULE_FAILED } from '../../../../common/constants'; import { INTERNAL_FEATURE_FLAGS, RULE_FAILED } from '../../../../common/constants';
import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings';
const logoMap: ReadonlyMap<string, EuiIconType> = new Map([['CIS Kubernetes', 'logoKubernetes']]);
const getBenchmarkLogo = (benchmarkName: string): EuiIconType => {
return logoMap.get(benchmarkName) ?? 'logoElastic';
};
const mockClusterId = '2468540';
const cardHeight = 300; const cardHeight = 300;
export const BenchmarksSection = () => { export const BenchmarksSection = ({
complianceData,
}: {
complianceData: ComplianceDashboardData;
}) => {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
const navToFindings = useNavigateFindings(); const navToFindings = useNavigateFindings();
const getStats = useCloudPostureStatsApi();
const clusters = getStats.isSuccess && getStats.data.clusters;
if (!clusters) return null;
const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => { const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => {
const [element] = elements; const [element] = elements;
@ -68,7 +59,7 @@ export const BenchmarksSection = () => {
return ( return (
<> <>
{clusters.map((cluster) => { {complianceData.clusters.map((cluster) => {
const shortId = cluster.meta.clusterId.slice(0, 6); const shortId = cluster.meta.clusterId.slice(0, 6);
return ( return (
@ -82,7 +73,7 @@ export const BenchmarksSection = () => {
<h4>{cluster.meta.benchmarkName}</h4> <h4>{cluster.meta.benchmarkName}</h4>
</EuiText> </EuiText>
<EuiText style={{ textAlign: 'center' }}> <EuiText style={{ textAlign: 'center' }}>
<h4>{`Cluster ID ${shortId || mockClusterId}`}</h4> <h4>{`Cluster ID ${shortId}`}</h4>
</EuiText> </EuiText>
<EuiSpacer size="xs" /> <EuiSpacer size="xs" />
<EuiText size="xs" color="subdued" style={{ textAlign: 'center' }}> <EuiText size="xs" color="subdued" style={{ textAlign: 'center' }}>
@ -91,7 +82,8 @@ export const BenchmarksSection = () => {
</EuiText> </EuiText>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiIcon type={getBenchmarkLogo(cluster.meta.benchmarkName)} size="xxl" /> {/* TODO: change default k8s logo to use a getBenchmarkLogo function */}
<EuiIcon type="logoKubernetes" size="xxl" />
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
{INTERNAL_FEATURE_FLAGS.showManageRulesMock && ( {INTERNAL_FEATURE_FLAGS.showManageRulesMock && (
@ -104,12 +96,7 @@ export const BenchmarksSection = () => {
grow={4} grow={4}
style={{ borderRight: `1px solid ${euiTheme.colors.lightShade}` }} style={{ borderRight: `1px solid ${euiTheme.colors.lightShade}` }}
> >
<ChartPanel <ChartPanel title={TEXT.COMPLIANCE_SCORE} hasBorder={false}>
title={TEXT.COMPLIANCE_SCORE}
hasBorder={false}
isLoading={getStats.isLoading}
isError={getStats.isError}
>
<CloudPostureScoreChart <CloudPostureScoreChart
id={`${cluster.meta.clusterId}_score_chart`} id={`${cluster.meta.clusterId}_score_chart`}
data={cluster.stats} data={cluster.stats}
@ -120,12 +107,7 @@ export const BenchmarksSection = () => {
</ChartPanel> </ChartPanel>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={4}> <EuiFlexItem grow={4}>
<ChartPanel <ChartPanel title={TEXT.RISKS} hasBorder={false}>
title={TEXT.RISKS}
hasBorder={false}
isLoading={getStats.isLoading}
isError={getStats.isError}
>
<RisksTable <RisksTable
data={cluster.resourcesTypes} data={cluster.resourcesTypes}
maxItems={3} maxItems={3}

View file

@ -9,10 +9,9 @@ import React from 'react';
import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui';
import { PartitionElementEvent } from '@elastic/charts'; import { PartitionElementEvent } from '@elastic/charts';
import { ChartPanel } from '../../../components/chart_panel'; import { ChartPanel } from '../../../components/chart_panel';
import { useCloudPostureStatsApi } from '../../../common/api';
import * as TEXT from '../translations'; import * as TEXT from '../translations';
import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart';
import { Evaluation } from '../../../../common/types'; import type { ComplianceDashboardData, Evaluation } from '../../../../common/types';
import { RisksTable } from '../compliance_charts/risks_table'; import { RisksTable } from '../compliance_charts/risks_table';
import { CasesTable } from '../compliance_charts/cases_table'; import { CasesTable } from '../compliance_charts/cases_table';
import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings';
@ -25,10 +24,8 @@ const summarySectionWrapperStyle = {
height: defaultHeight, height: defaultHeight,
}; };
export const SummarySection = () => { export const SummarySection = ({ complianceData }: { complianceData: ComplianceDashboardData }) => {
const navToFindings = useNavigateFindings(); const navToFindings = useNavigateFindings();
const getStats = useCloudPostureStatsApi();
if (!getStats.isSuccess) return null;
const handleElementClick = (elements: PartitionElementEvent[]) => { const handleElementClick = (elements: PartitionElementEvent[]) => {
const [element] = elements; const [element] = elements;
@ -49,22 +46,18 @@ export const SummarySection = () => {
return ( return (
<EuiFlexGrid columns={3} style={summarySectionWrapperStyle}> <EuiFlexGrid columns={3} style={summarySectionWrapperStyle}>
<EuiFlexItem> <EuiFlexItem>
<ChartPanel <ChartPanel title={TEXT.CLOUD_POSTURE_SCORE}>
title={TEXT.CLOUD_POSTURE_SCORE}
isLoading={getStats.isLoading}
isError={getStats.isError}
>
<CloudPostureScoreChart <CloudPostureScoreChart
id="cloud_posture_score_chart" id="cloud_posture_score_chart"
data={getStats.data.stats} data={complianceData.stats}
partitionOnElementClick={handleElementClick} partitionOnElementClick={handleElementClick}
/> />
</ChartPanel> </ChartPanel>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem> <EuiFlexItem>
<ChartPanel title={TEXT.RISKS} isLoading={getStats.isLoading} isError={getStats.isError}> <ChartPanel title={TEXT.RISKS}>
<RisksTable <RisksTable
data={getStats.data.resourcesTypes} data={complianceData.resourcesTypes}
maxItems={5} maxItems={5}
onCellClick={handleCellClick} onCellClick={handleCellClick}
onViewAllClick={handleViewAllClick} onViewAllClick={handleViewAllClick}
@ -72,11 +65,7 @@ export const SummarySection = () => {
</ChartPanel> </ChartPanel>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem> <EuiFlexItem>
<ChartPanel <ChartPanel title={TEXT.OPEN_CASES}>
title={TEXT.OPEN_CASES}
isLoading={getStats.isLoading}
isError={getStats.isError}
>
<CasesTable /> <CasesTable />
</ChartPanel> </ChartPanel>
</EuiFlexItem> </EuiFlexItem>

View file

@ -82,3 +82,18 @@ export const RESOURCE_TYPE = i18n.translate('xpack.csp.resource_type', {
export const FINDINGS = i18n.translate('xpack.csp.findings', { export const FINDINGS = i18n.translate('xpack.csp.findings', {
defaultMessage: 'Findings', defaultMessage: 'Findings',
}); });
export const NO_DATA_CONFIG_TEXT = {
PAGE_TITLE: i18n.translate('xpack.csp.complianceDashboard.noDataConfig.pageTitle', {
defaultMessage: 'Cloud Security Compliance Dashboard',
}),
SOLUTION: i18n.translate('xpack.csp.complianceDashboard.noDataConfig.solutionNameLabel', {
defaultMessage: 'Cloud Security Posture',
}),
BUTTON_TITLE: i18n.translate('xpack.csp.complianceDashboard.noDataConfig.actionTitle', {
defaultMessage: 'Try Again',
}),
DESCRIPTION: i18n.translate('xpack.csp.complianceDashboard.noDataConfig.actionDescription', {
defaultMessage: 'You can try to refetch your data',
}),
};

View file

@ -17,9 +17,11 @@ import {
import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub'; import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
import * as TEST_SUBJECTS from './test_subjects'; import * as TEST_SUBJECTS from './test_subjects';
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
import type { DataView } from '../../../../../../src/plugins/data/common'; import type { DataView } from '../../../../../../src/plugins/data/common';
jest.mock('../../common/api/use_kubebeat_data_view'); jest.mock('../../common/api/use_kubebeat_data_view');
jest.mock('../../common/api/use_cis_kubernetes_integration');
beforeEach(() => { beforeEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
@ -36,6 +38,9 @@ describe('<Findings />', () => {
const data = dataPluginMock.createStartContract(); const data = dataPluginMock.createStartContract();
const source = await data.search.searchSource.create(); const source = await data.search.searchSource.create();
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
data: { item: { status: 'installed' } },
}));
(source.fetch$ as jest.Mock).mockReturnValue({ (source.fetch$ as jest.Mock).mockReturnValue({
toPromise: () => Promise.resolve({ rawResponse: { hits: { hits: [] } } }), toPromise: () => Promise.resolve({ rawResponse: { hits: { hits: [] } } }),
}); });

View file

@ -10,7 +10,7 @@ import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view';
import { allNavigationItems } from '../../common/navigation/constants'; import { allNavigationItems } from '../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
import { FindingsContainer } from './findings_container'; import { FindingsContainer } from './findings_container';
import { CspPageTemplate } from '../../components/page_template'; import { CspPageTemplate } from '../../components/csp_page_template';
import { FINDINGS } from './translations'; import { FINDINGS } from './translations';
const pageHeader: EuiPageHeaderProps = { const pageHeader: EuiPageHeaderProps = {
@ -18,15 +18,12 @@ const pageHeader: EuiPageHeaderProps = {
}; };
export const Findings = () => { export const Findings = () => {
const dataView = useKubebeatDataView(); const dataViewQuery = useKubebeatDataView();
useCspBreadcrumbs([allNavigationItems.findings]); useCspBreadcrumbs([allNavigationItems.findings]);
return ( return (
// `CspPageTemplate` takes care of loading and error states for the kubebeat data view, no need to handle them here <CspPageTemplate pageHeader={pageHeader} query={dataViewQuery}>
<CspPageTemplate pageHeader={pageHeader}> {dataViewQuery.data && <FindingsContainer dataView={dataViewQuery.data} />}
{dataView.status === 'success' && dataView.data && (
<FindingsContainer dataView={dataView.data} />
)}
</CspPageTemplate> </CspPageTemplate>
); );
}; };

View file

@ -4,19 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps } from 'react-router-dom';
import { EuiTextColor, EuiEmptyPrompt } from '@elastic/eui'; import { EuiTextColor, EuiEmptyPrompt } from '@elastic/eui';
import * as t from 'io-ts'; import * as t from 'io-ts';
import { CspPageTemplate } from '../../components/page_template';
import { RulesContainer, type PageUrlParams } from './rules_container'; import { RulesContainer, type PageUrlParams } from './rules_container';
import { allNavigationItems } from '../../common/navigation/constants'; import { allNavigationItems } from '../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
import type { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public'; import type { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public';
import { CspLoadingState } from '../../components/csp_loading_state';
import { CspNavigationItem } from '../../common/navigation/types'; import { CspNavigationItem } from '../../common/navigation/types';
import { extractErrorMessage } from '../../../common/utils/helpers'; import { extractErrorMessage } from '../../../common/utils/helpers';
import { useCspIntegration } from './use_csp_integration'; import { useCspIntegration } from './use_csp_integration';
import { CspPageTemplate } from '../../components/csp_page_template';
const getRulesBreadcrumbs = (name?: string): CspNavigationItem[] => const getRulesBreadcrumbs = (name?: string): CspNavigationItem[] =>
[allNavigationItems.benchmarks, { ...allNavigationItems.rules, name }].filter( [allNavigationItems.benchmarks, { ...allNavigationItems.rules, name }].filter(
@ -35,7 +35,6 @@ export const Rules = ({ match: { params } }: RouteComponentProps<PageUrlParams>)
const pageProps: KibanaPageTemplateProps = useMemo( const pageProps: KibanaPageTemplateProps = useMemo(
() => ({ () => ({
template: integrationInfo.status !== 'success' ? 'centeredContent' : undefined,
pageHeader: { pageHeader: {
bottomBorder: false, // TODO: border still shows. bottomBorder: false, // TODO: border still shows.
pageTitle: 'Rules', pageTitle: 'Rules',
@ -46,16 +45,16 @@ export const Rules = ({ match: { params } }: RouteComponentProps<PageUrlParams>)
), ),
}, },
}), }),
[integrationInfo.data, integrationInfo.status] [integrationInfo.data]
); );
return ( return (
<CspPageTemplate {...pageProps}> <CspPageTemplate
{...pageProps}
query={integrationInfo}
errorRender={(error) => <RulesErrorPrompt error={extractErrorBodyMessage(error)} />}
>
{integrationInfo.status === 'success' && <RulesContainer />} {integrationInfo.status === 'success' && <RulesContainer />}
{integrationInfo.status === 'error' && (
<RulesErrorPrompt error={extractErrorBodyMessage(integrationInfo.error)} />
)}
{integrationInfo.status === 'loading' && <CspLoadingState />}
</CspPageTemplate> </CspPageTemplate>
); );
}; };

View file

@ -15,10 +15,12 @@ import { type RouteComponentProps } from 'react-router-dom';
import { cspLoadingStateTestId } from '../../components/csp_loading_state'; import { cspLoadingStateTestId } from '../../components/csp_loading_state';
import type { PageUrlParams } from './rules_container'; import type { PageUrlParams } from './rules_container';
import * as TEST_SUBJECTS from './test_subjects'; import * as TEST_SUBJECTS from './test_subjects';
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
jest.mock('./use_csp_integration', () => ({ jest.mock('./use_csp_integration', () => ({
useCspIntegration: jest.fn(), useCspIntegration: jest.fn(),
})); }));
jest.mock('../../common/api/use_cis_kubernetes_integration');
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -43,6 +45,9 @@ describe('<Rules />', () => {
beforeEach(() => { beforeEach(() => {
queryClient.clear(); queryClient.clear();
jest.clearAllMocks(); jest.clearAllMocks();
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
data: { item: { status: 'installed' } },
}));
}); });
it('calls API with URL params', async () => { it('calls API with URL params', async () => {
@ -63,6 +68,7 @@ describe('<Rules />', () => {
const Component = getTestComponent({ packagePolicyId: '1', policyId: '2' }); const Component = getTestComponent({ packagePolicyId: '1', policyId: '2' });
const request = { const request = {
status: 'error', status: 'error',
isError: true,
data: null, data: null,
error: new Error('some error message'), error: new Error('some error message'),
}; };
@ -78,6 +84,7 @@ describe('<Rules />', () => {
const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' }); const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' });
const request = { const request = {
status: 'loading', status: 'loading',
isLoading: true,
}; };
(useCspIntegration as jest.Mock).mockReturnValue(request); (useCspIntegration as jest.Mock).mockReturnValue(request);

View file

@ -22,11 +22,15 @@ export const createReactQueryResponse = <TData = unknown, TError = unknown>({
data = undefined, data = undefined,
}: CreateReactQueryResponseInput<TData, TError> = {}): Partial<UseQueryResult<TData, TError>> => { }: CreateReactQueryResponseInput<TData, TError> = {}): Partial<UseQueryResult<TData, TError>> => {
if (status === 'success') { if (status === 'success') {
return { status, data }; return { status, data, isSuccess: true, isLoading: false, isError: false };
} }
if (status === 'error') { if (status === 'error') {
return { status, error }; return { status, error, isSuccess: false, isLoading: false, isError: true };
}
if (status === 'loading') {
return { status, data: undefined, isSuccess: false, isLoading: true, isError: false };
} }
return { status }; return { status };

View file

@ -13,7 +13,7 @@ import type {
QueryDslQueryContainer, QueryDslQueryContainer,
SearchRequest, SearchRequest,
} from '@elastic/elasticsearch/lib/api/types'; } from '@elastic/elasticsearch/lib/api/types';
import type { CloudPostureStats } from '../../../common/types'; import type { ComplianceDashboardData } from '../../../common/types';
import { CSP_KUBEBEAT_INDEX_PATTERN, STATS_ROUTE_PATH } from '../../../common/constants'; import { CSP_KUBEBEAT_INDEX_PATTERN, STATS_ROUTE_PATH } from '../../../common/constants';
import { CspAppContext } from '../../plugin'; import { CspAppContext } from '../../plugin';
import { getResourcesTypes } from './get_resources_types'; import { getResourcesTypes } from './get_resources_types';
@ -102,7 +102,7 @@ export const defineGetComplianceDashboardRoute = (
getClusters(esClient, query), getClusters(esClient, query),
]); ]);
const body: CloudPostureStats = { const body: ComplianceDashboardData = {
stats, stats,
resourcesTypes, resourcesTypes,
clusters, clusters,

View file

@ -51,7 +51,7 @@ const mockClusterBuckets: ClusterBucket[] = [
]; ];
describe('getClustersFromAggs', () => { describe('getClustersFromAggs', () => {
it('should return value matching CloudPostureStats["clusters"]', async () => { it('should return value matching ComplianceDashboardData["clusters"]', async () => {
const clusters = getClustersFromAggs(mockClusterBuckets); const clusters = getClustersFromAggs(mockClusterBuckets);
expect(clusters).toEqual([ expect(clusters).toEqual([
{ {

View file

@ -11,7 +11,7 @@ import type {
QueryDslQueryContainer, QueryDslQueryContainer,
SearchRequest, SearchRequest,
} from '@elastic/elasticsearch/lib/api/types'; } from '@elastic/elasticsearch/lib/api/types';
import { CloudPostureStats } from '../../../common/types'; import { ComplianceDashboardData } from '../../../common/types';
import { getResourceTypeFromAggs, resourceTypeAggQuery } from './get_resources_types'; import { getResourceTypeFromAggs, resourceTypeAggQuery } from './get_resources_types';
import type { ResourceTypeQueryResult } from './get_resources_types'; import type { ResourceTypeQueryResult } from './get_resources_types';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
@ -66,7 +66,9 @@ export const getClustersQuery = (query: QueryDslQueryContainer): SearchRequest =
}, },
}); });
export const getClustersFromAggs = (clusters: ClusterBucket[]): CloudPostureStats['clusters'] => export const getClustersFromAggs = (
clusters: ClusterBucket[]
): ComplianceDashboardData['clusters'] =>
clusters.map((cluster) => { clusters.map((cluster) => {
// get cluster's meta data // get cluster's meta data
const benchmarks = cluster.benchmarks.buckets; const benchmarks = cluster.benchmarks.buckets;
@ -101,7 +103,7 @@ export const getClustersFromAggs = (clusters: ClusterBucket[]): CloudPostureStat
export const getClusters = async ( export const getClusters = async (
esClient: ElasticsearchClient, esClient: ElasticsearchClient,
query: QueryDslQueryContainer query: QueryDslQueryContainer
): Promise<CloudPostureStats['clusters']> => { ): Promise<ComplianceDashboardData['clusters']> => {
const queryResult = await esClient.search<unknown, ClustersQueryResult>(getClustersQuery(query), { const queryResult = await esClient.search<unknown, ClustersQueryResult>(getClustersQuery(query), {
meta: true, meta: true,
}); });

View file

@ -31,7 +31,7 @@ const resourceTypeBuckets: ResourceTypeBucket[] = [
]; ];
describe('getResourceTypeFromAggs', () => { describe('getResourceTypeFromAggs', () => {
it('should return value matching CloudPostureStats["resourcesTypes"]', async () => { it('should return value matching ComplianceDashboardData["resourcesTypes"]', async () => {
const resourceTypes = getResourceTypeFromAggs(resourceTypeBuckets); const resourceTypes = getResourceTypeFromAggs(resourceTypeBuckets);
expect(resourceTypes).toEqual([ expect(resourceTypes).toEqual([
{ {

View file

@ -6,12 +6,12 @@
*/ */
import { ElasticsearchClient } from 'kibana/server'; import { ElasticsearchClient } from 'kibana/server';
import { import type {
AggregationsMultiBucketAggregateBase as Aggregation, AggregationsMultiBucketAggregateBase as Aggregation,
QueryDslQueryContainer, QueryDslQueryContainer,
SearchRequest, SearchRequest,
} from '@elastic/elasticsearch/lib/api/types'; } from '@elastic/elasticsearch/lib/api/types';
import { CloudPostureStats } from '../../../common/types'; import type { ComplianceDashboardData } from '../../../common/types';
import { KeyDocCount } from './compliance_dashboard'; import { KeyDocCount } from './compliance_dashboard';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
@ -53,7 +53,7 @@ export const getRisksEsQuery = (query: QueryDslQueryContainer): SearchRequest =>
export const getResourceTypeFromAggs = ( export const getResourceTypeFromAggs = (
queryResult: ResourceTypeBucket[] queryResult: ResourceTypeBucket[]
): CloudPostureStats['resourcesTypes'] => ): ComplianceDashboardData['resourcesTypes'] =>
queryResult.map((bucket) => ({ queryResult.map((bucket) => ({
name: bucket.key, name: bucket.key,
totalFindings: bucket.doc_count, totalFindings: bucket.doc_count,
@ -64,7 +64,7 @@ export const getResourceTypeFromAggs = (
export const getResourcesTypes = async ( export const getResourcesTypes = async (
esClient: ElasticsearchClient, esClient: ElasticsearchClient,
query: QueryDslQueryContainer query: QueryDslQueryContainer
): Promise<CloudPostureStats['resourcesTypes']> => { ): Promise<ComplianceDashboardData['resourcesTypes']> => {
const resourceTypesQueryResult = await esClient.search<unknown, ResourceTypeQueryResult>( const resourceTypesQueryResult = await esClient.search<unknown, ResourceTypeQueryResult>(
getRisksEsQuery(query), getRisksEsQuery(query),
{ meta: true } { meta: true }

View file

@ -59,7 +59,7 @@ describe('getStatsFromFindingsEvaluationsAggs', () => {
expect(score).toEqual(36.4); expect(score).toEqual(36.4);
}); });
it('should return value matching CloudPostureStats["stats"]', async () => { it('should return value matching ComplianceDashboardData["stats"]', async () => {
const stats = getStatsFromFindingsEvaluationsAggs(standardQueryResult); const stats = getStatsFromFindingsEvaluationsAggs(standardQueryResult);
expect(stats).toEqual({ expect(stats).toEqual({
totalFailed: 30, totalFailed: 30,

View file

@ -6,9 +6,9 @@
*/ */
import { ElasticsearchClient } from 'kibana/server'; import { ElasticsearchClient } from 'kibana/server';
import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types'; import type { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
import { CloudPostureStats, Score } from '../../../common/types'; import type { ComplianceDashboardData, Score } from '../../../common/types';
/** /**
* @param value value is [0, 1] range * @param value value is [0, 1] range
@ -44,7 +44,7 @@ export const getEvaluationsQuery = (query: QueryDslQueryContainer): SearchReques
export const getStatsFromFindingsEvaluationsAggs = ( export const getStatsFromFindingsEvaluationsAggs = (
findingsEvaluationsAggs: FindingsEvaluationsQueryResult findingsEvaluationsAggs: FindingsEvaluationsQueryResult
): CloudPostureStats['stats'] => { ): ComplianceDashboardData['stats'] => {
const failedFindings = findingsEvaluationsAggs.failed_findings.doc_count || 0; const failedFindings = findingsEvaluationsAggs.failed_findings.doc_count || 0;
const passedFindings = findingsEvaluationsAggs.passed_findings.doc_count || 0; const passedFindings = findingsEvaluationsAggs.passed_findings.doc_count || 0;
const totalFindings = failedFindings + passedFindings; const totalFindings = failedFindings + passedFindings;
@ -62,7 +62,7 @@ export const getStatsFromFindingsEvaluationsAggs = (
export const getStats = async ( export const getStats = async (
esClient: ElasticsearchClient, esClient: ElasticsearchClient,
query: QueryDslQueryContainer query: QueryDslQueryContainer
): Promise<CloudPostureStats['stats']> => { ): Promise<ComplianceDashboardData['stats']> => {
const evaluationsQueryResult = await esClient.search<unknown, FindingsEvaluationsQueryResult>( const evaluationsQueryResult = await esClient.search<unknown, FindingsEvaluationsQueryResult>(
getEvaluationsQuery(query), getEvaluationsQuery(query),
{ meta: true } { meta: true }