[Security Solution] Document details flyout - update insight KPI count (#196617)

## Summary

This PR made some updates to the insights KPI following
https://github.com/elastic/kibana/pull/195509

- Updated all the counts to be total
alerts/misconfigurations/vulnerabilities
- Clicking on the count badge opens timeline (alerts) or entity preview
- Revert the order of the distribution bar for alerts to align with
others



https://github.com/user-attachments/assets/6d65503a-26b1-4db4-9118-a63ad66ac7b6

Latest design

![image](https://github.com/user-attachments/assets/6d01aaf7-d87d-4ba2-afae-0845e6d3efc7)




### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
christineweng 2024-10-17 17:57:52 -05:00 committed by GitHub
parent 8dd895fe57
commit 71951416ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 241 additions and 52 deletions

View file

@ -8,7 +8,7 @@
import type { FC, PropsWithChildren } from 'react';
import React, { useCallback } from 'react';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import type { IconType } from '@elastic/eui';
import type { IconType, EuiButtonEmptyProps } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { useDispatch, useSelector } from 'react-redux';
@ -34,6 +34,7 @@ export interface InvestigateInTimelineButtonProps {
isDisabled?: boolean;
iconType?: IconType;
children?: React.ReactNode;
flush?: EuiButtonEmptyProps['flush'];
}
export const InvestigateInTimelineButton: FC<
@ -46,6 +47,7 @@ export const InvestigateInTimelineButton: FC<
timeRange,
keepDataView,
iconType,
flush,
...rest
}) => {
const dispatch = useDispatch();
@ -118,7 +120,7 @@ export const InvestigateInTimelineButton: FC<
<EuiButtonEmpty
aria-label={ACTION_INVESTIGATE_IN_TIMELINE}
onClick={configureAndOpenTimeline}
flush="right"
flush={flush ?? 'right'}
size="xs"
iconType={iconType}
>

View file

@ -48,6 +48,7 @@ describe('AlertCountInsight', () => {
const { getByTestId } = renderAlertCountInsight();
expect(getByTestId(testId)).toBeInTheDocument();
expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument();
expect(getByTestId(`${testId}-count`)).toHaveTextContent('177');
});
it('renders loading spinner if data is being fetched', () => {

View file

@ -16,8 +16,12 @@ import {
getIsAlertsBySeverityData,
getSeverityColor,
} from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { InvestigateInTimelineButton } from '../../../../common/components/event_details/investigate_in_timeline_button';
import { getDataProvider } from '../../../../common/components/event_details/use_action_cell_data_provider';
const ENTITY_ALERT_COUNT_ID = 'entity-alert-count';
const SEVERITIES = ['unknown', 'low', 'medium', 'high', 'critical'];
interface AlertCountInsightProps {
/**
@ -39,7 +43,7 @@ interface AlertCountInsightProps {
}
/*
* Displays a distribution bar with the count of critical alerts for a given entity
* Displays a distribution bar with the total alert count for a given entity
*/
export const AlertCountInsight: React.FC<AlertCountInsightProps> = ({
name,
@ -56,22 +60,27 @@ export const AlertCountInsight: React.FC<AlertCountInsightProps> = ({
uniqueQueryId,
signalIndexName: null,
});
const dataProviders = useMemo(
() => [getDataProvider(fieldName, `timeline-indicator-${fieldName}-${name}`, name)],
[fieldName, name]
);
const data = useMemo(() => (getIsAlertsBySeverityData(items) ? items : []), [items]);
const alertStats = useMemo(() => {
return data.map((item) => ({
key: item.key,
count: item.value,
color: getSeverityColor(item.key),
}));
}, [data]);
const count = useMemo(
() => data.filter((item) => item.key === 'critical')[0]?.value ?? 0,
const alertStats = useMemo(
() =>
data
.map((item) => ({
key: item.key,
count: item.value,
color: getSeverityColor(item.key),
}))
.sort((a, b) => SEVERITIES.indexOf(a.key) - SEVERITIES.indexOf(b.key)),
[data]
);
const totalAlertCount = useMemo(() => data.reduce((acc, item) => acc + item.value, 0), [data]);
if (!isLoading && items.length === 0) return null;
return (
@ -87,7 +96,17 @@ export const AlertCountInsight: React.FC<AlertCountInsightProps> = ({
/>
}
stats={alertStats}
count={count}
count={
<div data-test-subj={`${dataTestSubj}-count`}>
<InvestigateInTimelineButton
asEmptyButton={true}
dataProviders={dataProviders}
flush={'both'}
>
<FormattedCount count={totalAlertCount} />
</InvestigateInTimelineButton>
</div>
}
direction={direction}
data-test-subj={`${dataTestSubj}-distribution-bar`}
/>

View file

@ -11,7 +11,7 @@ import { InsightDistributionBar } from './insight_distribution_bar';
import { TestProviders } from '../../../../common/mock';
const title = 'test title';
const count = 10;
const count = <div data-test-subj="test-count">{'100'}</div>;
const testId = 'test-id';
const stats = [
{
@ -35,7 +35,7 @@ describe('<InsightDistributionBar />', () => {
);
expect(getByTestId(testId)).toBeInTheDocument();
expect(getByText(title)).toBeInTheDocument();
expect(getByTestId(`${testId}-badge`)).toHaveTextContent(`${count}`);
expect(getByTestId('test-count')).toBeInTheDocument();
expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument();
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { css } from '@emotion/css';
import {
EuiFlexGroup,
@ -17,7 +17,6 @@ import {
type EuiFlexGroupProps,
} from '@elastic/eui';
import { DistributionBar } from '@kbn/security-solution-distribution-bar';
import { FormattedCount } from '../../../../common/components/formatted_number';
export interface InsightDistributionBarProps {
/**
@ -31,7 +30,7 @@ export interface InsightDistributionBarProps {
/**
* Count to be displayed in the badge
*/
count: number;
count: React.ReactNode;
/**
* Flex direction of the component
*/
@ -53,34 +52,53 @@ export const InsightDistributionBar: React.FC<InsightDistributionBarProps> = ({
const { euiTheme } = useEuiTheme();
const xsFontSize = useEuiFontSize('xs').fontSize;
const barComponent = useMemo(
() => (
<EuiFlexGroup gutterSize="xs" responsive={false}>
<EuiFlexItem>
<DistributionBar
stats={stats}
hideLastTooltip
data-test-subj={`${dataTestSubj}-distribution-bar`}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj={`${dataTestSubj}-badge`}>
<EuiBadge color="hollow">{count}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
),
[stats, count, dataTestSubj]
);
return (
<EuiFlexGroup direction={direction} data-test-subj={dataTestSubj} responsive={false}>
<EuiFlexItem>
<EuiFlexGroup
direction={direction}
data-test-subj={dataTestSubj}
responsive={false}
gutterSize="s"
>
<EuiFlexItem grow={false}>
<EuiText
css={css`
font-size: ${xsFontSize};
font-weight: ${euiTheme.font.weight.bold};
min-width: 115px;
`}
>
{title}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup gutterSize="xs" responsive={false}>
<EuiFlexItem>
<DistributionBar
stats={stats}
hideLastTooltip
data-test-subj={`${dataTestSubj}-distribution-bar`}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj={`${dataTestSubj}-badge`}>
<EuiBadge color="hollow">
<FormattedCount count={count} />
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{direction === 'column' ? (
<EuiFlexItem
css={css`
margin-top: -${euiTheme.size.base};
`}
>
{barComponent}
</EuiFlexItem>
) : (
<EuiFlexItem>{barComponent}</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -10,34 +10,87 @@ import { render } from '@testing-library/react';
import { TestProviders } from '../../../../common/mock';
import { MisconfigurationsInsight } from './misconfiguration_insight';
import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview';
import { DocumentDetailsContext } from '../context';
import { mockFlyoutApi } from '../mocks/mock_flyout_context';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { mockContextValue } from '../mocks/mock_context';
import { HostPreviewPanelKey } from '../../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview';
import { UserPreviewPanelKey } from '../../../entity_details/user_right';
import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview';
jest.mock('@kbn/expandable-flyout');
jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview');
const fieldName = 'host.name';
const name = 'test host';
const hostName = 'test host';
const userName = 'test user';
const testId = 'test';
const renderMisconfigurationsInsight = () => {
const renderMisconfigurationsInsight = (fieldName: 'host.name' | 'user.name', value: string) => {
return render(
<TestProviders>
<MisconfigurationsInsight name={name} fieldName={fieldName} data-test-subj={testId} />
<DocumentDetailsContext.Provider value={mockContextValue}>
<MisconfigurationsInsight name={value} fieldName={fieldName} data-test-subj={testId} />
</DocumentDetailsContext.Provider>
</TestProviders>
);
};
describe('MisconfigurationsInsight', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi);
});
it('renders', () => {
(useMisconfigurationPreview as jest.Mock).mockReturnValue({
data: { count: { passed: 1, failed: 2 } },
});
const { getByTestId } = renderMisconfigurationsInsight();
const { getByTestId } = renderMisconfigurationsInsight('host.name', hostName);
expect(getByTestId(testId)).toBeInTheDocument();
expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument();
});
it('renders null if no misconfiguration data found', () => {
(useMisconfigurationPreview as jest.Mock).mockReturnValue({});
const { container } = renderMisconfigurationsInsight();
const { container } = renderMisconfigurationsInsight('host.name', hostName);
expect(container).toBeEmptyDOMElement();
});
describe('should open entity flyout when clicking on badge', () => {
it('should open host entity flyout when clicking on host badge', () => {
(useMisconfigurationPreview as jest.Mock).mockReturnValue({
data: { count: { passed: 1, failed: 2 } },
});
const { getByTestId } = renderMisconfigurationsInsight('host.name', hostName);
expect(getByTestId(`${testId}-count`)).toHaveTextContent('3');
getByTestId(`${testId}-count`).click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: HostPreviewPanelKey,
params: {
hostName,
banner: HOST_PREVIEW_BANNER,
scopeId: mockContextValue.scopeId,
},
});
});
it('should open user entity flyout when clicking on user badge', () => {
(useMisconfigurationPreview as jest.Mock).mockReturnValue({
data: { count: { passed: 2, failed: 3 } },
});
const { getByTestId } = renderMisconfigurationsInsight('user.name', userName);
expect(getByTestId(`${testId}-count`)).toHaveTextContent('5');
getByTestId(`${testId}-count`).click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: UserPreviewPanelKey,
params: {
userName,
banner: USER_PREVIEW_BANNER,
scopeId: mockContextValue.scopeId,
},
});
});
});
});

View file

@ -6,12 +6,16 @@
*/
import React, { useMemo } from 'react';
import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui';
import { EuiFlexItem, type EuiFlexGroupProps, useEuiTheme } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/css';
import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview';
import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { InsightDistributionBar } from './insight_distribution_bar';
import { getFindingsStats } from '../../../../cloud_security_posture/components/misconfiguration/misconfiguration_preview';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { PreviewLink } from '../../../shared/components/preview_link';
import { useDocumentDetailsContext } from '../context';
interface MisconfigurationsInsightProps {
/**
@ -33,7 +37,7 @@ interface MisconfigurationsInsightProps {
}
/*
* Displays a distribution bar with the count of failed misconfigurations for a given entity
* Displays a distribution bar with the count of total misconfigurations for a given entity
*/
export const MisconfigurationsInsight: React.FC<MisconfigurationsInsightProps> = ({
name,
@ -41,6 +45,8 @@ export const MisconfigurationsInsight: React.FC<MisconfigurationsInsightProps> =
direction,
'data-test-subj': dataTestSubj,
}) => {
const { scopeId, isPreview } = useDocumentDetailsContext();
const { euiTheme } = useEuiTheme();
const { data } = useMisconfigurationPreview({
query: buildEntityFlyoutPreviewQuery(fieldName, name),
sort: [],
@ -50,13 +56,39 @@ export const MisconfigurationsInsight: React.FC<MisconfigurationsInsightProps> =
const passedFindings = data?.count.passed || 0;
const failedFindings = data?.count.failed || 0;
const hasMisconfigurationFindings = passedFindings > 0 || failedFindings > 0;
const totalFindings = useMemo(
() => passedFindings + failedFindings,
[passedFindings, failedFindings]
);
const hasMisconfigurationFindings = totalFindings > 0;
const misconfigurationsStats = useMemo(
() => getFindingsStats(passedFindings, failedFindings),
[passedFindings, failedFindings]
);
const count = useMemo(
() => (
<div
css={css`
margin-top: ${euiTheme.size.xs};
margin-bottom: ${euiTheme.size.xs};
`}
>
<PreviewLink
field={fieldName}
value={name}
scopeId={scopeId}
isPreview={isPreview}
data-test-subj={`${dataTestSubj}-count`}
>
<FormattedCount count={totalFindings} />
</PreviewLink>
</div>
),
[totalFindings, fieldName, name, scopeId, isPreview, dataTestSubj, euiTheme.size]
);
if (!hasMisconfigurationFindings) return null;
return (
@ -69,7 +101,7 @@ export const MisconfigurationsInsight: React.FC<MisconfigurationsInsightProps> =
/>
}
stats={misconfigurationsStats}
count={failedFindings}
count={count}
direction={direction}
data-test-subj={`${dataTestSubj}-distribution-bar`}
/>

View file

@ -10,7 +10,14 @@ import { render } from '@testing-library/react';
import React from 'react';
import { VulnerabilitiesInsight } from './vulnerabilities_insight';
import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview';
import { DocumentDetailsContext } from '../context';
import { mockFlyoutApi } from '../mocks/mock_flyout_context';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { mockContextValue } from '../mocks/mock_context';
import { HostPreviewPanelKey } from '../../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview';
jest.mock('@kbn/expandable-flyout');
jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview');
const hostName = 'test host';
@ -19,15 +26,21 @@ const testId = 'test';
const renderVulnerabilitiesInsight = () => {
return render(
<TestProviders>
<VulnerabilitiesInsight hostName={hostName} data-test-subj={testId} />
<DocumentDetailsContext.Provider value={mockContextValue}>
<VulnerabilitiesInsight hostName={hostName} data-test-subj={testId} />
</DocumentDetailsContext.Provider>
</TestProviders>
);
};
describe('VulnerabilitiesInsight', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi);
});
it('renders', () => {
(useVulnerabilitiesPreview as jest.Mock).mockReturnValue({
data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } },
data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, NONE: 0 } },
});
const { getByTestId } = renderVulnerabilitiesInsight();
@ -35,6 +48,24 @@ describe('VulnerabilitiesInsight', () => {
expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument();
});
it('opens host preview when click on count badge', () => {
(useVulnerabilitiesPreview as jest.Mock).mockReturnValue({
data: { count: { CRITICAL: 1, HIGH: 2, MEDIUM: 1, LOW: 2, NONE: 2 } },
});
const { getByTestId } = renderVulnerabilitiesInsight();
expect(getByTestId(`${testId}-count`)).toHaveTextContent('8');
getByTestId(`${testId}-count`).click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: HostPreviewPanelKey,
params: {
hostName,
banner: HOST_PREVIEW_BANNER,
scopeId: mockContextValue.scopeId,
},
});
});
it('renders null when data is not available', () => {
(useVulnerabilitiesPreview as jest.Mock).mockReturnValue({});

View file

@ -6,12 +6,16 @@
*/
import React, { useMemo } from 'react';
import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui';
import { EuiFlexItem, type EuiFlexGroupProps, useEuiTheme } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/css';
import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview';
import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { getVulnerabilityStats, hasVulnerabilitiesData } from '@kbn/cloud-security-posture';
import { InsightDistributionBar } from './insight_distribution_bar';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { PreviewLink } from '../../../shared/components/preview_link';
import { useDocumentDetailsContext } from '../context';
interface VulnerabilitiesInsightProps {
/**
@ -29,13 +33,15 @@ interface VulnerabilitiesInsightProps {
}
/*
* Displays a distribution bar with the count of critical vulnerabilities for a given host
* Displays a distribution bar and the total vulnerabilities count for a given host
*/
export const VulnerabilitiesInsight: React.FC<VulnerabilitiesInsightProps> = ({
hostName,
direction,
'data-test-subj': dataTestSubj,
}) => {
const { scopeId, isPreview } = useDocumentDetailsContext();
const { euiTheme } = useEuiTheme();
const { data } = useVulnerabilitiesPreview({
query: buildEntityFlyoutPreviewQuery('host.name', hostName),
sort: [],
@ -44,6 +50,11 @@ export const VulnerabilitiesInsight: React.FC<VulnerabilitiesInsightProps> = ({
});
const { CRITICAL = 0, HIGH = 0, MEDIUM = 0, LOW = 0, NONE = 0 } = data?.count || {};
const totalVulnerabilities = useMemo(
() => CRITICAL + HIGH + MEDIUM + LOW + NONE,
[CRITICAL, HIGH, MEDIUM, LOW, NONE]
);
const hasVulnerabilitiesFindings = useMemo(
() =>
hasVulnerabilitiesData({
@ -68,6 +79,28 @@ export const VulnerabilitiesInsight: React.FC<VulnerabilitiesInsightProps> = ({
[CRITICAL, HIGH, MEDIUM, LOW, NONE]
);
const count = useMemo(
() => (
<div
css={css`
margin-top: ${euiTheme.size.xs};
margin-bottom: ${euiTheme.size.xs};
`}
>
<PreviewLink
field={'host.name'}
value={hostName}
scopeId={scopeId}
isPreview={isPreview}
data-test-subj={`${dataTestSubj}-count`}
>
<FormattedCount count={totalVulnerabilities} />
</PreviewLink>
</div>
),
[totalVulnerabilities, hostName, scopeId, isPreview, dataTestSubj, euiTheme.size]
);
if (!hasVulnerabilitiesFindings) return null;
return (
@ -80,7 +113,7 @@ export const VulnerabilitiesInsight: React.FC<VulnerabilitiesInsightProps> = ({
/>
}
stats={vulnerabilitiesStats}
count={CRITICAL}
count={count}
direction={direction}
data-test-subj={`${dataTestSubj}-distribution-bar`}
/>