mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Cloud Posture] Refactor status and query logic out of CspPageTemplate (#136104)
This commit is contained in:
parent
ac434b3433
commit
d5ba9cc098
15 changed files with 590 additions and 629 deletions
|
@ -0,0 +1,276 @@
|
|||
/*
|
||||
* 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 Chance from 'chance';
|
||||
import { useCisKubernetesIntegration } from '../common/api/use_cis_kubernetes_integration';
|
||||
import {
|
||||
DEFAULT_NO_DATA_TEST_SUBJECT,
|
||||
ERROR_STATE_TEST_SUBJECT,
|
||||
isCommonError,
|
||||
LOADING_STATE_TEST_SUBJECT,
|
||||
PACKAGE_NOT_INSTALLED_TEST_SUBJECT,
|
||||
} from './cloud_posture_page';
|
||||
import { createReactQueryResponse } from '../test/fixtures/react_query';
|
||||
import { TestProvider } from '../test/test_provider';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { CloudPosturePage } from './cloud_posture_page';
|
||||
import { NoDataPage } from '@kbn/kibana-react-plugin/public';
|
||||
|
||||
const chance = new Chance();
|
||||
jest.mock('../common/api/use_cis_kubernetes_integration');
|
||||
|
||||
describe('<CloudPosturePage />', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
// if package installation status is 'not_installed', CloudPosturePage will render a noDataConfig prompt
|
||||
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
data: { item: { status: 'installed' } },
|
||||
}));
|
||||
});
|
||||
|
||||
const renderCloudPosturePage = (
|
||||
props: ComponentProps<typeof CloudPosturePage> = { children: null }
|
||||
) => {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloudPosturePage {...props} />
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders children if integration is installed', () => {
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children });
|
||||
|
||||
expect(screen.getByText(children)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders integrations installation prompt if integration is not installed', () => {
|
||||
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
data: { item: { status: 'not_installed' } },
|
||||
}));
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children });
|
||||
|
||||
expect(screen.getByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default loading state when the integration query is loading', () => {
|
||||
(useCisKubernetesIntegration as jest.Mock).mockImplementation(
|
||||
() =>
|
||||
createReactQueryResponse({
|
||||
status: 'loading',
|
||||
}) as unknown as UseQueryResult
|
||||
);
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children });
|
||||
|
||||
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default error state when the integration query has an error', () => {
|
||||
(useCisKubernetesIntegration as jest.Mock).mockImplementation(
|
||||
() =>
|
||||
createReactQueryResponse({
|
||||
status: 'error',
|
||||
error: new Error('error'),
|
||||
}) as unknown as UseQueryResult
|
||||
);
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children });
|
||||
|
||||
expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default loading text when query isLoading', () => {
|
||||
const query = createReactQueryResponse({
|
||||
status: 'loading',
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children, query });
|
||||
|
||||
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default loading text when query is idle', () => {
|
||||
const query = createReactQueryResponse({
|
||||
status: 'idle',
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children, query });
|
||||
|
||||
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).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();
|
||||
renderCloudPosturePage({ children, query });
|
||||
|
||||
[error, message, statusCode].forEach((text) =>
|
||||
expect(screen.getByText(text, { exact: false })).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).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();
|
||||
renderCloudPosturePage({
|
||||
children,
|
||||
query,
|
||||
errorRender: (err) => <div>{isCommonError(err) && err.body.message}</div>,
|
||||
});
|
||||
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
[error, statusCode].forEach((text) => expect(screen.queryByText(text)).not.toBeInTheDocument());
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers custom loading render', () => {
|
||||
const loading = chance.sentence();
|
||||
|
||||
const query = createReactQueryResponse({
|
||||
status: 'loading',
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({
|
||||
children,
|
||||
query,
|
||||
loadingRender: () => <div>{loading}</div>,
|
||||
});
|
||||
|
||||
expect(screen.getByText(loading)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no data prompt when query data is undefined', () => {
|
||||
const query = createReactQueryResponse({
|
||||
status: 'success',
|
||||
data: undefined,
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({ children, query });
|
||||
|
||||
expect(screen.getByTestId(DEFAULT_NO_DATA_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers custom no data prompt', () => {
|
||||
const pageTitle = chance.sentence();
|
||||
const solution = chance.sentence();
|
||||
const docsLink = chance.sentence();
|
||||
const noDataRenderer = () => (
|
||||
<NoDataPage pageTitle={pageTitle} solution={solution} docsLink={docsLink} actions={{}} />
|
||||
);
|
||||
|
||||
const query = createReactQueryResponse({
|
||||
status: 'success',
|
||||
data: undefined,
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCloudPosturePage({
|
||||
children,
|
||||
query,
|
||||
noDataRenderer,
|
||||
});
|
||||
|
||||
expect(screen.getByText(pageTitle)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(solution, { exact: false })[0]).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { NoDataPage } from '@kbn/kibana-react-plugin/public';
|
||||
import { css } from '@emotion/react';
|
||||
import { CspLoadingState } from './csp_loading_state';
|
||||
import { useCisKubernetesIntegration } from '../common/api/use_cis_kubernetes_integration';
|
||||
import { useCISIntegrationLink } from '../common/navigation/use_navigate_to_cis_integration';
|
||||
|
||||
export const LOADING_STATE_TEST_SUBJECT = 'cloud_posture_page_loading';
|
||||
export const ERROR_STATE_TEST_SUBJECT = 'cloud_posture_page_error';
|
||||
export const PACKAGE_NOT_INSTALLED_TEST_SUBJECT = 'cloud_posture_page_package_not_installed';
|
||||
export const DEFAULT_NO_DATA_TEST_SUBJECT = 'cloud_posture_page_no_data';
|
||||
|
||||
interface CommonError {
|
||||
body: {
|
||||
error: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const isCommonError = (error: unknown): error is CommonError => {
|
||||
if (
|
||||
!(error as any)?.body ||
|
||||
!(error as any)?.body?.error ||
|
||||
!(error as any)?.body?.message ||
|
||||
!(error as any)?.body?.statusCode
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const packageNotInstalledRenderer = (cisIntegrationLink?: string) => (
|
||||
<NoDataPage
|
||||
data-test-subj={PACKAGE_NOT_INSTALLED_TEST_SUBJECT}
|
||||
css={css`
|
||||
max-width: 950px;
|
||||
margin-top: 50px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
`}
|
||||
pageTitle={i18n.translate('xpack.csp.cloudPosturePage.packageNotInstalled.pageTitle', {
|
||||
defaultMessage: 'Install Integration to get started',
|
||||
})}
|
||||
solution={i18n.translate('xpack.csp.cloudPosturePage.packageNotInstalled.solutionNameLabel', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
})}
|
||||
// TODO: Add real docs link once we have it
|
||||
docsLink={'https://www.elastic.co/guide/index.html'}
|
||||
logo={'logoSecurity'}
|
||||
actions={{
|
||||
elasticAgent: {
|
||||
href: cisIntegrationLink,
|
||||
isDisabled: !cisIntegrationLink,
|
||||
title: i18n.translate('xpack.csp.cloudPosturePage.packageNotInstalled.buttonLabel', {
|
||||
defaultMessage: 'Add a CIS integration',
|
||||
}),
|
||||
description: i18n.translate('xpack.csp.cloudPosturePage.packageNotInstalled.description', {
|
||||
defaultMessage:
|
||||
'Use our CIS Kubernetes Benchmark integration to measure your Kubernetes cluster setup against the CIS recommendations.',
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const defaultLoadingRenderer = () => (
|
||||
<CspLoadingState data-test-subj={LOADING_STATE_TEST_SUBJECT}>
|
||||
<FormattedMessage
|
||||
id="xpack.csp.cloudPosturePage.loadingDescription"
|
||||
defaultMessage="Loading..."
|
||||
/>
|
||||
</CspLoadingState>
|
||||
);
|
||||
|
||||
const defaultErrorRenderer = (error: unknown) => (
|
||||
<EuiEmptyPrompt
|
||||
css={css`
|
||||
margin-top: 50px;
|
||||
`}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
data-test-subj={ERROR_STATE_TEST_SUBJECT}
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.csp.cloudPosturePage.errorRenderer.errorTitle"
|
||||
defaultMessage="We couldn't fetch your cloud security posture data"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
body={
|
||||
isCommonError(error) ? (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.csp.cloudPosturePage.errorRenderer.errorDescription"
|
||||
defaultMessage="{error} {statusCode}: {body}"
|
||||
values={{
|
||||
error: error.body.error,
|
||||
statusCode: error.body.statusCode,
|
||||
body: error.body.message,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const defaultNoDataRenderer = () => {
|
||||
return (
|
||||
<NoDataPage
|
||||
data-test-subj={DEFAULT_NO_DATA_TEST_SUBJECT}
|
||||
css={css`
|
||||
margin-top: 50px;
|
||||
`}
|
||||
pageTitle={i18n.translate('xpack.csp.cloudPosturePage.defaultNoDataConfig.pageTitle', {
|
||||
defaultMessage: 'No data found',
|
||||
})}
|
||||
solution={i18n.translate('xpack.csp.cloudPosturePage.defaultNoDataConfig.solutionNameLabel', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
})}
|
||||
// TODO: Add real docs link once we have it
|
||||
docsLink={'https://www.elastic.co/guide/index.html'}
|
||||
logo={'logoSecurity'}
|
||||
actions={{}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CloudPosturePageProps<TData, TError> {
|
||||
children: React.ReactNode;
|
||||
query?: UseQueryResult<TData, TError>;
|
||||
loadingRender?: () => React.ReactNode;
|
||||
errorRender?: (error: TError) => React.ReactNode;
|
||||
noDataRenderer?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export const CloudPosturePage = <TData, TError>({
|
||||
children,
|
||||
query,
|
||||
loadingRender = defaultLoadingRenderer,
|
||||
errorRender = defaultErrorRenderer,
|
||||
noDataRenderer = defaultNoDataRenderer,
|
||||
}: CloudPosturePageProps<TData, TError>) => {
|
||||
const cisKubernetesPackageInfo = useCisKubernetesIntegration();
|
||||
const cisIntegrationLink = useCISIntegrationLink();
|
||||
|
||||
const render = () => {
|
||||
if (cisKubernetesPackageInfo.isError) {
|
||||
return defaultErrorRenderer(cisKubernetesPackageInfo.error);
|
||||
}
|
||||
|
||||
if (cisKubernetesPackageInfo.isLoading || cisKubernetesPackageInfo.isIdle) {
|
||||
return defaultLoadingRenderer();
|
||||
}
|
||||
|
||||
if (cisKubernetesPackageInfo.data.item.status !== 'installed') {
|
||||
return packageNotInstalledRenderer(cisIntegrationLink);
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return errorRender(query.error);
|
||||
}
|
||||
|
||||
if (query.isLoading || query.isIdle) {
|
||||
return loadingRender();
|
||||
}
|
||||
|
||||
if (!query.data) {
|
||||
return noDataRenderer();
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return <>{render()}</>;
|
||||
};
|
|
@ -18,6 +18,7 @@ export const CspLoadingState: React.FunctionComponent<{ ['data-test-subj']?: str
|
|||
<EuiFlexGroup
|
||||
css={css`
|
||||
padding: ${euiTheme.size.l};
|
||||
margin-top: 50px;
|
||||
`}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
|
|
|
@ -4,33 +4,12 @@
|
|||
* 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 '@kbn/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,
|
||||
ERROR_STATE_TEST_SUBJECT,
|
||||
getSideNavItems,
|
||||
isCommonError,
|
||||
LOADING_STATE_TEST_SUBJECT,
|
||||
} from './csp_page_template';
|
||||
import { PACKAGE_NOT_INSTALLED_TEXT, DEFAULT_NO_DATA_TEXT } from './translations';
|
||||
import { useCisKubernetesIntegration } from '../common/api/use_cis_kubernetes_integration';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { getSideNavItems } from './csp_page_template';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
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();
|
||||
|
@ -52,230 +31,3 @@ describe('getSideNavItems', () => {
|
|||
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(() => ({
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
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.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).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,
|
||||
isLoading: false,
|
||||
data: { item: { status: 'not_installed' } },
|
||||
}));
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCspPageTemplate({ children });
|
||||
|
||||
Object.values(PACKAGE_NOT_INSTALLED_TEXT).forEach((text) =>
|
||||
expect(screen.getAllByText(text)[0]).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).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.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
packageNotInstalledUniqueTexts.forEach((text) =>
|
||||
expect(screen.queryByText(text)).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders default loading text when query is idle', () => {
|
||||
const query = createReactQueryResponse({
|
||||
status: 'idle',
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCspPageTemplate({ children, query });
|
||||
|
||||
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).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, message, statusCode].forEach((text) =>
|
||||
expect(screen.getByText(text, { exact: false })).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).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, statusCode].forEach((text) => expect(screen.queryByText(text)).not.toBeInTheDocument());
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).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.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).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.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).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.getAllByText(solution, { exact: false })[0]).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
packageNotInstalledUniqueTexts.forEach((text) =>
|
||||
expect(screen.queryByText(text)).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,45 +5,18 @@
|
|||
* 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 '@kbn/kibana-react-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiErrorBoundary } from '@elastic/eui';
|
||||
import { KibanaPageTemplate, type KibanaPageTemplateProps } from '@kbn/shared-ux-components';
|
||||
import { allNavigationItems } from '../common/navigation/constants';
|
||||
import type { CspNavigationItem } from '../common/navigation/types';
|
||||
import { CspLoadingState } from './csp_loading_state';
|
||||
import {
|
||||
CLOUD_SECURITY_POSTURE,
|
||||
DEFAULT_NO_DATA_TEXT,
|
||||
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'] =>
|
||||
): NonNullable<NonNullable<KibanaPageTemplateProps['solutionNav']>['items']> =>
|
||||
Object.entries(navigationItems)
|
||||
.filter(([_, navigationItem]) => !navigationItem.disabled)
|
||||
.map(([id, navigationItem]) => ({
|
||||
|
@ -58,7 +31,9 @@ export const getSideNavItems = (
|
|||
|
||||
const DEFAULT_PAGE_PROPS: KibanaPageTemplateProps = {
|
||||
solutionNav: {
|
||||
name: CLOUD_SECURITY_POSTURE,
|
||||
name: i18n.translate('xpack.csp.cspPageTemplate.navigationTitle', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
}),
|
||||
items: getSideNavItems({
|
||||
dashboard: allNavigationItems.dashboard,
|
||||
findings: allNavigationItems.findings,
|
||||
|
@ -68,146 +43,13 @@ const DEFAULT_PAGE_PROPS: KibanaPageTemplateProps = {
|
|||
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: {},
|
||||
};
|
||||
|
||||
export const LOADING_STATE_TEST_SUBJECT = 'csp_page_template_loading';
|
||||
export const ERROR_STATE_TEST_SUBJECT = 'csp_page_template_error';
|
||||
|
||||
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,
|
||||
isDisabled: !cisIntegrationLink,
|
||||
title: PACKAGE_NOT_INSTALLED_TEXT.BUTTON_TITLE,
|
||||
description: PACKAGE_NOT_INSTALLED_TEXT.DESCRIPTION,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const DefaultLoading = () => (
|
||||
<CspLoadingState data-test-subj={LOADING_STATE_TEST_SUBJECT}>
|
||||
<FormattedMessage
|
||||
id="xpack.csp.cspPageTemplate.loadingDescription"
|
||||
defaultMessage="Loading..."
|
||||
/>
|
||||
</CspLoadingState>
|
||||
);
|
||||
|
||||
const DefaultError = (error: unknown) => (
|
||||
<EuiEmptyPrompt
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
data-test-subj={ERROR_STATE_TEST_SUBJECT}
|
||||
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.data?.item.status !== 'installed') {
|
||||
return getPackageNotInstalledNoDataConfig(cisIntegrationLink);
|
||||
}
|
||||
|
||||
// when query was successful, but data is undefined
|
||||
if (query?.isSuccess && !query?.data) {
|
||||
return kibanaPageTemplateProps.noDataConfig || DEFAULT_NO_DATA_CONFIG;
|
||||
}
|
||||
|
||||
return kibanaPageTemplateProps.noDataConfig;
|
||||
};
|
||||
|
||||
const getTemplate = (): KibanaPageTemplateProps['template'] => {
|
||||
if (query?.isLoading || query?.isError || cisKubernetesPackageInfo.isLoading)
|
||||
return 'centeredContent';
|
||||
|
||||
return kibanaPageTemplateProps.template || 'default';
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (query?.isLoading || query?.isIdle || cisKubernetesPackageInfo.isLoading) {
|
||||
return loadingRender();
|
||||
}
|
||||
if (query?.isError) return errorRender(query.error);
|
||||
if (query?.isSuccess) return children;
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
}: KibanaPageTemplateProps) => {
|
||||
return (
|
||||
<KibanaPageTemplate
|
||||
{...DEFAULT_PAGE_PROPS}
|
||||
{...kibanaPageTemplateProps}
|
||||
template={getTemplate()}
|
||||
noDataConfig={cisKubernetesPackageInfo.isSuccess ? getNoDataConfig() : undefined}
|
||||
>
|
||||
<EuiErrorBoundary>
|
||||
<>{render()}</>
|
||||
</EuiErrorBoundary>
|
||||
<KibanaPageTemplate {...DEFAULT_PAGE_PROPS} {...kibanaPageTemplateProps}>
|
||||
<EuiErrorBoundary>{children}</EuiErrorBoundary>
|
||||
</KibanaPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,36 +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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const PACKAGE_NOT_INSTALLED_TEXT = {
|
||||
PAGE_TITLE: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.pageTitle', {
|
||||
defaultMessage: 'Install Integration to get started',
|
||||
}),
|
||||
SOLUTION: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.solutionNameLabel', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
}),
|
||||
BUTTON_TITLE: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.buttonLabel', {
|
||||
defaultMessage: 'Add a CIS integration',
|
||||
}),
|
||||
DESCRIPTION: i18n.translate('xpack.csp.cspPageTemplate.packageNotInstalled.description', {
|
||||
defaultMessage:
|
||||
'Use our CIS Kubernetes Benchmark integration to measure your Kubernetes cluster setup against the CIS recommendations.',
|
||||
}),
|
||||
};
|
||||
|
||||
export const DEFAULT_NO_DATA_TEXT = {
|
||||
PAGE_TITLE: i18n.translate('xpack.csp.cspPageTemplate.defaultNoDataConfig.pageTitle', {
|
||||
defaultMessage: 'No data found',
|
||||
}),
|
||||
SOLUTION: i18n.translate('xpack.csp.cspPageTemplate.defaultNoDataConfig.solutionNameLabel', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
}),
|
||||
};
|
||||
|
||||
export const CLOUD_SECURITY_POSTURE = i18n.translate('xpack.csp.cspPageTemplate.navigationTitle', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
});
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { createReactQueryResponse } from '../../test/fixtures/react_query';
|
||||
import React from 'react';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
@ -13,7 +14,7 @@ import { ComplianceDashboard } from '..';
|
|||
import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api';
|
||||
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
|
||||
import { useComplianceDashboardDataApi } from '../../common/api/use_compliance_dashboard_data_api';
|
||||
import { DASHBOARD_PAGE_HEADER, MISSING_FINDINGS_NO_DATA_CONFIG } from './test_subjects';
|
||||
import { DASHBOARD_CONTAINER, MISSING_FINDINGS_NO_DATA_CONFIG } from './test_subjects';
|
||||
|
||||
jest.mock('../../common/api/use_setup_status_api');
|
||||
jest.mock('../../common/api/use_cis_kubernetes_integration');
|
||||
|
@ -196,17 +197,17 @@ describe('<ComplianceDashboard />', () => {
|
|||
};
|
||||
|
||||
it('shows noDataConfig when latestFindingsIndexStatus is inapplicable', () => {
|
||||
(useCspSetupStatusApi as jest.Mock).mockImplementation(() => ({
|
||||
data: { latestFindingsIndexStatus: 'inapplicable' },
|
||||
}));
|
||||
(useComplianceDashboardDataApi as jest.Mock).mockImplementation(() => ({
|
||||
data: undefined,
|
||||
}));
|
||||
(useCspSetupStatusApi as jest.Mock).mockImplementation(() =>
|
||||
createReactQueryResponse({ status: 'success', data: 'inapplicable' })
|
||||
);
|
||||
(useComplianceDashboardDataApi as jest.Mock).mockImplementation(() =>
|
||||
createReactQueryResponse({ status: 'success', data: undefined })
|
||||
);
|
||||
|
||||
renderComplianceDashboardPage();
|
||||
|
||||
expect(screen.queryByTestId(MISSING_FINDINGS_NO_DATA_CONFIG)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(DASHBOARD_PAGE_HEADER)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(DASHBOARD_CONTAINER)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows dashboard when latestFindingsIndexStatus is applicable', () => {
|
||||
|
@ -225,6 +226,6 @@ describe('<ComplianceDashboard />', () => {
|
|||
renderComplianceDashboardPage();
|
||||
|
||||
expect(screen.queryByTestId(MISSING_FINDINGS_NO_DATA_CONFIG)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(DASHBOARD_PAGE_HEADER)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(DASHBOARD_CONTAINER)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,10 +7,12 @@
|
|||
|
||||
import React from 'react';
|
||||
import { EuiSpacer, EuiIcon } from '@elastic/eui';
|
||||
import { type KibanaPageTemplateProps } from '@kbn/kibana-react-plugin/public';
|
||||
import { NoDataPage } from '@kbn/kibana-react-plugin/public';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DASHBOARD_PAGE_HEADER, MISSING_FINDINGS_NO_DATA_CONFIG } from './test_subjects';
|
||||
import { css } from '@emotion/react';
|
||||
import { CloudPosturePage } from '../../components/cloud_posture_page';
|
||||
import { DASHBOARD_CONTAINER, MISSING_FINDINGS_NO_DATA_CONFIG } from './test_subjects';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { SummarySection } from './dashboard_sections/summary_section';
|
||||
|
@ -19,31 +21,39 @@ import { useComplianceDashboardDataApi } from '../../common/api';
|
|||
import { CspPageTemplate } from '../../components/csp_page_template';
|
||||
import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api';
|
||||
|
||||
const getNoDataConfig = (onClick: () => void): KibanaPageTemplateProps['noDataConfig'] => ({
|
||||
'data-test-subj': MISSING_FINDINGS_NO_DATA_CONFIG,
|
||||
pageTitle: i18n.translate('xpack.csp.dashboard.noDataConfig.pageTitle', {
|
||||
defaultMessage: 'Cloud Posture Dashboard',
|
||||
}),
|
||||
solution: i18n.translate('xpack.csp.dashboard.noDataConfig.solutionNameTitle', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
}),
|
||||
// TODO: Add real docs link once we have it
|
||||
docsLink: 'https://www.elastic.co/guide/index.html',
|
||||
logo: 'logoSecurity',
|
||||
actions: {
|
||||
dashboardNoDataCard: {
|
||||
icon: <EuiIcon type="refresh" size="xxl" />,
|
||||
onClick,
|
||||
title: i18n.translate('xpack.csp.dashboard.noDataConfig.actionTitle', {
|
||||
defaultMessage: 'Try Again',
|
||||
}),
|
||||
description: i18n.translate('xpack.csp.dashboard.noDataConfig.actionDescription', {
|
||||
defaultMessage:
|
||||
"The cloud posture dashboard can't be presented since there are no findings. This can happen due to the agent not being installed yet, or since data is still being processed.",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const NoData = ({ onClick }: { onClick: () => void }) => (
|
||||
<NoDataPage
|
||||
css={css`
|
||||
margin-top: 50px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 950px;
|
||||
`}
|
||||
data-test-subj={MISSING_FINDINGS_NO_DATA_CONFIG}
|
||||
pageTitle={i18n.translate('xpack.csp.dashboard.noDataConfig.pageTitle', {
|
||||
defaultMessage: 'Cloud Posture Dashboard',
|
||||
})}
|
||||
solution={i18n.translate('xpack.csp.dashboard.noDataConfig.solutionNameTitle', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
})}
|
||||
// TODO: Add real docs link once we have it
|
||||
docsLink={'https://www.elastic.co/guide/index.html'}
|
||||
logo={'logoSecurity'}
|
||||
actions={{
|
||||
dashboardNoDataCard: {
|
||||
icon: <EuiIcon type="refresh" size="xxl" />,
|
||||
onClick,
|
||||
title: i18n.translate('xpack.csp.dashboard.noDataConfig.actionTitle', {
|
||||
defaultMessage: 'Try Again',
|
||||
}),
|
||||
description: i18n.translate('xpack.csp.dashboard.noDataConfig.actionDescription', {
|
||||
defaultMessage:
|
||||
"The cloud posture dashboard can't be presented since there are no findings. This can happen due to the agent not being installed yet, or since data is still being processed.",
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ComplianceDashboard = () => {
|
||||
const getInfo = useCspSetupStatusApi();
|
||||
|
@ -51,6 +61,7 @@ export const ComplianceDashboard = () => {
|
|||
const getDashboardData = useComplianceDashboardDataApi({
|
||||
enabled: isFindingsIndexApplicable,
|
||||
});
|
||||
|
||||
useCspBreadcrumbs([allNavigationItems.dashboard]);
|
||||
|
||||
const pageQuery: UseQueryResult = isFindingsIndexApplicable ? getDashboardData : getInfo;
|
||||
|
@ -58,23 +69,24 @@ export const ComplianceDashboard = () => {
|
|||
return (
|
||||
<CspPageTemplate
|
||||
pageHeader={{
|
||||
'data-test-subj': DASHBOARD_PAGE_HEADER,
|
||||
pageTitle: i18n.translate('xpack.csp.dashboard.cspPageTemplate.pageTitle', {
|
||||
defaultMessage: 'Cloud Posture',
|
||||
}),
|
||||
}}
|
||||
restrictWidth={1600}
|
||||
query={pageQuery}
|
||||
noDataConfig={!isFindingsIndexApplicable ? getNoDataConfig(getInfo.refetch) : undefined}
|
||||
>
|
||||
{getDashboardData.data && (
|
||||
<>
|
||||
<SummarySection complianceData={getDashboardData.data} />
|
||||
<EuiSpacer />
|
||||
<BenchmarksSection complianceData={getDashboardData.data} />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
<CloudPosturePage query={pageQuery}>
|
||||
{isFindingsIndexApplicable ? (
|
||||
<div data-test-subj={DASHBOARD_CONTAINER}>
|
||||
<SummarySection complianceData={getDashboardData.data!} />
|
||||
<EuiSpacer />
|
||||
<BenchmarksSection complianceData={getDashboardData.data!} />
|
||||
<EuiSpacer />
|
||||
</div>
|
||||
) : (
|
||||
<NoData onClick={getInfo.refetch} />
|
||||
)}
|
||||
</CloudPosturePage>
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,4 +6,4 @@
|
|||
*/
|
||||
|
||||
export const MISSING_FINDINGS_NO_DATA_CONFIG = 'missing-findings-no-data-config';
|
||||
export const DASHBOARD_PAGE_HEADER = 'dashboard-page-header';
|
||||
export const DASHBOARD_CONTAINER = 'dashboard-container';
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import React from 'react';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { Redirect, Switch, Route, useLocation } from 'react-router-dom';
|
||||
import { CloudPosturePage } from '../../components/cloud_posture_page';
|
||||
import { useFindingsEsPit } from './es_pit/use_findings_es_pit';
|
||||
import { FindingsEsPitContext } from './es_pit/findings_es_pit_context';
|
||||
import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view';
|
||||
|
@ -21,48 +22,50 @@ export const Findings = () => {
|
|||
// TODO: Consider splitting the PIT window so that each "group by" view has its own PIT
|
||||
const { pitQuery, pitIdRef, setPitId } = useFindingsEsPit('findings');
|
||||
|
||||
let queryForPageTemplate: UseQueryResult = dataViewQuery;
|
||||
let queryForSetupStatus: UseQueryResult = dataViewQuery;
|
||||
if (pitQuery.isError || pitQuery.isLoading || pitQuery.isIdle) {
|
||||
queryForPageTemplate = pitQuery;
|
||||
queryForSetupStatus = pitQuery;
|
||||
}
|
||||
|
||||
return (
|
||||
<CspPageTemplate paddingSize="none" query={queryForPageTemplate}>
|
||||
<FindingsEsPitContext.Provider
|
||||
value={{
|
||||
pitQuery,
|
||||
// Asserting the ref as a string value since at this point the query was necessarily successful
|
||||
pitIdRef: pitIdRef as React.MutableRefObject<string>,
|
||||
setPitId,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={allNavigationItems.findings.path}
|
||||
component={() => (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: findingsNavigation.findings_default.path,
|
||||
search: location.search,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_default.path}
|
||||
render={() => <LatestFindingsContainer dataView={dataViewQuery.data!} />}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_by_resource.path}
|
||||
render={() => <FindingsByResourceContainer dataView={dataViewQuery.data!} />}
|
||||
/>
|
||||
<Route
|
||||
path={'*'}
|
||||
component={() => <Redirect to={findingsNavigation.findings_default.path} />}
|
||||
/>
|
||||
</Switch>
|
||||
</FindingsEsPitContext.Provider>
|
||||
<CspPageTemplate paddingSize="none">
|
||||
<CloudPosturePage query={queryForSetupStatus}>
|
||||
<FindingsEsPitContext.Provider
|
||||
value={{
|
||||
pitQuery,
|
||||
// Asserting the ref as a string value since at this point the query was necessarily successful
|
||||
pitIdRef: pitIdRef as React.MutableRefObject<string>,
|
||||
setPitId,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={allNavigationItems.findings.path}
|
||||
component={() => (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: findingsNavigation.findings_default.path,
|
||||
search: location.search,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_default.path}
|
||||
render={() => <LatestFindingsContainer dataView={dataViewQuery.data!} />}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_by_resource.path}
|
||||
render={() => <FindingsByResourceContainer dataView={dataViewQuery.data!} />}
|
||||
/>
|
||||
<Route
|
||||
path={'*'}
|
||||
component={() => <Redirect to={findingsNavigation.findings_default.path} />}
|
||||
/>
|
||||
</Switch>
|
||||
</FindingsEsPitContext.Provider>
|
||||
</CloudPosturePage>
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,19 +7,18 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import { generatePath, Link, RouteComponentProps } from 'react-router-dom';
|
||||
import { EuiTextColor, EuiEmptyPrompt, EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
|
||||
import * as t from 'io-ts';
|
||||
import type { KibanaPageTemplateProps } from '@kbn/kibana-react-plugin/public';
|
||||
import { EuiTextColor, EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
|
||||
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-components';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { pagePathGetters } from '@kbn/fleet-plugin/public';
|
||||
import { RulesContainer, type PageUrlParams } from './rules_container';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { CspNavigationItem } from '../../common/navigation/types';
|
||||
import { extractErrorMessage } from '../../../common/utils/helpers';
|
||||
import { useCspIntegrationInfo } from './use_csp_integration';
|
||||
import { CspPageTemplate } from '../../components/csp_page_template';
|
||||
import { useKibana } from '../../common/hooks/use_kibana';
|
||||
import { CloudPosturePage } from '../../components/cloud_posture_page';
|
||||
|
||||
const getRulesBreadcrumbs = (name?: string): CspNavigationItem[] =>
|
||||
[allNavigationItems.benchmarks, { ...allNavigationItems.rules, name }].filter(
|
||||
|
@ -93,34 +92,10 @@ export const Rules = ({ match: { params } }: RouteComponentProps<PageUrlParams>)
|
|||
);
|
||||
|
||||
return (
|
||||
<CspPageTemplate
|
||||
{...pageProps}
|
||||
query={integrationInfo}
|
||||
errorRender={(error) => <RulesErrorPrompt error={extractErrorBodyMessage(error)} />}
|
||||
>
|
||||
<RulesContainer />
|
||||
<CspPageTemplate {...pageProps}>
|
||||
<CloudPosturePage query={integrationInfo}>
|
||||
<RulesContainer />
|
||||
</CloudPosturePage>
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
// react-query puts the response data on the 'error' object
|
||||
const bodyError = t.type({
|
||||
body: t.type({
|
||||
message: t.string,
|
||||
}),
|
||||
});
|
||||
|
||||
const extractErrorBodyMessage = (err: unknown) => {
|
||||
if (bodyError.is(err)) return err.body.message;
|
||||
return extractErrorMessage(err);
|
||||
};
|
||||
|
||||
const RulesErrorPrompt = ({ error }: { error: string }) => (
|
||||
<EuiEmptyPrompt
|
||||
{...{
|
||||
color: 'danger',
|
||||
iconType: 'alert',
|
||||
title: <h2>{error}</h2>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LOADING_STATE_TEST_SUBJECT } from '../../components/csp_page_template';
|
||||
import { Rules } from '.';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient } from 'react-query';
|
||||
|
@ -79,33 +78,6 @@ describe('<Rules />', () => {
|
|||
expect(useCspIntegrationInfo).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('displays error state when request had an error', async () => {
|
||||
const Component = getTestComponent({ packagePolicyId: '1', policyId: '2' });
|
||||
const request = createReactQueryResponse({
|
||||
status: 'error',
|
||||
error: new Error('some error message'),
|
||||
});
|
||||
|
||||
(useCspIntegrationInfo as jest.Mock).mockReturnValue(request);
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(await screen.findByText(request.error?.message!)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays loading state when request is pending', () => {
|
||||
const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' });
|
||||
const request = createReactQueryResponse({
|
||||
status: 'loading',
|
||||
});
|
||||
|
||||
(useCspIntegrationInfo as jest.Mock).mockReturnValue(request);
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays success state when result request is resolved', async () => {
|
||||
const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' });
|
||||
const request = createReactQueryResponse({
|
||||
|
|
|
@ -10439,14 +10439,7 @@
|
|||
"xpack.csp.cspHealthBadge.criticalLabel": "Critique",
|
||||
"xpack.csp.cspHealthBadge.healthyLabel": "Intègre",
|
||||
"xpack.csp.cspHealthBadge.warningLabel": "Avertissement",
|
||||
"xpack.csp.cspPageTemplate.defaultNoDataConfig.pageTitle": "Aucune donnée trouvée",
|
||||
"xpack.csp.cspPageTemplate.defaultNoDataConfig.solutionNameLabel": "Niveau de sécurité du cloud",
|
||||
"xpack.csp.cspPageTemplate.loadingDescription": "Chargement...",
|
||||
"xpack.csp.cspPageTemplate.navigationTitle": "Niveau de sécurité du cloud",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.buttonLabel": "Ajouter une intégration CIS",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.description": "Utilisez notre intégration CIS Kubernetes Benchmark pour mesurer votre configuration de cluster Kubernetes par rapport aux recommandations du CIS.",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.pageTitle": "Installer l'intégration pour commencer",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.solutionNameLabel": "Niveau de sécurité du cloud",
|
||||
"xpack.csp.cspSettings.rules": "Règles de sécurité du CSP - ",
|
||||
"xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "Section CIS",
|
||||
"xpack.csp.expandColumnDescriptionLabel": "Développer",
|
||||
|
@ -10466,9 +10459,6 @@
|
|||
"xpack.csp.findings.resourceFindings.backToResourcesPageButtonLabel": "Retour à la vue de regroupement par ressource",
|
||||
"xpack.csp.findings.searchBar.searchPlaceholder": "Rechercher dans les résultats (par ex. rule.section.keyword : \"serveur d'API\")",
|
||||
"xpack.csp.navigation.cloudPostureBreadcrumbLabel": "Niveau du cloud",
|
||||
"xpack.csp.pageTemplate.errorDetails.errorBodyTitle": "{body}",
|
||||
"xpack.csp.pageTemplate.errorDetails.errorCodeTitle": "{error} {statusCode}",
|
||||
"xpack.csp.pageTemplate.loadErrorMessage": "Nous n'avons pas pu récupérer vos données sur le niveau de sécurité du cloud.",
|
||||
"xpack.csp.rules.activateAllButtonLabel": "Activer {count, plural, one {# règle} other {# règles}}",
|
||||
"xpack.csp.rules.clearSelectionButtonLabel": "Effacer la sélection",
|
||||
"xpack.csp.rules.deactivateAllButtonLabel": "Désactiver {count, plural, one {# règle} other {# règles}}",
|
||||
|
|
|
@ -10431,14 +10431,7 @@
|
|||
"xpack.csp.cspHealthBadge.criticalLabel": "重大",
|
||||
"xpack.csp.cspHealthBadge.healthyLabel": "正常",
|
||||
"xpack.csp.cspHealthBadge.warningLabel": "警告",
|
||||
"xpack.csp.cspPageTemplate.defaultNoDataConfig.pageTitle": "データが見つかりません",
|
||||
"xpack.csp.cspPageTemplate.defaultNoDataConfig.solutionNameLabel": "クラウドセキュリティ態勢",
|
||||
"xpack.csp.cspPageTemplate.loadingDescription": "読み込み中...",
|
||||
"xpack.csp.cspPageTemplate.navigationTitle": "クラウドセキュリティ態勢",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.buttonLabel": "CIS統合を追加",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.description": "CIS Kubernetes Benchmark統合は、CISの推奨事項に照らしてKubernetesクラスター設定を測定します。",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.pageTitle": "開始するには統合をインストールしてください",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.solutionNameLabel": "クラウドセキュリティ態勢",
|
||||
"xpack.csp.cspSettings.rules": "CSPセキュリティルール - ",
|
||||
"xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "CISセクション",
|
||||
"xpack.csp.expandColumnDescriptionLabel": "拡張",
|
||||
|
@ -10458,9 +10451,6 @@
|
|||
"xpack.csp.findings.resourceFindings.backToResourcesPageButtonLabel": "リソース別グループビューに戻る",
|
||||
"xpack.csp.findings.searchBar.searchPlaceholder": "検索結果(例:rule.section.keyword:\"API Server\")",
|
||||
"xpack.csp.navigation.cloudPostureBreadcrumbLabel": "クラウド態勢",
|
||||
"xpack.csp.pageTemplate.errorDetails.errorBodyTitle": "{body}",
|
||||
"xpack.csp.pageTemplate.errorDetails.errorCodeTitle": "{error} {statusCode}",
|
||||
"xpack.csp.pageTemplate.loadErrorMessage": "クラウドセキュリティ態勢データを取得できませんでした",
|
||||
"xpack.csp.rules.activateAllButtonLabel": "{count, plural, other {#個のルール}}をアクティブ化",
|
||||
"xpack.csp.rules.clearSelectionButtonLabel": "選択した項目をクリア",
|
||||
"xpack.csp.rules.deactivateAllButtonLabel": "{count, plural, other {#個のルール}}を非アクティブ化",
|
||||
|
|
|
@ -10446,14 +10446,7 @@
|
|||
"xpack.csp.cspHealthBadge.criticalLabel": "紧急",
|
||||
"xpack.csp.cspHealthBadge.healthyLabel": "运行正常",
|
||||
"xpack.csp.cspHealthBadge.warningLabel": "警告",
|
||||
"xpack.csp.cspPageTemplate.defaultNoDataConfig.pageTitle": "未找到任何数据",
|
||||
"xpack.csp.cspPageTemplate.defaultNoDataConfig.solutionNameLabel": "云安全态势",
|
||||
"xpack.csp.cspPageTemplate.loadingDescription": "正在加载……",
|
||||
"xpack.csp.cspPageTemplate.navigationTitle": "云安全态势",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.buttonLabel": "添加 CIS 集成",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.description": "使用我们的 CIS Kubernetes 基准集成根据 CIS 建议衡量 Kubernetes 集群设置。",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.pageTitle": "安装集成以开始",
|
||||
"xpack.csp.cspPageTemplate.packageNotInstalled.solutionNameLabel": "云安全态势",
|
||||
"xpack.csp.cspSettings.rules": "CSP 安全规则 - ",
|
||||
"xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "CIS 部分",
|
||||
"xpack.csp.expandColumnDescriptionLabel": "展开",
|
||||
|
@ -10473,9 +10466,6 @@
|
|||
"xpack.csp.findings.resourceFindings.backToResourcesPageButtonLabel": "返回到按资源视图分组",
|
||||
"xpack.csp.findings.searchBar.searchPlaceholder": "搜索结果(例如,rule.section.keyword:“APM 服务器”)",
|
||||
"xpack.csp.navigation.cloudPostureBreadcrumbLabel": "云态势",
|
||||
"xpack.csp.pageTemplate.errorDetails.errorBodyTitle": "{body}",
|
||||
"xpack.csp.pageTemplate.errorDetails.errorCodeTitle": "{error} {statusCode}",
|
||||
"xpack.csp.pageTemplate.loadErrorMessage": "我们无法提取您的云安全态势数据",
|
||||
"xpack.csp.rules.activateAllButtonLabel": "激活 {count, plural, other {# 个规则}}",
|
||||
"xpack.csp.rules.clearSelectionButtonLabel": "清除所选内容",
|
||||
"xpack.csp.rules.deactivateAllButtonLabel": "停用 {count, plural, other {# 个规则}}",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue