Skip to content

Commit

Permalink
[Security Solution] Document details flyout - update insight KPI count (
Browse files Browse the repository at this point in the history
#196617)

## Summary

This PR made some updates to the insights KPI following
#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
  • Loading branch information
christineweng authored Oct 17, 2024
1 parent 8dd895f commit 7195141
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -34,6 +34,7 @@ export interface InvestigateInTimelineButtonProps {
isDisabled?: boolean;
iconType?: IconType;
children?: React.ReactNode;
flush?: EuiButtonEmptyProps['flush'];
}

export const InvestigateInTimelineButton: FC<
Expand All @@ -46,6 +47,7 @@ export const InvestigateInTimelineButton: FC<
timeRange,
keepDataView,
iconType,
flush,
...rest
}) => {
const dispatch = useDispatch();
Expand Down Expand Up @@ -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}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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,
Expand All @@ -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 (
Expand All @@ -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`}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import React from 'react';
import React, { useMemo } from 'react';
import { css } from '@emotion/css';
import {
EuiFlexGroup,
Expand All @@ -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 {
/**
Expand All @@ -31,7 +30,7 @@ export interface InsightDistributionBarProps {
/**
* Count to be displayed in the badge
*/
count: number;
count: React.ReactNode;
/**
* Flex direction of the component
*/
Expand All @@ -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>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
});
});
});
Loading

0 comments on commit 7195141

Please sign in to comment.