[Security Solution] Add host/user name hover action to Entity analytics page (#144819)

issue: https://github.com/elastic/kibana/issues/144501

## Summary
Add host/user name hover action to Entity Analytics page.


https://user-images.githubusercontent.com/1490444/200599973-415049c3-f2bb-4ae4-9052-c58db87d32ea.mov


Why? To Improve triage by allowing users to investigate a Host.

*** The UX is aligned with the event view UI. When the user hovers a
table row it displays all hover actions.

### Extras
* Extract duplicated topN logic to a hook for reusability.
* Improve Entity analytics page header and risk table for small screens

**Before**
<img width="400px"
src="https://user-images.githubusercontent.com/1490444/200850153-c81a0d73-1f59-4384-b721-37cbebbbd35d.png">

**After**
<img width="250px"
src="https://user-images.githubusercontent.com/1490444/200850149-d8a3aaa4-db04-4b13-a266-71eedb1ffa97.png">
This commit is contained in:
Pablo Machado 2022-11-10 14:01:18 +01:00 committed by GitHub
parent b1d2211f9f
commit cbc7fe10f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 217 additions and 58 deletions

View file

@ -155,7 +155,7 @@ const DraggableOnWrapperComponent: React.FC<Props> = ({
openPopover,
onFocus,
setContainerRef,
showTopN,
isShowingTopN,
} = useHoverActions({
dataProvider,
hideTopN,
@ -307,7 +307,7 @@ const DraggableOnWrapperComponent: React.FC<Props> = ({
return (
<WithHoverActions
alwaysShow={showTopN || hoverActionsOwnFocus}
alwaysShow={isShowingTopN || hoverActionsOwnFocus}
closePopOverTrigger={closePopOverTrigger}
hoverContent={hoverContent}
onCloseRequested={onCloseRequested}
@ -333,7 +333,7 @@ const DraggableWrapperComponent: React.FC<Props> = ({
hoverContent,
onCloseRequested,
setContainerRef,
showTopN,
isShowingTopN,
} = useHoverActions({
dataProvider,
hideTopN,
@ -372,7 +372,7 @@ const DraggableWrapperComponent: React.FC<Props> = ({
if (!isDraggable) {
return (
<WithHoverActions
alwaysShow={showTopN || hoverActionsOwnFocus}
alwaysShow={isShowingTopN || hoverActionsOwnFocus}
closePopOverTrigger={closePopOverTrigger}
hoverContent={disableHoverActions(scopeId) ? undefined : hoverContent}
onCloseRequested={onCloseRequested}

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import React, { useCallback, useState, useContext } from 'react';
import React, { useContext } from 'react';
import { TimelineContext } from '../../../../timelines/components/timeline';
import { HoverActions } from '../../hover_actions';
import { useActionCellDataProvider } from './use_action_cell_data_provider';
import type { EnrichedFieldInfo } from '../types';
import type { ColumnHeaderOptions } from '../../../../../common/types/timeline';
import { useTopNPopOver } from '../../hover_actions/utils';
interface Props extends EnrichedFieldInfo {
contextId: string;
@ -51,22 +52,9 @@ export const ActionCell: React.FC<Props> = React.memo(
values,
});
const { closeTopN, toggleTopN, isShowingTopN } = useTopNPopOver(setIsPopoverVisible);
const { aggregatable, type } = fieldFromBrowserField || { aggregatable: false, type: '' };
const [showTopN, setShowTopN] = useState<boolean>(false);
const { timelineId: timelineIdFind } = useContext(TimelineContext);
const [hoverActionsOwnFocus] = useState<boolean>(false);
const toggleTopN = useCallback(() => {
setShowTopN((prevShowTopN) => {
const newShowTopN = !prevShowTopN;
if (setIsPopoverVisible) setIsPopoverVisible(newShowTopN);
return newShowTopN;
});
}, [setIsPopoverVisible]);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
return (
<HoverActions
@ -81,8 +69,8 @@ export const ActionCell: React.FC<Props> = React.memo(
hideAddToTimeline={hideAddToTimeline}
isObjectArray={data.isObjectArray}
onFilterAdded={onFilterAdded}
ownFocus={hoverActionsOwnFocus}
showTopN={showTopN}
ownFocus={false}
showTopN={isShowingTopN}
scopeId={scopeId ?? timelineIdFind}
toggleColumn={toggleColumn}
toggleTopN={toggleTopN}

View file

@ -16,6 +16,7 @@ import type { ColumnHeaderOptions, DataProvider } from '../../../../common/types
import { TimelineId } from '../../../../common/types/timeline';
import { SHOW_TOP_N_KEYBOARD_SHORTCUT } from './keyboard_shortcut_constants';
import { useHoverActionItems } from './use_hover_action_items';
import { SecurityPageName } from '../../../app/types';
export const YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS = (fieldName: string) =>
i18n.translate(
@ -217,10 +218,12 @@ export const HoverActions: React.FC<Props> = React.memo(
const isCaseView = scopeId === TimelineId.casePage;
const isTimelineView = scopeId === TimelineId.active;
const isAlertDetailsView = scopeId === TimelineId.detectionsAlertDetailsPage;
// TODO Provide a list of disabled/enabled actions as props
const isEntityAnalyticsPage = scopeId === SecurityPageName.entityAnalytics;
const hideFilters = useMemo(
() => isAlertDetailsView && !isTimelineView,
[isTimelineView, isAlertDetailsView]
() => (isAlertDetailsView || isEntityAnalyticsPage) && !isTimelineView,
[isTimelineView, isAlertDetailsView, isEntityAnalyticsPage]
);
const hiddenActionsCount = useMemo(() => {

View file

@ -14,6 +14,7 @@ import { HoverActions } from '.';
import type { DataProvider } from '../../../../common/types';
import { ProviderContentWrapper } from '../drag_and_drop/draggable_wrapper';
import { getDraggableId } from '../drag_and_drop/helpers';
import { useTopNPopOver } from './utils';
const draggableContainsLinks = (draggableElement: HTMLDivElement | null) => {
const links = draggableElement?.querySelectorAll('.euiLink') ?? [];
@ -55,7 +56,6 @@ export const useHoverActions = ({
const containerRef = useRef<HTMLDivElement | null>(null);
const keyboardHandlerRef = useRef<HTMLDivElement | null>(null);
const [closePopOverTrigger, setClosePopOverTrigger] = useState(false);
const [showTopN, setShowTopN] = useState<boolean>(false);
const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState<boolean>(false);
const id = useMemo(
() => (!scopeId ? timelineIdFind ?? tableIdFind : scopeId),
@ -78,25 +78,13 @@ export const useHoverActions = ({
}, 0); // invoked on the next tick, because we want to restore focus first
}, [keyboardHandlerRef]);
const toggleTopN = useCallback(() => {
setShowTopN((prevShowTopN) => {
const newShowTopN = !prevShowTopN;
if (newShowTopN === false) {
handleClosePopOverTrigger();
}
return newShowTopN;
});
}, [handleClosePopOverTrigger]);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
const { closeTopN, toggleTopN, isShowingTopN } = useTopNPopOver(handleClosePopOverTrigger);
const hoverContent = useMemo(() => {
// display links as additional content in the hover menu to enable keyboard
// navigation of links (when the draggable contains them):
const additionalContent =
hoverActionsOwnFocus && !showTopN && draggableContainsLinks(containerRef.current) ? (
hoverActionsOwnFocus && !isShowingTopN && draggableContainsLinks(containerRef.current) ? (
<ProviderContentWrapper
data-test-subj={`draggable-link-content-${dataProvider.queryMatch.field}`}
>
@ -119,7 +107,7 @@ export const useHoverActions = ({
onFilterAdded={onFilterAdded}
ownFocus={hoverActionsOwnFocus}
showOwnFocus={false}
showTopN={showTopN}
showTopN={isShowingTopN}
scopeId={id}
toggleTopN={toggleTopN}
values={
@ -131,7 +119,7 @@ export const useHoverActions = ({
);
}, [
hoverActionsOwnFocus,
showTopN,
isShowingTopN,
dataProvider,
render,
closeTopN,
@ -156,7 +144,7 @@ export const useHoverActions = ({
}, [hoverActionsOwnFocus, keyboardHandlerRef]);
const onCloseRequested = useCallback(() => {
setShowTopN(false);
closeTopN();
if (hoverActionsOwnFocus) {
setHoverActionsOwnFocus(false);
@ -165,7 +153,7 @@ export const useHoverActions = ({
onFocus(); // return focus to this draggable on the next tick, because we owned focus
}, 0);
}
}, [onFocus, hoverActionsOwnFocus]);
}, [onFocus, hoverActionsOwnFocus, closeTopN]);
const openPopover = useCallback(() => {
setHoverActionsOwnFocus(true);
@ -182,7 +170,7 @@ export const useHoverActions = ({
onFocus,
openPopover,
setContainerRef,
showTopN,
isShowingTopN,
}),
[
closePopOverTrigger,
@ -193,7 +181,7 @@ export const useHoverActions = ({
onFocus,
openPopover,
setContainerRef,
showTopN,
isShowingTopN,
]
);
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { useCallback, useState } from 'react';
export const getAdditionalScreenReaderOnlyContext = ({
field,
value,
@ -18,3 +20,20 @@ export const getAdditionalScreenReaderOnlyContext = ({
return Array.isArray(value) ? `${field} ${value.join(' ')}` : `${field} ${value}`;
};
export const useTopNPopOver = (setIsPopoverVisible?: (isVisible: boolean) => void) => {
const [isShowingTopN, setShowTopN] = useState<boolean>(false);
const toggleTopN = useCallback(() => {
setShowTopN((prevShowTopN) => {
const newShowTopN = !prevShowTopN;
if (setIsPopoverVisible) setIsPopoverVisible(newShowTopN);
return newShowTopN;
});
}, [setIsPopoverVisible]);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
return { closeTopN, toggleTopN, isShowingTopN };
};

View file

@ -0,0 +1,38 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__';
import { TestProviders } from '../../../../common/mock';
import { EntityAnalyticsHoverActions } from './entity_hover_actions';
const mockedUseKibana = mockUseKibana();
jest.mock('../../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../../common/lib/kibana');
return {
...original,
useKibana: () => mockedUseKibana,
};
});
describe('EntityAnalyticsHoverActions', () => {
it('it renders "add to timeline" and "copy" hover action', () => {
const { getByTestId } = render(
<EntityAnalyticsHoverActions
idPrefix={`my-test-field`}
fieldName={'test.field'}
fieldValue={'testValue'}
/>,
{ wrapper: TestProviders }
);
expect(getByTestId('test-add-to-timeline')).toBeInTheDocument();
expect(getByTestId('test-copy-button')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,72 @@
/*
* 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, { useMemo } from 'react';
import { noop } from 'lodash/fp';
import type { DataProvider } from '../../../../../common/types';
import { IS_OPERATOR } from '../../../../../common/types';
import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers';
import { SecurityPageName } from '../../../../app/types';
import { HoverActions } from '../../../../common/components/hover_actions';
interface Props {
onFilterAdded?: () => void;
fieldName: string;
fieldValue: string;
idPrefix: string;
}
export const EntityAnalyticsHoverActions: React.FC<Props> = ({
fieldName,
fieldValue,
idPrefix,
onFilterAdded,
}) => {
const id = useMemo(
() => escapeDataProviderId(`${idPrefix}-${fieldName}-${fieldValue}`),
[idPrefix, fieldName, fieldValue]
);
const dataProvider: DataProvider = useMemo(
() => ({
and: [],
enabled: true,
id,
name: fieldValue,
excluded: false,
kqlQuery: '',
queryMatch: {
field: fieldName,
value: fieldValue,
displayValue: fieldValue,
operator: IS_OPERATOR,
},
}),
[fieldName, fieldValue, id]
);
return (
<HoverActions
applyWidthAndPadding
closeTopN={noop}
dataProvider={dataProvider}
dataType={'string'}
field={fieldName}
fieldType={'keyword'}
hideTopN={true}
isAggregatable
isObjectArray={false}
onFilterAdded={onFilterAdded}
ownFocus={false}
scopeId={SecurityPageName.entityAnalytics}
showTopN={false}
toggleTopN={noop}
values={[fieldValue]}
/>
);
};
EntityAnalyticsHoverActions.displayName = 'EntityAnalyticsHoverActions';

View file

@ -8,10 +8,30 @@ import styled from 'styled-components';
import { EuiBasicTable } from '@elastic/eui';
// @ts-expect-error TS2769
export const BasicTableWithoutBorderBottom = styled(EuiBasicTable)`
export const StyledBasicTable = styled(EuiBasicTable)`
.euiTableRow {
.euiTableRowCell {
border-bottom: none !important;
}
}
.timelines__hoverActionButton {
opacity: 0;
}
.EntityAnalyticsTableHoverActions {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
}
`;

View file

@ -31,6 +31,7 @@ import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml
import { useQueryInspector } from '../../../../common/components/page/manage_query';
import { ENTITY_ANALYTICS_ANOMALIES_PANEL } from '../anomalies';
import { isJobStarted } from '../../../../../common/machine_learning/helpers';
import { FormattedCount } from '../../../../common/components/formatted_number';
const StyledEuiTitle = styled(EuiTitle)`
color: ${({ theme: { eui } }) => eui.euiColorDanger};
@ -150,7 +151,7 @@ export const EntityAnalyticsHeader = () => {
);
const totalAnomalies = useMemo(
() => (areJobsEnabled ? sumBy('count', data) : '-'),
() => (areJobsEnabled ? <FormattedCount count={sumBy('count', data)} /> : '-'),
[data, areJobsEnabled]
);
@ -165,14 +166,18 @@ export const EntityAnalyticsHeader = () => {
return (
<EuiPanel hasBorder paddingSize="l">
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexGroup justifyContent="spaceAround" responsive={false}>
{isPlatinumOrTrialLicense && (
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
<EuiFlexItem className="eui-textCenter">
<StyledEuiTitle data-test-subj="critical_hosts_quantity" size="l">
<span>
{hostsSeverityCount ? hostsSeverityCount[RiskSeverity.critical] : '-'}
{hostsSeverityCount ? (
<FormattedCount count={hostsSeverityCount[RiskSeverity.critical]} />
) : (
'-'
)}
</span>
</StyledEuiTitle>
</EuiFlexItem>
@ -190,11 +195,15 @@ export const EntityAnalyticsHeader = () => {
)}
{isPlatinumOrTrialLicense && (
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
<EuiFlexItem className="eui-textCenter">
<StyledEuiTitle data-test-subj="critical_users_quantity" size="l">
<span>
{usersSeverityCount ? usersSeverityCount[RiskSeverity.critical] : '-'}
{usersSeverityCount ? (
<FormattedCount count={usersSeverityCount[RiskSeverity.critical]} />
) : (
'-'
)}
</span>
</StyledEuiTitle>
</EuiFlexItem>
@ -212,7 +221,7 @@ export const EntityAnalyticsHeader = () => {
)}
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
<EuiFlexItem className="eui-textCenter">
<EuiTitle data-test-subj="anomalies_quantity" size="l">
<span>{totalAnomalies}</span>

View file

@ -22,6 +22,7 @@ import type {
import { RiskScoreEntity, RiskScoreFields } from '../../../../../common/search_strategy';
import * as i18n from './translations';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { EntityAnalyticsHoverActions } from '../common/entity_hover_actions';
type HostRiskScoreColumns = Array<EuiBasicTableColumn<HostRiskScore & UserRiskScore>>;
@ -37,9 +38,23 @@ export const getRiskScoreColumns = (
render: (entityName: string) => {
if (entityName != null && entityName.length > 0) {
return riskEntity === RiskScoreEntity.host ? (
<HostDetailsLink hostName={entityName} hostTab={HostsTableType.risk} />
<>
<HostDetailsLink hostName={entityName} hostTab={HostsTableType.risk} />
<EntityAnalyticsHoverActions
idPrefix={`hosts-risk-table-${entityName}`}
fieldName={'host.name'}
fieldValue={entityName}
/>
</>
) : (
<UserDetailsLink userName={entityName} userTab={UsersTableType.risk} />
<>
<UserDetailsLink userName={entityName} userTab={UsersTableType.risk} />
<EntityAnalyticsHoverActions
idPrefix={`users-risk-table-${entityName}`}
fieldName={'user.name'}
fieldValue={entityName}
/>
</>
);
}
return getEmptyTagValue();
@ -50,6 +65,7 @@ export const getRiskScoreColumns = (
riskEntity === RiskScoreEntity.host
? RiskScoreFields.hostRiskScore
: RiskScoreFields.userRiskScore,
width: '15%',
name: i18n.RISK_SCORE_TITLE(riskEntity),
truncateText: true,
mobileOptions: { show: true },
@ -57,7 +73,7 @@ export const getRiskScoreColumns = (
if (riskScore != null) {
return (
<span data-test-subj="risk-score-truncate" title={`${riskScore}`}>
{riskScore.toFixed(2)}
{Math.round(riskScore)}
</span>
);
}
@ -67,6 +83,7 @@ export const getRiskScoreColumns = (
{
field:
riskEntity === RiskScoreEntity.host ? RiskScoreFields.hostRisk : RiskScoreFields.userRisk,
width: '30%',
name: (
<EuiToolTip content={i18n.ENTITY_RISK_TOOLTIP(riskEntity)}>
<>
@ -86,7 +103,7 @@ export const getRiskScoreColumns = (
},
{
field: RiskScoreFields.alertsCount,
width: '15%',
width: '10%',
name: i18n.ALERTS,
truncateText: false,
mobileOptions: { show: true },

View file

@ -52,6 +52,8 @@ jest.mock('../../detection_response/hooks/use_navigate_to_timeline', () => {
};
});
jest.mock('../../../../common/components/hover_actions', () => ({ HoverActions: () => null }));
describe.each([RiskScoreEntity.host, RiskScoreEntity.user])(
'EntityAnalyticsRiskScores entityType: %s',
(riskEntity) => {

View file

@ -31,7 +31,7 @@ import { InspectButtonContainer } from '../../../../common/components/inspect';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { hostsActions } from '../../../../hosts/store';
import { RiskScoreDonutChart } from '../common/risk_score_donut_chart';
import { BasicTableWithoutBorderBottom } from '../common/basic_table_without_border_bottom';
import { StyledBasicTable } from '../common/styled_basic_table';
import { RISKY_HOSTS_DOC_LINK, RISKY_USERS_DOC_LINK } from '../../../../../common/constants';
import { RiskScoreHeaderTitle } from '../../../../risk_score/components/risk_score_onboarding/risk_score_header_title';
import { RiskScoresNoDataDetected } from '../../../../risk_score/components/risk_score_onboarding/risk_score_no_data_detected';
@ -273,12 +273,15 @@ const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskSc
<RiskScoreDonutChart severityCount={severityCount ?? EMPTY_SEVERITY_COUNT} />
</EuiFlexItem>
<EuiFlexItem>
<BasicTableWithoutBorderBottom
<StyledBasicTable
responsive={false}
items={data ?? []}
columns={columns}
loading={isTableLoading}
id={entity.tableQueryId}
rowProps={{
className: 'EntityAnalyticsTableHoverActions',
}}
/>
</EuiFlexItem>
</EuiFlexGroup>