mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 02:09:32 -04:00
CSP Viewing states (#128215)
This commit is contained in:
parent
7cb72014aa
commit
23586d2e4f
30 changed files with 646 additions and 412 deletions
|
@ -35,7 +35,7 @@ export interface Cluster {
|
|||
resourcesTypes: ResourceType[];
|
||||
}
|
||||
|
||||
export interface CloudPostureStats {
|
||||
export interface ComplianceDashboardData {
|
||||
stats: Stats;
|
||||
resourcesTypes: ResourceType[];
|
||||
clusters: Cluster[];
|
||||
|
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './use_cloud_posture_stats_api';
|
||||
export * from './use_compliance_dashboard_data_api';
|
||||
|
|
|
@ -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 } }
|
||||
)
|
||||
);
|
||||
};
|
|
@ -7,12 +7,12 @@
|
|||
|
||||
import { useQuery } from 'react-query';
|
||||
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';
|
||||
|
||||
const getStatsKey = 'csp_dashboard_stats';
|
||||
|
||||
export const useCloudPostureStatsApi = () => {
|
||||
export const useComplianceDashboardDataApi = () => {
|
||||
const { http } = useKibana().services;
|
||||
return useQuery([getStatsKey], () => http!.get<CloudPostureStats>(STATS_ROUTE_PATH));
|
||||
return useQuery([getStatsKey], () => http!.get<ComplianceDashboardData>(STATS_ROUTE_PATH));
|
||||
};
|
|
@ -19,8 +19,8 @@ import { CHART_PANEL_TEST_SUBJECTS } from './constants';
|
|||
interface ChartPanelProps {
|
||||
title?: string;
|
||||
hasBorder?: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
const Loading = () => (
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -40,28 +40,27 @@ export const CSP_EVALUATION_BADGE_PASSED = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const NO_DATA_CONFIG_TITLE = i18n.translate('xpack.csp.pageTemplate.noDataConfigTitle', {
|
||||
defaultMessage: 'Understand your cloud security posture',
|
||||
});
|
||||
|
||||
export const NO_DATA_CONFIG_SOLUTION_NAME = i18n.translate(
|
||||
'xpack.csp.pageTemplate.noDataConfig.solutionNameLabel',
|
||||
{
|
||||
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',
|
||||
}
|
||||
);
|
||||
|
||||
export const NO_DATA_CONFIG_DESCRIPTION = i18n.translate(
|
||||
'xpack.csp.pageTemplate.noDataConfigDescription',
|
||||
{
|
||||
}),
|
||||
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 NO_DATA_CONFIG_BUTTON = i18n.translate(
|
||||
'xpack.csp.pageTemplate.noDataConfigButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Add a CIS integration',
|
||||
}
|
||||
);
|
||||
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',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { CspPageTemplate } from './page_template';
|
||||
import { CspPageTemplate } from './csp_page_template';
|
||||
import * as TEXT from './translations';
|
||||
|
||||
export const UnknownRoute = React.memo(() => (
|
||||
|
|
|
@ -21,13 +21,20 @@ import {
|
|||
TABLE_COLUMN_HEADERS,
|
||||
} from './translations';
|
||||
import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
|
||||
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
|
||||
|
||||
jest.mock('./use_csp_benchmark_integrations');
|
||||
jest.mock('../../common/api/use_kubebeat_data_view');
|
||||
jest.mock('../../common/api/use_cis_kubernetes_integration');
|
||||
|
||||
describe('<Benchmarks />', () => {
|
||||
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' } },
|
||||
}));
|
||||
// Required for the page template to render the benchmarks page
|
||||
(useKubebeatDataView as jest.Mock).mockImplementation(() =>
|
||||
createReactQueryResponse({
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
EuiFieldSearch,
|
||||
EuiFieldSearchProps,
|
||||
|
@ -15,13 +17,12 @@ import {
|
|||
EuiTextColor,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
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 { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations';
|
||||
import {
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
EuiLink,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { CloudPostureStats, ResourceType } from '../../../../common/types';
|
||||
import { ComplianceDashboardData, ResourceType } from '../../../../common/types';
|
||||
import { CompactFormattedNumber } from '../../../components/compact_formatted_number';
|
||||
import * as TEXT from '../translations';
|
||||
import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants';
|
||||
|
@ -59,14 +59,14 @@ const mockData = [
|
|||
];
|
||||
|
||||
export interface RisksTableProps {
|
||||
data: CloudPostureStats['resourcesTypes'];
|
||||
data: ComplianceDashboardData['resourcesTypes'];
|
||||
maxItems: number;
|
||||
onCellClick: (resourceTypeName: string) => void;
|
||||
onViewAllClick: () => void;
|
||||
}
|
||||
|
||||
export const getTopRisks = (
|
||||
resourcesTypes: CloudPostureStats['resourcesTypes'],
|
||||
resourcesTypes: ComplianceDashboardData['resourcesTypes'],
|
||||
maxItems: number
|
||||
) => {
|
||||
const filtered = resourcesTypes.filter((x) => x.totalFailed > 0);
|
||||
|
|
|
@ -6,40 +6,51 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { EuiSpacer, EuiIcon } from '@elastic/eui';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { SummarySection } from './dashboard_sections/summary_section';
|
||||
import { BenchmarksSection } from './dashboard_sections/benchmarks_section';
|
||||
import { useCloudPostureStatsApi } from '../../common/api';
|
||||
import { CspPageTemplate } from '../../components/page_template';
|
||||
import * as TEXT from './translations';
|
||||
import { useComplianceDashboardDataApi } from '../../common/api';
|
||||
import { CspPageTemplate } from '../../components/csp_page_template';
|
||||
import { type KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { CLOUD_POSTURE, NO_DATA_CONFIG_TEXT } from './translations';
|
||||
|
||||
const CompliancePage = () => {
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
if (getStats.isLoading) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummarySection />
|
||||
<EuiSpacer />
|
||||
<BenchmarksSection />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
const getNoDataConfig = (onClick: () => void): KibanaPageTemplateProps['noDataConfig'] => ({
|
||||
pageTitle: NO_DATA_CONFIG_TEXT.PAGE_TITLE,
|
||||
solution: NO_DATA_CONFIG_TEXT.SOLUTION,
|
||||
// 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: NO_DATA_CONFIG_TEXT.BUTTON_TITLE,
|
||||
description: NO_DATA_CONFIG_TEXT.DESCRIPTION,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ComplianceDashboard = () => {
|
||||
const getDashboardDataQuery = useComplianceDashboardDataApi();
|
||||
useCspBreadcrumbs([allNavigationItems.dashboard]);
|
||||
|
||||
return (
|
||||
<CspPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: TEXT.CLOUD_POSTURE,
|
||||
}}
|
||||
pageHeader={{ pageTitle: CLOUD_POSTURE }}
|
||||
restrictWidth={1600}
|
||||
query={getDashboardDataQuery}
|
||||
noDataConfig={getNoDataConfig(getDashboardDataQuery.refetch)}
|
||||
>
|
||||
<CompliancePage />
|
||||
{getDashboardDataQuery.data && (
|
||||
<>
|
||||
<SummarySection complianceData={getDashboardDataQuery.data} />
|
||||
<EuiSpacer />
|
||||
<BenchmarksSection complianceData={getDashboardDataQuery.data} />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -17,34 +17,25 @@ import {
|
|||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import moment from 'moment';
|
||||
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
|
||||
import { PartitionElementEvent } from '@elastic/charts';
|
||||
import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
|
||||
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 * as TEXT from '../translations';
|
||||
import { Evaluation } from '../../../../common/types';
|
||||
import type { ComplianceDashboardData, Evaluation } from '../../../../common/types';
|
||||
import { RisksTable } from '../compliance_charts/risks_table';
|
||||
import { INTERNAL_FEATURE_FLAGS, RULE_FAILED } from '../../../../common/constants';
|
||||
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;
|
||||
|
||||
export const BenchmarksSection = () => {
|
||||
export const BenchmarksSection = ({
|
||||
complianceData,
|
||||
}: {
|
||||
complianceData: ComplianceDashboardData;
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const navToFindings = useNavigateFindings();
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
const clusters = getStats.isSuccess && getStats.data.clusters;
|
||||
if (!clusters) return null;
|
||||
|
||||
const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => {
|
||||
const [element] = elements;
|
||||
|
@ -68,7 +59,7 @@ export const BenchmarksSection = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{clusters.map((cluster) => {
|
||||
{complianceData.clusters.map((cluster) => {
|
||||
const shortId = cluster.meta.clusterId.slice(0, 6);
|
||||
|
||||
return (
|
||||
|
@ -82,7 +73,7 @@ export const BenchmarksSection = () => {
|
|||
<h4>{cluster.meta.benchmarkName}</h4>
|
||||
</EuiText>
|
||||
<EuiText style={{ textAlign: 'center' }}>
|
||||
<h4>{`Cluster ID ${shortId || mockClusterId}`}</h4>
|
||||
<h4>{`Cluster ID ${shortId}`}</h4>
|
||||
</EuiText>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText size="xs" color="subdued" style={{ textAlign: 'center' }}>
|
||||
|
@ -91,7 +82,8 @@ export const BenchmarksSection = () => {
|
|||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<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 grow={false}>
|
||||
{INTERNAL_FEATURE_FLAGS.showManageRulesMock && (
|
||||
|
@ -104,12 +96,7 @@ export const BenchmarksSection = () => {
|
|||
grow={4}
|
||||
style={{ borderRight: `1px solid ${euiTheme.colors.lightShade}` }}
|
||||
>
|
||||
<ChartPanel
|
||||
title={TEXT.COMPLIANCE_SCORE}
|
||||
hasBorder={false}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
<ChartPanel title={TEXT.COMPLIANCE_SCORE} hasBorder={false}>
|
||||
<CloudPostureScoreChart
|
||||
id={`${cluster.meta.clusterId}_score_chart`}
|
||||
data={cluster.stats}
|
||||
|
@ -120,12 +107,7 @@ export const BenchmarksSection = () => {
|
|||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={4}>
|
||||
<ChartPanel
|
||||
title={TEXT.RISKS}
|
||||
hasBorder={false}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
<ChartPanel title={TEXT.RISKS} hasBorder={false}>
|
||||
<RisksTable
|
||||
data={cluster.resourcesTypes}
|
||||
maxItems={3}
|
||||
|
|
|
@ -9,10 +9,9 @@ import React from 'react';
|
|||
import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui';
|
||||
import { PartitionElementEvent } from '@elastic/charts';
|
||||
import { ChartPanel } from '../../../components/chart_panel';
|
||||
import { useCloudPostureStatsApi } from '../../../common/api';
|
||||
import * as TEXT from '../translations';
|
||||
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 { CasesTable } from '../compliance_charts/cases_table';
|
||||
import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings';
|
||||
|
@ -25,10 +24,8 @@ const summarySectionWrapperStyle = {
|
|||
height: defaultHeight,
|
||||
};
|
||||
|
||||
export const SummarySection = () => {
|
||||
export const SummarySection = ({ complianceData }: { complianceData: ComplianceDashboardData }) => {
|
||||
const navToFindings = useNavigateFindings();
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
if (!getStats.isSuccess) return null;
|
||||
|
||||
const handleElementClick = (elements: PartitionElementEvent[]) => {
|
||||
const [element] = elements;
|
||||
|
@ -49,22 +46,18 @@ export const SummarySection = () => {
|
|||
return (
|
||||
<EuiFlexGrid columns={3} style={summarySectionWrapperStyle}>
|
||||
<EuiFlexItem>
|
||||
<ChartPanel
|
||||
title={TEXT.CLOUD_POSTURE_SCORE}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
<ChartPanel title={TEXT.CLOUD_POSTURE_SCORE}>
|
||||
<CloudPostureScoreChart
|
||||
id="cloud_posture_score_chart"
|
||||
data={getStats.data.stats}
|
||||
data={complianceData.stats}
|
||||
partitionOnElementClick={handleElementClick}
|
||||
/>
|
||||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<ChartPanel title={TEXT.RISKS} isLoading={getStats.isLoading} isError={getStats.isError}>
|
||||
<ChartPanel title={TEXT.RISKS}>
|
||||
<RisksTable
|
||||
data={getStats.data.resourcesTypes}
|
||||
data={complianceData.resourcesTypes}
|
||||
maxItems={5}
|
||||
onCellClick={handleCellClick}
|
||||
onViewAllClick={handleViewAllClick}
|
||||
|
@ -72,11 +65,7 @@ export const SummarySection = () => {
|
|||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<ChartPanel
|
||||
title={TEXT.OPEN_CASES}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
<ChartPanel title={TEXT.OPEN_CASES}>
|
||||
<CasesTable />
|
||||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -82,3 +82,18 @@ export const RESOURCE_TYPE = i18n.translate('xpack.csp.resource_type', {
|
|||
export const FINDINGS = i18n.translate('xpack.csp.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',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -17,9 +17,11 @@ import {
|
|||
import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub';
|
||||
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
|
||||
import type { DataView } from '../../../../../../src/plugins/data/common';
|
||||
|
||||
jest.mock('../../common/api/use_kubebeat_data_view');
|
||||
jest.mock('../../common/api/use_cis_kubernetes_integration');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
@ -36,6 +38,9 @@ describe('<Findings />', () => {
|
|||
const data = dataPluginMock.createStartContract();
|
||||
const source = await data.search.searchSource.create();
|
||||
|
||||
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
|
||||
data: { item: { status: 'installed' } },
|
||||
}));
|
||||
(source.fetch$ as jest.Mock).mockReturnValue({
|
||||
toPromise: () => Promise.resolve({ rawResponse: { hits: { hits: [] } } }),
|
||||
});
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useKubebeatDataView } from '../../common/api/use_kubebeat_data_view';
|
|||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { FindingsContainer } from './findings_container';
|
||||
import { CspPageTemplate } from '../../components/page_template';
|
||||
import { CspPageTemplate } from '../../components/csp_page_template';
|
||||
import { FINDINGS } from './translations';
|
||||
|
||||
const pageHeader: EuiPageHeaderProps = {
|
||||
|
@ -18,15 +18,12 @@ const pageHeader: EuiPageHeaderProps = {
|
|||
};
|
||||
|
||||
export const Findings = () => {
|
||||
const dataView = useKubebeatDataView();
|
||||
const dataViewQuery = useKubebeatDataView();
|
||||
useCspBreadcrumbs([allNavigationItems.findings]);
|
||||
|
||||
return (
|
||||
// `CspPageTemplate` takes care of loading and error states for the kubebeat data view, no need to handle them here
|
||||
<CspPageTemplate pageHeader={pageHeader}>
|
||||
{dataView.status === 'success' && dataView.data && (
|
||||
<FindingsContainer dataView={dataView.data} />
|
||||
)}
|
||||
<CspPageTemplate pageHeader={pageHeader} query={dataViewQuery}>
|
||||
{dataViewQuery.data && <FindingsContainer dataView={dataViewQuery.data} />}
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,19 +4,19 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { EuiTextColor, EuiEmptyPrompt } from '@elastic/eui';
|
||||
import * as t from 'io-ts';
|
||||
import { CspPageTemplate } from '../../components/page_template';
|
||||
import { RulesContainer, type PageUrlParams } from './rules_container';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import type { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { CspLoadingState } from '../../components/csp_loading_state';
|
||||
import { CspNavigationItem } from '../../common/navigation/types';
|
||||
import { extractErrorMessage } from '../../../common/utils/helpers';
|
||||
import { useCspIntegration } from './use_csp_integration';
|
||||
import { CspPageTemplate } from '../../components/csp_page_template';
|
||||
|
||||
const getRulesBreadcrumbs = (name?: string): CspNavigationItem[] =>
|
||||
[allNavigationItems.benchmarks, { ...allNavigationItems.rules, name }].filter(
|
||||
|
@ -35,7 +35,6 @@ export const Rules = ({ match: { params } }: RouteComponentProps<PageUrlParams>)
|
|||
|
||||
const pageProps: KibanaPageTemplateProps = useMemo(
|
||||
() => ({
|
||||
template: integrationInfo.status !== 'success' ? 'centeredContent' : undefined,
|
||||
pageHeader: {
|
||||
bottomBorder: false, // TODO: border still shows.
|
||||
pageTitle: 'Rules',
|
||||
|
@ -46,16 +45,16 @@ export const Rules = ({ match: { params } }: RouteComponentProps<PageUrlParams>)
|
|||
),
|
||||
},
|
||||
}),
|
||||
[integrationInfo.data, integrationInfo.status]
|
||||
[integrationInfo.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<CspPageTemplate {...pageProps}>
|
||||
<CspPageTemplate
|
||||
{...pageProps}
|
||||
query={integrationInfo}
|
||||
errorRender={(error) => <RulesErrorPrompt error={extractErrorBodyMessage(error)} />}
|
||||
>
|
||||
{integrationInfo.status === 'success' && <RulesContainer />}
|
||||
{integrationInfo.status === 'error' && (
|
||||
<RulesErrorPrompt error={extractErrorBodyMessage(integrationInfo.error)} />
|
||||
)}
|
||||
{integrationInfo.status === 'loading' && <CspLoadingState />}
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,10 +15,12 @@ import { type RouteComponentProps } from 'react-router-dom';
|
|||
import { cspLoadingStateTestId } from '../../components/csp_loading_state';
|
||||
import type { PageUrlParams } from './rules_container';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import { useCisKubernetesIntegration } from '../../common/api/use_cis_kubernetes_integration';
|
||||
|
||||
jest.mock('./use_csp_integration', () => ({
|
||||
useCspIntegration: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../common/api/use_cis_kubernetes_integration');
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
@ -43,6 +45,9 @@ describe('<Rules />', () => {
|
|||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
jest.clearAllMocks();
|
||||
(useCisKubernetesIntegration as jest.Mock).mockImplementation(() => ({
|
||||
data: { item: { status: 'installed' } },
|
||||
}));
|
||||
});
|
||||
|
||||
it('calls API with URL params', async () => {
|
||||
|
@ -63,6 +68,7 @@ describe('<Rules />', () => {
|
|||
const Component = getTestComponent({ packagePolicyId: '1', policyId: '2' });
|
||||
const request = {
|
||||
status: 'error',
|
||||
isError: true,
|
||||
data: null,
|
||||
error: new Error('some error message'),
|
||||
};
|
||||
|
@ -78,6 +84,7 @@ describe('<Rules />', () => {
|
|||
const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' });
|
||||
const request = {
|
||||
status: 'loading',
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
(useCspIntegration as jest.Mock).mockReturnValue(request);
|
||||
|
|
|
@ -22,11 +22,15 @@ export const createReactQueryResponse = <TData = unknown, TError = unknown>({
|
|||
data = undefined,
|
||||
}: CreateReactQueryResponseInput<TData, TError> = {}): Partial<UseQueryResult<TData, TError>> => {
|
||||
if (status === 'success') {
|
||||
return { status, data };
|
||||
return { status, data, isSuccess: true, isLoading: false, isError: false };
|
||||
}
|
||||
|
||||
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 };
|
||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
|||
QueryDslQueryContainer,
|
||||
SearchRequest,
|
||||
} 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 { CspAppContext } from '../../plugin';
|
||||
import { getResourcesTypes } from './get_resources_types';
|
||||
|
@ -102,7 +102,7 @@ export const defineGetComplianceDashboardRoute = (
|
|||
getClusters(esClient, query),
|
||||
]);
|
||||
|
||||
const body: CloudPostureStats = {
|
||||
const body: ComplianceDashboardData = {
|
||||
stats,
|
||||
resourcesTypes,
|
||||
clusters,
|
||||
|
|
|
@ -51,7 +51,7 @@ const mockClusterBuckets: ClusterBucket[] = [
|
|||
];
|
||||
|
||||
describe('getClustersFromAggs', () => {
|
||||
it('should return value matching CloudPostureStats["clusters"]', async () => {
|
||||
it('should return value matching ComplianceDashboardData["clusters"]', async () => {
|
||||
const clusters = getClustersFromAggs(mockClusterBuckets);
|
||||
expect(clusters).toEqual([
|
||||
{
|
||||
|
|
|
@ -11,7 +11,7 @@ import type {
|
|||
QueryDslQueryContainer,
|
||||
SearchRequest,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import { CloudPostureStats } from '../../../common/types';
|
||||
import { ComplianceDashboardData } from '../../../common/types';
|
||||
import { getResourceTypeFromAggs, resourceTypeAggQuery } from './get_resources_types';
|
||||
import type { ResourceTypeQueryResult } from './get_resources_types';
|
||||
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) => {
|
||||
// get cluster's meta data
|
||||
const benchmarks = cluster.benchmarks.buckets;
|
||||
|
@ -101,7 +103,7 @@ export const getClustersFromAggs = (clusters: ClusterBucket[]): CloudPostureStat
|
|||
export const getClusters = async (
|
||||
esClient: ElasticsearchClient,
|
||||
query: QueryDslQueryContainer
|
||||
): Promise<CloudPostureStats['clusters']> => {
|
||||
): Promise<ComplianceDashboardData['clusters']> => {
|
||||
const queryResult = await esClient.search<unknown, ClustersQueryResult>(getClustersQuery(query), {
|
||||
meta: true,
|
||||
});
|
||||
|
|
|
@ -31,7 +31,7 @@ const resourceTypeBuckets: ResourceTypeBucket[] = [
|
|||
];
|
||||
|
||||
describe('getResourceTypeFromAggs', () => {
|
||||
it('should return value matching CloudPostureStats["resourcesTypes"]', async () => {
|
||||
it('should return value matching ComplianceDashboardData["resourcesTypes"]', async () => {
|
||||
const resourceTypes = getResourceTypeFromAggs(resourceTypeBuckets);
|
||||
expect(resourceTypes).toEqual([
|
||||
{
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
*/
|
||||
|
||||
import { ElasticsearchClient } from 'kibana/server';
|
||||
import {
|
||||
import type {
|
||||
AggregationsMultiBucketAggregateBase as Aggregation,
|
||||
QueryDslQueryContainer,
|
||||
SearchRequest,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import { CloudPostureStats } from '../../../common/types';
|
||||
import type { ComplianceDashboardData } from '../../../common/types';
|
||||
import { KeyDocCount } from './compliance_dashboard';
|
||||
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
|
||||
|
||||
|
@ -53,7 +53,7 @@ export const getRisksEsQuery = (query: QueryDslQueryContainer): SearchRequest =>
|
|||
|
||||
export const getResourceTypeFromAggs = (
|
||||
queryResult: ResourceTypeBucket[]
|
||||
): CloudPostureStats['resourcesTypes'] =>
|
||||
): ComplianceDashboardData['resourcesTypes'] =>
|
||||
queryResult.map((bucket) => ({
|
||||
name: bucket.key,
|
||||
totalFindings: bucket.doc_count,
|
||||
|
@ -64,7 +64,7 @@ export const getResourceTypeFromAggs = (
|
|||
export const getResourcesTypes = async (
|
||||
esClient: ElasticsearchClient,
|
||||
query: QueryDslQueryContainer
|
||||
): Promise<CloudPostureStats['resourcesTypes']> => {
|
||||
): Promise<ComplianceDashboardData['resourcesTypes']> => {
|
||||
const resourceTypesQueryResult = await esClient.search<unknown, ResourceTypeQueryResult>(
|
||||
getRisksEsQuery(query),
|
||||
{ meta: true }
|
||||
|
|
|
@ -59,7 +59,7 @@ describe('getStatsFromFindingsEvaluationsAggs', () => {
|
|||
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);
|
||||
expect(stats).toEqual({
|
||||
totalFailed: 30,
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
*/
|
||||
|
||||
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 { CloudPostureStats, Score } from '../../../common/types';
|
||||
import type { ComplianceDashboardData, Score } from '../../../common/types';
|
||||
|
||||
/**
|
||||
* @param value value is [0, 1] range
|
||||
|
@ -44,7 +44,7 @@ export const getEvaluationsQuery = (query: QueryDslQueryContainer): SearchReques
|
|||
|
||||
export const getStatsFromFindingsEvaluationsAggs = (
|
||||
findingsEvaluationsAggs: FindingsEvaluationsQueryResult
|
||||
): CloudPostureStats['stats'] => {
|
||||
): ComplianceDashboardData['stats'] => {
|
||||
const failedFindings = findingsEvaluationsAggs.failed_findings.doc_count || 0;
|
||||
const passedFindings = findingsEvaluationsAggs.passed_findings.doc_count || 0;
|
||||
const totalFindings = failedFindings + passedFindings;
|
||||
|
@ -62,7 +62,7 @@ export const getStatsFromFindingsEvaluationsAggs = (
|
|||
export const getStats = async (
|
||||
esClient: ElasticsearchClient,
|
||||
query: QueryDslQueryContainer
|
||||
): Promise<CloudPostureStats['stats']> => {
|
||||
): Promise<ComplianceDashboardData['stats']> => {
|
||||
const evaluationsQueryResult = await esClient.search<unknown, FindingsEvaluationsQueryResult>(
|
||||
getEvaluationsQuery(query),
|
||||
{ meta: true }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue