mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Cloud Security] CSPM dashboard namespaces selection (#224621)
This commit is contained in:
parent
3ec2c7e46e
commit
bd412c0f83
15 changed files with 501 additions and 54 deletions
|
@ -16,3 +16,7 @@ export const getComplianceDashboardSchema = schema.object({
|
|||
schema.literal(KSPM_POLICY_TEMPLATE),
|
||||
]),
|
||||
});
|
||||
|
||||
export const getComplianceDashboardQuerySchema = schema.object({
|
||||
namespace: schema.maybe(schema.string()),
|
||||
});
|
||||
|
|
|
@ -102,6 +102,7 @@ export interface ComplianceDashboardDataV2 {
|
|||
groupedFindingsEvaluation: GroupedFindingsEvaluation[];
|
||||
trend: PostureTrend[];
|
||||
benchmarks: BenchmarkData[];
|
||||
namespaces: string[];
|
||||
}
|
||||
|
||||
export type RuleSection = CspBenchmarkRuleMetadata['section'];
|
||||
|
|
|
@ -10,35 +10,49 @@ import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '@kbn/cloud-security-
|
|||
import { useKibana } from '../hooks/use_kibana';
|
||||
import { ComplianceDashboardDataV2, PosturePolicyTemplate } from '../../../common/types_old';
|
||||
import { STATS_ROUTE_PATH } from '../../../common/constants';
|
||||
import { DEFAULT_NAMESPACE } from '../constants';
|
||||
|
||||
// TODO: consolidate both hooks into one hook with a dynamic key
|
||||
export const CSPM_STATS_QUERY_KEY = ['csp_cspm_dashboard_stats'];
|
||||
export const KSPM_STATS_QUERY_KEY = ['csp_kspm_dashboard_stats'];
|
||||
export const CSPM_STATS_QUERY_KEY = 'csp_cspm_dashboard_stats';
|
||||
export const KSPM_STATS_QUERY_KEY = 'csp_kspm_dashboard_stats';
|
||||
|
||||
export const getStatsRoute = (policyTemplate: PosturePolicyTemplate) => {
|
||||
return STATS_ROUTE_PATH.replace('{policy_template}', policyTemplate);
|
||||
};
|
||||
|
||||
export const useCspmStatsApi = (
|
||||
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>
|
||||
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>,
|
||||
namespace: string = DEFAULT_NAMESPACE
|
||||
) => {
|
||||
const { http } = useKibana().services;
|
||||
return useQuery(
|
||||
CSPM_STATS_QUERY_KEY,
|
||||
() =>
|
||||
http.get<ComplianceDashboardDataV2>(getStatsRoute(CSPM_POLICY_TEMPLATE), { version: '2' }),
|
||||
[CSPM_STATS_QUERY_KEY, namespace],
|
||||
() => {
|
||||
return http.get<ComplianceDashboardDataV2>(getStatsRoute(CSPM_POLICY_TEMPLATE), {
|
||||
version: '2',
|
||||
query: {
|
||||
namespace,
|
||||
},
|
||||
});
|
||||
},
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const useKspmStatsApi = (
|
||||
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>
|
||||
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>,
|
||||
namespace: string = DEFAULT_NAMESPACE
|
||||
) => {
|
||||
const { http } = useKibana().services;
|
||||
return useQuery(
|
||||
KSPM_STATS_QUERY_KEY,
|
||||
[KSPM_STATS_QUERY_KEY, namespace],
|
||||
() =>
|
||||
http.get<ComplianceDashboardDataV2>(getStatsRoute(KSPM_POLICY_TEMPLATE), { version: '2' }),
|
||||
http.get<ComplianceDashboardDataV2>(getStatsRoute(KSPM_POLICY_TEMPLATE), {
|
||||
version: '2',
|
||||
query: {
|
||||
namespace,
|
||||
},
|
||||
}),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
|
|
@ -39,8 +39,12 @@ export const LOCAL_STORAGE_3P_INTEGRATIONS_CALLOUT_KEY =
|
|||
export const LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY = 'cspLatestVulnerabilitiesGrouping';
|
||||
export const LOCAL_STORAGE_FINDINGS_GROUPING_KEY = 'cspLatestFindingsGrouping';
|
||||
|
||||
export const LOCAL_STORAGE_NAMESPACE_KEY = 'cloudPosture:dashboard:namespace';
|
||||
|
||||
export const SESSION_STORAGE_FIELDS_MODAL_SHOW_SELECTED = 'cloudPosture:fieldsModal:showSelected';
|
||||
|
||||
export const DEFAULT_NAMESPACE = 'default';
|
||||
|
||||
export type CloudPostureIntegrations = Record<
|
||||
CloudSecurityPolicyTemplate,
|
||||
CloudPostureIntegrationProps
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { useState, useCallback } from 'react';
|
||||
import useLocalStorage from 'react-use/lib/useLocalStorage';
|
||||
import { LOCAL_STORAGE_NAMESPACE_KEY, DEFAULT_NAMESPACE } from '../constants';
|
||||
|
||||
export const useActiveNamespace = ({ postureType }: { postureType?: 'cspm' | 'kspm' }) => {
|
||||
const [localStorageActiveNamespace, localStorageSetActiveNamespace] = useLocalStorage(
|
||||
`${LOCAL_STORAGE_NAMESPACE_KEY}:${postureType}`,
|
||||
DEFAULT_NAMESPACE
|
||||
);
|
||||
const [activeNamespace, setActiveNamespaceState] = useState<string>(
|
||||
localStorageActiveNamespace || DEFAULT_NAMESPACE
|
||||
);
|
||||
|
||||
const updateActiveNamespace = useCallback(
|
||||
(namespace: string) => {
|
||||
setActiveNamespaceState(namespace);
|
||||
localStorageSetActiveNamespace(namespace);
|
||||
},
|
||||
[localStorageSetActiveNamespace]
|
||||
);
|
||||
return { activeNamespace, updateActiveNamespace };
|
||||
};
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 { render, waitFor } from '@testing-library/react';
|
||||
import { NamespaceSelector } from './namespace_selector';
|
||||
|
||||
describe('NamespaceSelector', () => {
|
||||
const mockProps = {
|
||||
activeNamespace: 'namespace2',
|
||||
namespaces: ['default', 'namespace1', 'namespace2'],
|
||||
onNamespaceChange: jest.fn(),
|
||||
};
|
||||
it('renders correctly', () => {
|
||||
const { getByTestId } = render(<NamespaceSelector {...mockProps} />);
|
||||
expect(getByTestId('namespace-selector')).toBeInTheDocument();
|
||||
expect(getByTestId('namespace-selector-dropdown-button')).toHaveTextContent('namespace2');
|
||||
});
|
||||
|
||||
it('opens the popover on button click', async () => {
|
||||
const { getByTestId } = render(<NamespaceSelector {...mockProps} />);
|
||||
const button = getByTestId('namespace-selector-dropdown-button');
|
||||
button.click();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('namespace-selector-menu')).toBeVisible();
|
||||
expect(getByTestId('namespace-selector-menu')).toHaveTextContent('default');
|
||||
expect(getByTestId('namespace-selector-menu')).toHaveTextContent('namespace1');
|
||||
expect(getByTestId('namespace-selector-menu')).toHaveTextContent('namespace2');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onNamespaceChange when a namespace is selected', async () => {
|
||||
const { getByTestId } = render(<NamespaceSelector {...mockProps} />);
|
||||
const button = getByTestId('namespace-selector-dropdown-button');
|
||||
button.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('namespace-selector-menu')).toBeVisible();
|
||||
});
|
||||
|
||||
const namespace1 = getByTestId('namespace-selector-menu-item-namespace1');
|
||||
namespace1.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockProps.onNamespaceChange).toHaveBeenCalledWith('namespace1');
|
||||
});
|
||||
});
|
||||
|
||||
it('is disabled when only one namespace is available', () => {
|
||||
const propsWithSingleNamespace = {
|
||||
...mockProps,
|
||||
activeNamespace: 'default-disabled',
|
||||
namespaces: ['default-disabled'],
|
||||
};
|
||||
const { getByTestId } = render(<NamespaceSelector {...propsWithSingleNamespace} />);
|
||||
const button = getByTestId('namespace-selector-dropdown-button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent('Namespace: default-disabled');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanel,
|
||||
EuiHorizontalRule,
|
||||
} from '@elastic/eui';
|
||||
import { EuiPopover } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
interface NamespaceSelectorProps {
|
||||
activeNamespace: string;
|
||||
namespaces: string[];
|
||||
onNamespaceChange: (namespace: string) => void;
|
||||
}
|
||||
|
||||
export const NamespaceSelector = ({
|
||||
activeNamespace,
|
||||
namespaces,
|
||||
onNamespaceChange,
|
||||
}: NamespaceSelectorProps) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const onSelectedNamespaceChange = useCallback(
|
||||
(namespaceKey: string) => {
|
||||
if (namespaceKey !== activeNamespace) {
|
||||
onNamespaceChange(namespaceKey);
|
||||
}
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[activeNamespace, onNamespaceChange]
|
||||
);
|
||||
|
||||
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
|
||||
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
|
||||
const isSelectedProps = useCallback(
|
||||
(namespace: string) => {
|
||||
return namespace === activeNamespace
|
||||
? { icon: 'check', 'aria-current': true }
|
||||
: { icon: 'empty', 'aria-current': undefined };
|
||||
},
|
||||
[activeNamespace]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
return namespaces.map((namespace, index) => (
|
||||
<React.Fragment key={`namespace-selector-${namespace}`}>
|
||||
<EuiContextMenuItem
|
||||
{...isSelectedProps(namespace)}
|
||||
key={namespace}
|
||||
data-test-subj={`namespace-selector-menu-item-${namespace}`}
|
||||
onClick={() => {
|
||||
onSelectedNamespaceChange(namespace);
|
||||
}}
|
||||
>
|
||||
{namespace}
|
||||
</EuiContextMenuItem>
|
||||
{index < namespaces.length - 1 && (
|
||||
<EuiHorizontalRule margin="none" key={`rule-${namespace}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
));
|
||||
}, [namespaces, isSelectedProps, onSelectedNamespaceChange]);
|
||||
|
||||
const button = useMemo(() => {
|
||||
const title = i18n.translate('xpack.csp.namespaceSelector.title', {
|
||||
defaultMessage: 'Namespace',
|
||||
});
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="namespace-selector-dropdown-button"
|
||||
flush="both"
|
||||
iconSide="right"
|
||||
iconSize="s"
|
||||
iconType="arrowDown"
|
||||
onClick={onButtonClick}
|
||||
title={activeNamespace}
|
||||
size="xs"
|
||||
disabled={namespaces.length < 2}
|
||||
>
|
||||
{`${title}: ${activeNamespace}`}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
}, [onButtonClick, activeNamespace, namespaces.length]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
data-test-subj="namespace-selector"
|
||||
button={button}
|
||||
closePopover={closePopover}
|
||||
isOpen={isPopoverOpen}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenuPanel data-test-subj="namespace-selector-menu" size="s" items={menuItems} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -36,6 +36,7 @@ import { ComplianceDashboardDataV2 } from '../../../common/types_old';
|
|||
import { cloudPosturePages } from '../../common/navigation/constants';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExperimentalFeaturesService } from '../../common/experimental_features_service';
|
||||
|
||||
jest.mock('@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api');
|
||||
jest.mock('../../common/api/use_stats_api');
|
||||
|
@ -44,6 +45,12 @@ jest.mock('../../common/hooks/use_is_subscription_status_valid');
|
|||
jest.mock('../../common/navigation/use_navigate_to_cis_integration_policies');
|
||||
jest.mock('../../common/navigation/use_csp_integration_link');
|
||||
|
||||
// jest.mock('../../common/experimental_features_service', () => ({
|
||||
// ExperimentalFeaturesService: {
|
||||
// get: jest.fn(() => ({ cloudSecurityNamespaceSupportEnabled: true })),
|
||||
// },
|
||||
// }));
|
||||
|
||||
describe('<ComplianceDashboard />', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
@ -72,6 +79,18 @@ describe('<ComplianceDashboard />', () => {
|
|||
status: 'success',
|
||||
})
|
||||
);
|
||||
jest.mock('../../common/experimental_features_service', () => ({
|
||||
ExperimentalFeaturesService: {
|
||||
get: jest.fn(() => ({ cloudSecurityNamespaceSupportEnabled: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.spyOn(ExperimentalFeaturesService, 'get').mockImplementation(() => {
|
||||
return {
|
||||
cloudSecurityNamespaceSupportEnabled: true,
|
||||
cloudConnectorsEnabled: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const ComplianceDashboardWithTestProviders = (route: string) => {
|
||||
|
@ -838,3 +857,94 @@ describe('getDefaultTab', () => {
|
|||
expect(getDefaultTab(pluginStatus, undefined, undefined)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// describe('Compliance Dashboard CSPM Namespace Selector', () => {
|
||||
// (useCspSetupStatusApi as jest.Mock).mockImplementation(() =>
|
||||
// createReactQueryResponse({
|
||||
// status: 'success',
|
||||
// data: {
|
||||
// cspm: { status: 'indexed' },
|
||||
// kspm: { status: 'indexed' },
|
||||
// installedPackageVersion: '1.2.13',
|
||||
// indicesDetails: [
|
||||
// {
|
||||
// index: 'security_solution-cloud_security_posture.misconfiguration_latest',
|
||||
// status: 'not-empty',
|
||||
// },
|
||||
// { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' },
|
||||
// ],
|
||||
// },
|
||||
// })
|
||||
// );
|
||||
// (useKspmStatsApi as jest.Mock).mockImplementation(() => ({
|
||||
// isSuccess: true,
|
||||
// isLoading: false,
|
||||
// data: mockDashboardData,
|
||||
// }));
|
||||
// (useCspmStatsApi as jest.Mock).mockImplementation(() => ({
|
||||
// isSuccess: true,
|
||||
// isLoading: false,
|
||||
// data: mockDashboardData,
|
||||
// }));
|
||||
|
||||
// const renderComplianceDashboardPage = (route = cloudPosturePages.dashboard.path) => {
|
||||
// return render(
|
||||
// <TestProvider>
|
||||
// <MemoryRouter initialEntries={[route]}>
|
||||
// <ComplianceDashboard />
|
||||
// </MemoryRouter>
|
||||
// </TestProvider>
|
||||
// );
|
||||
// };
|
||||
|
||||
// it('should render namespace selector', () => {
|
||||
// renderComplianceDashboardPage();
|
||||
|
||||
// expect(screen.getByTestId('namespace-selector')).toBeInTheDocument();
|
||||
// });
|
||||
|
||||
// it('should render namespace selector with default value', () => {
|
||||
// renderComplianceDashboardPage();
|
||||
|
||||
// expect(screen.getByTestId('namespace-selector')).toHaveTextContent('default');
|
||||
// });
|
||||
|
||||
// it('should change namespace when a different namespace is selected', async () => {
|
||||
// renderComplianceDashboardPage();
|
||||
|
||||
// const user = userEvent.setup();
|
||||
// const namespaceSelector = screen.getByTestId('namespace-selector-dropdown-button');
|
||||
|
||||
// await user.click(namespaceSelector);
|
||||
// await waitFor(() => {
|
||||
// expect(screen.getByTestId('namespace-selector-menu')).toBeVisible();
|
||||
// user.click(screen.getByTestId('namespace-selector-menu-item-namespace1'));
|
||||
// });
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(namespaceSelector).toHaveTextContent('namespace1');
|
||||
// });
|
||||
// });
|
||||
|
||||
// it('should reset the namespace when the active namespace does not exist', async () => {
|
||||
// Object.defineProperty(window, 'localStorage', {
|
||||
// value: {
|
||||
// getItem: jest.fn((key) => {
|
||||
// if (key === `${LOCAL_STORAGE_NAMESPACE_KEY}-cspm`) return 'non-existent-namespace';
|
||||
// return null;
|
||||
// }),
|
||||
// setItem: jest.fn(),
|
||||
// removeItem: jest.fn(),
|
||||
// clear: jest.fn(),
|
||||
// },
|
||||
// writable: true,
|
||||
// });
|
||||
// renderComplianceDashboardPage();
|
||||
|
||||
// const namespaceSelector = screen.getByTestId('namespace-selector-dropdown-button');
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(namespaceSelector).toHaveTextContent('default');
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { UseQueryResult } from '@tanstack/react-query';
|
||||
import { EuiEmptyPrompt, EuiIcon, EuiLink, EuiPageHeader, EuiSpacer } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
@ -17,6 +17,7 @@ import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '@kbn/cloud-security-
|
|||
import type { BaseCspSetupStatus } from '@kbn/cloud-security-posture-common';
|
||||
import { useCspSetupStatusApi } from '@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api';
|
||||
import { encodeQuery } from '@kbn/cloud-security-posture';
|
||||
|
||||
import { NO_FINDINGS_STATUS_TEST_SUBJ } from '../../components/test_subjects';
|
||||
import { useCspIntegrationLink } from '../../common/navigation/use_csp_integration_link';
|
||||
import type { PosturePolicyTemplate, ComplianceDashboardDataV2 } from '../../../common/types_old';
|
||||
|
@ -43,6 +44,9 @@ import { BenchmarksSection } from './dashboard_sections/benchmarks_section';
|
|||
import { cloudPosturePages, cspIntegrationDocsNavigation } from '../../common/navigation/constants';
|
||||
import { NO_FINDINGS_STATUS_REFRESH_INTERVAL_MS } from '../../common/constants';
|
||||
import { useKibana } from '../../common/hooks/use_kibana';
|
||||
import { NamespaceSelector } from '../../components/namespace_selector';
|
||||
import { useActiveNamespace } from '../../common/hooks/use_active_namespace';
|
||||
import { ExperimentalFeaturesService } from '../../common/experimental_features_service';
|
||||
|
||||
const POSTURE_TYPE_CSPM = CSPM_POLICY_TEMPLATE;
|
||||
const POSTURE_TYPE_KSPM = KSPM_POLICY_TEMPLATE;
|
||||
|
@ -237,8 +241,10 @@ const determineDashboardDataRefetchInterval = (data: ComplianceDashboardDataV2 |
|
|||
|
||||
const TabContent = ({
|
||||
selectedPostureTypeTab,
|
||||
activeNamespace,
|
||||
}: {
|
||||
selectedPostureTypeTab: PosturePolicyTemplate;
|
||||
activeNamespace: string;
|
||||
}) => {
|
||||
const { data: getSetupStatus } = useCspSetupStatusApi({
|
||||
refetchInterval: (data) => {
|
||||
|
@ -250,14 +256,21 @@ const TabContent = ({
|
|||
},
|
||||
});
|
||||
const isCloudSecurityPostureInstalled = !!getSetupStatus?.installedPackageVersion;
|
||||
const getCspmDashboardData = useCspmStatsApi({
|
||||
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_CSPM,
|
||||
refetchInterval: determineDashboardDataRefetchInterval,
|
||||
});
|
||||
const getKspmDashboardData = useKspmStatsApi({
|
||||
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_KSPM,
|
||||
refetchInterval: determineDashboardDataRefetchInterval,
|
||||
});
|
||||
const getCspmDashboardData = useCspmStatsApi(
|
||||
{
|
||||
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_CSPM,
|
||||
refetchInterval: determineDashboardDataRefetchInterval,
|
||||
},
|
||||
activeNamespace
|
||||
);
|
||||
|
||||
const getKspmDashboardData = useKspmStatsApi(
|
||||
{
|
||||
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_KSPM,
|
||||
refetchInterval: determineDashboardDataRefetchInterval,
|
||||
},
|
||||
activeNamespace
|
||||
);
|
||||
const setupStatus = getSetupStatus?.[selectedPostureTypeTab]?.status;
|
||||
const isStatusManagedInDashboard = setupStatus === 'indexed' || setupStatus === 'not-installed';
|
||||
const shouldRenderNoFindings = !isCloudSecurityPostureInstalled || !isStatusManagedInDashboard;
|
||||
|
@ -315,14 +328,11 @@ const TabContent = ({
|
|||
};
|
||||
|
||||
export const ComplianceDashboard = () => {
|
||||
const cloudSecurityNamespaceSupportEnabled = useMemo(() => {
|
||||
return ExperimentalFeaturesService.get().cloudSecurityNamespaceSupportEnabled;
|
||||
}, []);
|
||||
const { data: getSetupStatus } = useCspSetupStatusApi();
|
||||
const isCloudSecurityPostureInstalled = !!getSetupStatus?.installedPackageVersion;
|
||||
const getCspmDashboardData = useCspmStatsApi({
|
||||
enabled: isCloudSecurityPostureInstalled,
|
||||
});
|
||||
const getKspmDashboardData = useKspmStatsApi({
|
||||
enabled: isCloudSecurityPostureInstalled,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
@ -342,6 +352,45 @@ export const ComplianceDashboard = () => {
|
|||
return tab;
|
||||
}, [location.pathname]);
|
||||
|
||||
const { activeNamespace, updateActiveNamespace } = useActiveNamespace({
|
||||
postureType: currentTabUrlState,
|
||||
});
|
||||
|
||||
const getCspmDashboardData = useCspmStatsApi(
|
||||
{
|
||||
enabled: isCloudSecurityPostureInstalled,
|
||||
},
|
||||
activeNamespace
|
||||
);
|
||||
const getKspmDashboardData = useKspmStatsApi(
|
||||
{
|
||||
enabled: isCloudSecurityPostureInstalled,
|
||||
},
|
||||
activeNamespace
|
||||
);
|
||||
|
||||
const onActiveNamespaceChange = useCallback(
|
||||
(selectedNamespace: string) => {
|
||||
updateActiveNamespace(selectedNamespace);
|
||||
},
|
||||
[updateActiveNamespace]
|
||||
);
|
||||
|
||||
const namespaces = useMemo(() => {
|
||||
const postureNamespaces =
|
||||
currentTabUrlState === POSTURE_TYPE_CSPM
|
||||
? getCspmDashboardData.data?.namespaces || []
|
||||
: getKspmDashboardData.data?.namespaces || [];
|
||||
|
||||
return postureNamespaces.sort((a: string, b: string) => a.localeCompare(b));
|
||||
}, [currentTabUrlState, getCspmDashboardData.data, getKspmDashboardData.data]);
|
||||
|
||||
// if the active namespace is not in the list of namespaces, default to the first available namespace
|
||||
// this can happen when changing between CSPM and KSPM dashboards and if there is no namespace called "default"
|
||||
if (activeNamespace && namespaces.length > 0 && !namespaces.includes(activeNamespace)) {
|
||||
onActiveNamespaceChange(namespaces[0]);
|
||||
}
|
||||
|
||||
const preferredTabUrlState = useMemo(
|
||||
() => getDefaultTab(getSetupStatus, getCspmDashboardData.data, getKspmDashboardData.data),
|
||||
[getCspmDashboardData.data, getKspmDashboardData.data, getSetupStatus]
|
||||
|
@ -371,7 +420,12 @@ export const ComplianceDashboard = () => {
|
|||
onClick: () => {
|
||||
navigateToPostureTypeDashboardTab(cloudPosturePages.cspm_dashboard.path);
|
||||
},
|
||||
content: <TabContent selectedPostureTypeTab={selectedTab || POSTURE_TYPE_CSPM} />,
|
||||
content: (
|
||||
<TabContent
|
||||
selectedPostureTypeTab={selectedTab || POSTURE_TYPE_CSPM}
|
||||
activeNamespace={activeNamespace}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: i18n.translate('xpack.csp.dashboardTabs.kubernetesTab.tabTitle', {
|
||||
|
@ -382,18 +436,48 @@ export const ComplianceDashboard = () => {
|
|||
onClick: () => {
|
||||
navigateToPostureTypeDashboardTab(cloudPosturePages.kspm_dashboard.path);
|
||||
},
|
||||
content: <TabContent selectedPostureTypeTab={selectedTab || POSTURE_TYPE_KSPM} />,
|
||||
content: (
|
||||
<TabContent
|
||||
selectedPostureTypeTab={selectedTab || POSTURE_TYPE_KSPM}
|
||||
activeNamespace={activeNamespace}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [
|
||||
isCloudSecurityPostureInstalled,
|
||||
preferredTabUrlState,
|
||||
currentTabUrlState,
|
||||
preferredTabUrlState,
|
||||
isCloudSecurityPostureInstalled,
|
||||
activeNamespace,
|
||||
history,
|
||||
services,
|
||||
services.data.query.queryString,
|
||||
services.data.query.filterManager,
|
||||
]);
|
||||
|
||||
// if there is more than one namespace, show the namespace selector in the header
|
||||
const rightSideItems = useMemo(
|
||||
() =>
|
||||
namespaces.length > 0 && cloudSecurityNamespaceSupportEnabled
|
||||
? [
|
||||
<NamespaceSelector
|
||||
data-test-subj="namespace-selector"
|
||||
key={`namespace-selector-${currentTabUrlState}`}
|
||||
namespaces={namespaces}
|
||||
activeNamespace={activeNamespace}
|
||||
onNamespaceChange={onActiveNamespaceChange}
|
||||
/>,
|
||||
]
|
||||
: [],
|
||||
[
|
||||
namespaces,
|
||||
cloudSecurityNamespaceSupportEnabled,
|
||||
currentTabUrlState,
|
||||
activeNamespace,
|
||||
onActiveNamespaceChange,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<CloudPosturePage>
|
||||
<EuiPageHeader
|
||||
|
@ -406,8 +490,10 @@ export const ComplianceDashboard = () => {
|
|||
})}
|
||||
/>
|
||||
}
|
||||
rightSideItems={rightSideItems}
|
||||
tabs={tabs.map(({ content, ...rest }) => rest)}
|
||||
/>
|
||||
|
||||
<EuiSpacer />
|
||||
<div
|
||||
data-test-subj={DASHBOARD_CONTAINER}
|
||||
|
|
|
@ -94,6 +94,7 @@ export const getBenchmarkMockData = (): BenchmarkData => ({
|
|||
});
|
||||
|
||||
export const mockDashboardData: ComplianceDashboardDataV2 = {
|
||||
namespaces: ['default', 'namespace1', 'namespace2'],
|
||||
stats: {
|
||||
totalFailed: 17,
|
||||
totalPassed: 155,
|
||||
|
|
|
@ -190,8 +190,8 @@ describe('use_change_csp_rule_state', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(BENCHMARK_INTEGRATION_QUERY_KEY_V2);
|
||||
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(CSPM_STATS_QUERY_KEY);
|
||||
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(KSPM_STATS_QUERY_KEY);
|
||||
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith([CSPM_STATS_QUERY_KEY]);
|
||||
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith([KSPM_STATS_QUERY_KEY]);
|
||||
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(CSP_RULES_STATES_QUERY_KEY);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -160,8 +160,8 @@ export const useChangeCspRuleState = () => {
|
|||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries(BENCHMARK_INTEGRATION_QUERY_KEY_V2);
|
||||
queryClient.invalidateQueries(CSPM_STATS_QUERY_KEY);
|
||||
queryClient.invalidateQueries(KSPM_STATS_QUERY_KEY);
|
||||
queryClient.invalidateQueries([CSPM_STATS_QUERY_KEY]);
|
||||
queryClient.invalidateQueries([KSPM_STATS_QUERY_KEY]);
|
||||
queryClient.invalidateQueries(CSP_RULES_STATES_QUERY_KEY);
|
||||
showChangeBenchmarkRuleStatesSuccessToast(startServices, {
|
||||
newState: variables?.newState,
|
||||
|
|
|
@ -9,7 +9,10 @@ import { transformError } from '@kbn/securitysolution-es-utils';
|
|||
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { CDR_LATEST_NATIVE_MISCONFIGURATIONS_INDEX_ALIAS } from '@kbn/cloud-security-posture-common';
|
||||
import { getComplianceDashboardSchema } from '../../../common/schemas/stats';
|
||||
import {
|
||||
getComplianceDashboardSchema,
|
||||
getComplianceDashboardQuerySchema,
|
||||
} from '../../../common/schemas/stats';
|
||||
import { getSafePostureTypeRuntimeMapping } from '../../../common/runtime_mappings/get_safe_posture_type_runtime_mapping';
|
||||
import type {
|
||||
PosturePolicyTemplate,
|
||||
|
@ -22,7 +25,7 @@ import { getGroupedFindingsEvaluation } from './get_grouped_findings_evaluation'
|
|||
import { ClusterWithoutTrend, getClusters } from './get_clusters';
|
||||
import { getStats } from './get_stats';
|
||||
import { CspRouter } from '../../types';
|
||||
import { getTrends, Trends } from './get_trends';
|
||||
import { getTrends, TrendsDetails } from './get_trends';
|
||||
import { BenchmarkWithoutTrend, getBenchmarks } from './get_benchmarks';
|
||||
import { toBenchmarkDocFieldKey } from '../../lib/mapping_field_util';
|
||||
import { getMutedRulesFilterQuery } from '../benchmark_rules/get_states/v1';
|
||||
|
@ -32,7 +35,7 @@ export interface KeyDocCount<TKey = string> {
|
|||
doc_count: number;
|
||||
}
|
||||
|
||||
const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends: Trends) =>
|
||||
const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends: TrendsDetails) =>
|
||||
clustersWithoutTrends.map((cluster) => ({
|
||||
...cluster,
|
||||
trend: trends.map(({ timestamp, clusters: clustersTrendData }) => ({
|
||||
|
@ -41,7 +44,10 @@ const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends:
|
|||
})),
|
||||
}));
|
||||
|
||||
const getBenchmarksTrends = (benchmarksWithoutTrends: BenchmarkWithoutTrend[], trends: Trends) => {
|
||||
const getBenchmarksTrends = (
|
||||
benchmarksWithoutTrends: BenchmarkWithoutTrend[],
|
||||
trends: TrendsDetails
|
||||
) => {
|
||||
return benchmarksWithoutTrends.map((benchmark) => ({
|
||||
...benchmark,
|
||||
trend: trends.map(({ timestamp, benchmarks: benchmarksTrendData }) => {
|
||||
|
@ -58,7 +64,7 @@ const getBenchmarksTrends = (benchmarksWithoutTrends: BenchmarkWithoutTrend[], t
|
|||
}));
|
||||
};
|
||||
|
||||
const getSummaryTrend = (trends: Trends) =>
|
||||
const getSummaryTrend = (trends: TrendsDetails) =>
|
||||
trends.map(({ timestamp, summary }) => ({ timestamp, ...summary }));
|
||||
|
||||
export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
|
||||
|
@ -95,7 +101,6 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
|
|||
|
||||
const params: GetComplianceDashboardRequest = request.params;
|
||||
const policyTemplate = params.policy_template as PosturePolicyTemplate;
|
||||
|
||||
// runtime mappings create the `safe_posture_type` field, which equals to `kspm` or `cspm` based on the value and existence of the `posture_type` field which was introduced at 8.7
|
||||
// the `query` is then being passed to our getter functions to filter per posture type even for older findings before 8.7
|
||||
const runtimeMappings: MappingRuntimeFields = getSafePostureTypeRuntimeMapping();
|
||||
|
@ -119,8 +124,8 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
|
|||
logger.warn(`Could not close PIT for stats endpoint: ${err}`);
|
||||
});
|
||||
|
||||
const clusters = getClustersTrends(clustersWithoutTrends, trends);
|
||||
const trend = getSummaryTrend(trends);
|
||||
const clusters = getClustersTrends(clustersWithoutTrends, trends.trends);
|
||||
const trend = getSummaryTrend(trends.trends);
|
||||
|
||||
const body: ComplianceDashboardData = {
|
||||
stats,
|
||||
|
@ -150,6 +155,7 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
|
|||
validate: {
|
||||
request: {
|
||||
params: getComplianceDashboardSchema,
|
||||
query: getComplianceDashboardQuerySchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -169,23 +175,31 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
|
|||
|
||||
const params: GetComplianceDashboardRequest = request.params;
|
||||
const policyTemplate = params.policy_template as PosturePolicyTemplate;
|
||||
const namespace = request.query.namespace;
|
||||
|
||||
// runtime mappings create the `safe_posture_type` field, which equals to `kspm` or `cspm` based on the value and existence of the `posture_type` field which was introduced at 8.7
|
||||
// the `query` is then being passed to our getter functions to filter per posture type even for older findings before 8.7
|
||||
const runtimeMappings: MappingRuntimeFields = getSafePostureTypeRuntimeMapping();
|
||||
const filter = namespace
|
||||
? [
|
||||
{ term: { safe_posture_type: policyTemplate } },
|
||||
{ term: { 'data_stream.namespace': namespace } },
|
||||
]
|
||||
: [{ term: { safe_posture_type: policyTemplate } }];
|
||||
|
||||
const query: QueryDslQueryContainer = {
|
||||
bool: {
|
||||
filter: [{ term: { safe_posture_type: policyTemplate } }],
|
||||
filter,
|
||||
must_not: filteredRules,
|
||||
},
|
||||
};
|
||||
|
||||
const [stats, groupedFindingsEvaluation, benchmarksWithoutTrends, trends] =
|
||||
const [stats, groupedFindingsEvaluation, benchmarksWithoutTrends, trendDetails] =
|
||||
await Promise.all([
|
||||
getStats(esClient, query, pitId, runtimeMappings, logger),
|
||||
getGroupedFindingsEvaluation(esClient, query, pitId, runtimeMappings, logger),
|
||||
getBenchmarks(esClient, query, pitId, runtimeMappings, logger),
|
||||
getTrends(esClient, policyTemplate, logger),
|
||||
getTrends(esClient, policyTemplate, logger, namespace),
|
||||
]);
|
||||
|
||||
// Try closing the PIT, if it fails we can safely ignore the error since it closes itself after the keep alive
|
||||
|
@ -194,14 +208,15 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
|
|||
logger.warn(`Could not close PIT for stats endpoint: ${err}`);
|
||||
});
|
||||
|
||||
const benchmarks = getBenchmarksTrends(benchmarksWithoutTrends, trends);
|
||||
const trend = getSummaryTrend(trends);
|
||||
const benchmarks = getBenchmarksTrends(benchmarksWithoutTrends, trendDetails.trends);
|
||||
const trend = getSummaryTrend(trendDetails.trends);
|
||||
|
||||
const body: ComplianceDashboardDataV2 = {
|
||||
stats,
|
||||
groupedFindingsEvaluation,
|
||||
benchmarks,
|
||||
trend,
|
||||
namespaces: trendDetails.namespaces,
|
||||
};
|
||||
|
||||
return response.ok({
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
|
||||
import { calculatePostureScore } from '../../../common/utils/helpers';
|
||||
import { BENCHMARK_SCORE_INDEX_DEFAULT_NS } from '../../../common/constants';
|
||||
import type { PosturePolicyTemplate, Stats } from '../../../common/types_old';
|
||||
|
@ -37,12 +38,17 @@ export interface ScoreTrendDoc {
|
|||
score_by_benchmark_id: ScoreByBenchmarkId;
|
||||
}
|
||||
|
||||
export type Trends = Array<{
|
||||
export type TrendsDetails = Array<{
|
||||
timestamp: string;
|
||||
summary: Stats;
|
||||
clusters: Record<string, Stats>;
|
||||
benchmarks: Record<string, Stats>;
|
||||
}>;
|
||||
|
||||
export interface Trends {
|
||||
trends: TrendsDetails;
|
||||
namespaces: string[];
|
||||
}
|
||||
export interface ScoreTrendAggregateResponse {
|
||||
by_namespace: {
|
||||
buckets: Array<{
|
||||
|
@ -114,7 +120,7 @@ export const getTrendsQuery = (policyTemplate: PosturePolicyTemplate): SearchReq
|
|||
},
|
||||
});
|
||||
|
||||
export const formatTrends = (scoreTrendDocs: ScoreTrendDoc[]): Trends => {
|
||||
export const formatTrends = (scoreTrendDocs: ScoreTrendDoc[]): TrendsDetails => {
|
||||
return scoreTrendDocs.map((data) => {
|
||||
return {
|
||||
timestamp: data['@timestamp'],
|
||||
|
@ -163,7 +169,8 @@ export const formatTrends = (scoreTrendDocs: ScoreTrendDoc[]): Trends => {
|
|||
export const getTrends = async (
|
||||
esClient: ElasticsearchClient,
|
||||
policyTemplate: PosturePolicyTemplate,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
namespace: string = DEFAULT_NAMESPACE_STRING
|
||||
): Promise<Trends> => {
|
||||
try {
|
||||
const trendsQueryResult = await esClient.search<unknown, ScoreTrendAggregateResponse>(
|
||||
|
@ -174,20 +181,28 @@ export const getTrends = async (
|
|||
|
||||
const scoreTrendDocs =
|
||||
trendsQueryResult.aggregations.by_namespace.buckets.map((bucket) => {
|
||||
const namespace = bucket.key;
|
||||
const namespaceKey = bucket.key;
|
||||
const documents = bucket.all_scores?.hits?.hits?.map((hit) => hit._source) || [];
|
||||
return { [namespace]: { documents } };
|
||||
return { [namespaceKey]: { documents } };
|
||||
}) ?? [];
|
||||
|
||||
if (!scoreTrendDocs.length) return []; // No trends data available
|
||||
const result = Object.fromEntries(
|
||||
if (!scoreTrendDocs.length) return { trends: [], namespaces: [] }; // No trends data available
|
||||
|
||||
const namespacedData = Object.fromEntries(
|
||||
scoreTrendDocs.map((entry) => {
|
||||
const [key, value] = Object.entries(entry)[0];
|
||||
return [key, value.documents];
|
||||
})
|
||||
);
|
||||
|
||||
return formatTrends(result.default); // Return the trends for the default namespace until namespace support will be visible to users.
|
||||
const namespaceKeys = Object.keys(namespacedData);
|
||||
|
||||
if (!namespacedData[namespace]) {
|
||||
logger.warn(`Namespace '${namespace}' not found in trend results.`);
|
||||
return { trends: [], namespaces: namespaceKeys };
|
||||
}
|
||||
|
||||
return { trends: formatTrends(namespacedData[namespace]), namespaces: namespaceKeys };
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch trendlines data ${err.message}`);
|
||||
logger.error(err);
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
"@kbn/management-settings-ids",
|
||||
"@kbn/expandable-flyout",
|
||||
"@kbn/code-editor-mock",
|
||||
"@kbn/core-saved-objects-utils-server",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue