[Security Solution] Add Timeline Hover Action to Security Dashboard Calculated Metrics (#154299)

Brings back the Add to timeline action on alert counts via Cell Actions

Areas implemented include:
- D&R Dashboard
  - Open alerts by rule
  - Hosts by alert severity
  - Users by alert severity
- Entity Analytics Dashboard
  - Host Risk Scores
  - User Risk Scores


![123](https://user-images.githubusercontent.com/6935300/231568732-2263ac45-ba50-4aab-b3a6-224182fbf92c.jpg)

---------
Crafted with friendship by

Co-authored-by: Pablo Neves Machado <pablo.nevesmachado@elastic.co>
Co-authored-by: semd <sergi.massaneda@elastic.co>
This commit is contained in:
Steph Milovic 2023-04-21 13:43:08 -06:00 committed by GitHub
parent e1b3bc259a
commit 22b8e5b0a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 885 additions and 196 deletions

View file

@ -6,7 +6,7 @@ Example:
```JSX
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
[...]
<CellActions mode={CellActionsMode.HOVER} triggerId={MY_TRIGGER_ID} config={{ field: 'fieldName', value: 'fieldValue', fieldType: 'text' }}>
<CellActions mode={CellActionsMode.HOVER_DOWN} triggerId={MY_TRIGGER_ID} config={{ field: 'fieldName', value: 'fieldValue', fieldType: 'text' }}>
Hover me
</CellActions>
</CellActionsProvider>

View file

@ -50,8 +50,8 @@ export const DefaultWithControls = CellActionsTemplate.bind({});
DefaultWithControls.argTypes = {
mode: {
options: [CellActionsMode.HOVER, CellActionsMode.INLINE],
defaultValue: CellActionsMode.HOVER,
options: [CellActionsMode.HOVER_DOWN, CellActionsMode.INLINE],
defaultValue: CellActionsMode.HOVER_DOWN,
control: {
type: 'radio',
},
@ -72,8 +72,14 @@ export const CellActionInline = ({}: {}) => (
</CellActions>
);
export const CellActionHoverPopup = ({}: {}) => (
<CellActions mode={CellActionsMode.HOVER} triggerId={TRIGGER_ID} field={FIELD}>
export const CellActionHoverPopoverDown = ({}: {}) => (
<CellActions mode={CellActionsMode.HOVER_DOWN} triggerId={TRIGGER_ID} field={FIELD}>
Hover me
</CellActions>
);
export const CellActionHoverPopoverRight = ({}: {}) => (
<CellActions mode={CellActionsMode.HOVER_RIGHT} triggerId={TRIGGER_ID} field={FIELD}>
Hover me
</CellActions>
);

View file

@ -15,6 +15,11 @@ import { CellActionsProvider } from '../context/cell_actions_context';
const TRIGGER_ID = 'test-trigger-id';
const FIELD = { name: 'name', value: '123', type: 'text' };
jest.mock('./hover_actions_popover', () => ({
HoverActionsPopover: jest.fn((props) => (
<span data-test-subj="hoverActionsPopover">{props.anchorPosition}</span>
)),
}));
describe('CellActions', () => {
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
@ -54,13 +59,13 @@ describe('CellActions', () => {
expect(queryByTestId('inlineActions')).toBeInTheDocument();
});
it('renders HoverActionsPopover when mode is HOVER', async () => {
it('renders HoverActionsPopover when mode is HOVER_DOWN', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
const { getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.HOVER} triggerId={TRIGGER_ID} field={FIELD}>
<CellActions mode={CellActionsMode.HOVER_DOWN} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsProvider>
@ -70,6 +75,27 @@ describe('CellActions', () => {
await getActionsPromise;
});
expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument();
expect(getByTestId('hoverActionsPopover')).toBeInTheDocument();
expect(getByTestId('hoverActionsPopover')).toHaveTextContent('downCenter');
});
it('renders HoverActionsPopover when mode is HOVER_RIGHT', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.HOVER_RIGHT} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsProvider>
);
await act(async () => {
await getActionsPromise;
});
expect(getByTestId('hoverActionsPopover')).toBeInTheDocument();
expect(getByTestId('hoverActionsPopover')).toHaveTextContent('rightCenter');
});
});

View file

@ -36,11 +36,17 @@ export const CellActions: React.FC<CellActionsProps> = ({
[field, triggerId, metadata]
);
const anchorPosition = useMemo(
() => (mode === CellActionsMode.HOVER_DOWN ? 'downCenter' : 'rightCenter'),
[mode]
);
const dataTestSubj = `cellActions-renderContent-${field.name}`;
if (mode === CellActionsMode.HOVER) {
if (mode === CellActionsMode.HOVER_DOWN || mode === CellActionsMode.HOVER_RIGHT) {
return (
<div className={className} ref={nodeRef} data-test-subj={dataTestSubj}>
<HoverActionsPopover
anchorPosition={anchorPosition}
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}
@ -65,6 +71,7 @@ export const CellActions: React.FC<CellActionsProps> = ({
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
<EuiFlexItem grow={false}>
<InlineActions
anchorPosition={anchorPosition}
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}

View file

@ -12,17 +12,17 @@ import { makeAction, makeActionContext } from '../mocks/helpers';
import { ExtraActionsPopOver, ExtraActionsPopOverWithAnchor } from './extra_actions_popover';
const actionContext = makeActionContext();
const defaultProps = {
anchorPosition: 'rightCenter' as const,
actionContext,
isOpen: false,
closePopOver: () => {},
actions: [],
button: <span />,
};
describe('ExtraActionsPopOver', () => {
it('renders', () => {
const { queryByTestId } = render(
<ExtraActionsPopOver
actionContext={actionContext}
isOpen={false}
closePopOver={() => {}}
actions={[]}
button={<span />}
/>
);
const { queryByTestId } = render(<ExtraActionsPopOver {...defaultProps} />);
expect(queryByTestId('extraActionsPopOver')).toBeInTheDocument();
});
@ -33,11 +33,10 @@ describe('ExtraActionsPopOver', () => {
const action = { ...makeAction('test-action'), execute: executeAction };
const { getByLabelText } = render(
<ExtraActionsPopOver
actionContext={actionContext}
{...defaultProps}
isOpen={true}
closePopOver={closePopOver}
actions={[action]}
button={<span />}
/>
);
@ -56,13 +55,7 @@ describe('ExtraActionsPopOverWithAnchor', () => {
it('renders', () => {
const { queryByTestId } = render(
<ExtraActionsPopOverWithAnchor
actionContext={actionContext}
isOpen={false}
closePopOver={() => {}}
actions={[]}
anchorRef={{ current: anchorElement }}
/>
<ExtraActionsPopOverWithAnchor {...defaultProps} anchorRef={{ current: anchorElement }} />
);
expect(queryByTestId('extraActionsPopOverWithAnchor')).toBeInTheDocument();
@ -74,7 +67,7 @@ describe('ExtraActionsPopOverWithAnchor', () => {
const action = { ...makeAction('test-action'), execute: executeAction };
const { getByLabelText } = render(
<ExtraActionsPopOverWithAnchor
actionContext={actionContext}
{...defaultProps}
isOpen={true}
closePopOver={closePopOver}
actions={[action]}

View file

@ -24,6 +24,7 @@ const euiContextMenuItemCSS = css`
`;
interface ActionsPopOverProps {
anchorPosition: 'rightCenter' | 'downCenter';
actionContext: CellActionExecutionContext;
isOpen: boolean;
closePopOver: () => void;
@ -32,6 +33,7 @@ interface ActionsPopOverProps {
}
export const ExtraActionsPopOver: React.FC<ActionsPopOverProps> = ({
anchorPosition,
actions,
actionContext,
isOpen,
@ -43,7 +45,7 @@ export const ExtraActionsPopOver: React.FC<ActionsPopOverProps> = ({
isOpen={isOpen}
closePopover={closePopOver}
panelPaddingSize="xs"
anchorPosition={'downCenter'}
anchorPosition={anchorPosition}
hasArrow
repositionOnScroll
ownFocus
@ -59,11 +61,15 @@ export const ExtraActionsPopOver: React.FC<ActionsPopOverProps> = ({
);
interface ExtraActionsPopOverWithAnchorProps
extends Pick<ActionsPopOverProps, 'actionContext' | 'closePopOver' | 'isOpen' | 'actions'> {
extends Pick<
ActionsPopOverProps,
'anchorPosition' | 'actionContext' | 'closePopOver' | 'isOpen' | 'actions'
> {
anchorRef: React.RefObject<HTMLElement>;
}
export const ExtraActionsPopOverWithAnchor = ({
anchorPosition,
anchorRef,
actionContext,
isOpen,
@ -77,7 +83,7 @@ export const ExtraActionsPopOverWithAnchor = ({
isOpen={isOpen}
closePopover={closePopOver}
panelPaddingSize="xs"
anchorPosition={'downCenter'}
anchorPosition={anchorPosition}
hasArrow={false}
repositionOnScroll
ownFocus

View file

@ -13,11 +13,17 @@ import { makeAction } from '../mocks/helpers';
import { CellActionExecutionContext } from '../types';
import { HoverActionsPopover } from './hover_actions_popover';
describe('HoverActionsPopover', () => {
const actionContext = {
const defaultProps = {
anchorPosition: 'rightCenter' as const,
disabledActionTypes: [],
visibleCellActions: 4,
actionContext: {
trigger: { id: 'triggerId' },
field: { name: 'fieldName' },
} as CellActionExecutionContext;
} as CellActionExecutionContext,
showActionTooltips: false,
};
describe('HoverActionsPopover', () => {
const TestComponent = () => <span data-test-subj="test-component" />;
jest.useFakeTimers();
@ -25,13 +31,7 @@ describe('HoverActionsPopover', () => {
const getActions = () => Promise.resolve([]);
const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActionTypes={[]}
children={null}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
/>
<HoverActionsPopover {...defaultProps} children={null} />
</CellActionsProvider>
);
expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument();
@ -44,12 +44,7 @@ describe('HoverActionsPopover', () => {
const { queryByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActionTypes={[]}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
>
<HoverActionsPopover {...defaultProps}>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
@ -70,12 +65,7 @@ describe('HoverActionsPopover', () => {
const { queryByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActionTypes={[]}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
>
<HoverActionsPopover {...defaultProps}>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
@ -101,12 +91,7 @@ describe('HoverActionsPopover', () => {
const { getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActionTypes={[]}
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
>
<HoverActionsPopover {...defaultProps} visibleCellActions={1}>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
@ -127,12 +112,7 @@ describe('HoverActionsPopover', () => {
const { getByTestId, getByLabelText } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActionTypes={[]}
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
>
<HoverActionsPopover {...defaultProps} visibleCellActions={1}>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
@ -162,12 +142,7 @@ describe('HoverActionsPopover', () => {
const { getByTestId, queryByLabelText } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActionTypes={[]}
visibleCellActions={2}
actionContext={actionContext}
showActionTooltips={false}
>
<HoverActionsPopover {...defaultProps} visibleCellActions={2}>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
@ -191,6 +166,44 @@ describe('HoverActionsPopover', () => {
expect(queryByLabelText('test-action-2')).toBeInTheDocument();
expect(queryByLabelText('test-action-3')).toBeInTheDocument();
});
it('does not add css positioning when anchorPosition = downCenter', async () => {
const getActionsPromise = Promise.resolve([makeAction('test-action')]);
const getActions = () => getActionsPromise;
const { getByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover {...defaultProps} anchorPosition="downCenter">
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
expect(Object.values(getByLabelText('Actions').style).includes('margin-top')).toEqual(false);
});
it('adds css positioning when anchorPosition = rightCenter', async () => {
const getActionsPromise = Promise.resolve([makeAction('test-action')]);
const getActions = () => getActionsPromise;
const { getByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover {...defaultProps} anchorPosition="rightCenter">
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
expect(Object.values(getByLabelText('Actions').style).includes('margin-top')).toEqual(true);
});
});
const hoverElement = async (element: Element, waitForChange: () => Promise<unknown>) => {

View file

@ -36,6 +36,7 @@ const hoverContentWrapperCSS = css`
const HOVER_INTENT_DELAY = 100; // ms
interface Props {
anchorPosition: 'downCenter' | 'rightCenter';
children: React.ReactNode;
visibleCellActions: number;
actionContext: CellActionExecutionContext;
@ -44,6 +45,7 @@ interface Props {
}
export const HoverActionsPopover: React.FC<Props> = ({
anchorPosition,
children,
visibleCellActions,
actionContext,
@ -115,12 +117,17 @@ export const HoverActionsPopover: React.FC<Props> = ({
);
}, [onMouseEnter, closeExtraActions, children]);
const panelStyle = useMemo(
() => (anchorPosition === 'rightCenter' ? { marginTop: euiThemeVars.euiSizeS } : {}),
[anchorPosition]
);
return (
<>
<div onMouseLeave={closePopover}>
<EuiPopover
panelStyle={PANEL_STYLE}
anchorPosition={'downCenter'}
panelStyle={{ ...PANEL_STYLE, ...panelStyle }}
anchorPosition={anchorPosition}
button={content}
closePopover={closePopover}
hasArrow={false}
@ -155,6 +162,7 @@ export const HoverActionsPopover: React.FC<Props> = ({
</EuiPopover>
</div>
<ExtraActionsPopOverWithAnchor
anchorPosition={anchorPosition}
actions={extraActions}
anchorRef={contentRef}
actionContext={actionContext}

View file

@ -13,19 +13,20 @@ import { InlineActions } from './inline_actions';
import { CellActionExecutionContext } from '../types';
import { CellActionsProvider } from '../context';
const defaultProps = {
anchorPosition: 'rightCenter' as const,
disabledActionTypes: [],
visibleCellActions: 5,
actionContext: { trigger: { id: 'triggerId' } } as CellActionExecutionContext,
showActionTooltips: false,
};
describe('InlineActions', () => {
const actionContext = { trigger: { id: 'triggerId' } } as CellActionExecutionContext;
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<InlineActions
disabledActionTypes={[]}
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
/>
<InlineActions {...defaultProps} />
</CellActionsProvider>
);
@ -47,12 +48,7 @@ describe('InlineActions', () => {
const getActions = () => getActionsPromise;
const { queryAllByRole } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<InlineActions
disabledActionTypes={[]}
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
/>
<InlineActions {...defaultProps} />
</CellActionsProvider>
);

View file

@ -17,6 +17,7 @@ import { useLoadActions } from '../hooks/use_load_actions';
interface InlineActionsProps {
actionContext: CellActionExecutionContext;
anchorPosition: 'rightCenter' | 'downCenter';
showActionTooltips: boolean;
visibleCellActions: number;
disabledActionTypes: string[];
@ -24,6 +25,7 @@ interface InlineActionsProps {
export const InlineActions: React.FC<InlineActionsProps> = ({
actionContext,
anchorPosition,
showActionTooltips,
visibleCellActions,
disabledActionTypes,
@ -47,10 +49,9 @@ export const InlineActions: React.FC<InlineActionsProps> = ({
data-test-subj="inlineActions"
className={`inlineActions ${isPopoverOpen ? 'inlineActions-popoverOpen' : ''}`}
>
{visibleActions.map((action, index) => (
<EuiFlexItem grow={false}>
{visibleActions.map((action) => (
<EuiFlexItem grow={false} key={`action-item-${action.id}`}>
<ActionItem
key={`action-item-${index}`}
action={action}
actionContext={actionContext}
showTooltip={showActionTooltips}
@ -62,6 +63,7 @@ export const InlineActions: React.FC<InlineActionsProps> = ({
<ExtraActionsPopOver
actions={extraActions}
actionContext={actionContext}
anchorPosition={anchorPosition}
button={button}
closePopOver={closePopOver}
isOpen={isPopoverOpen}

View file

@ -10,6 +10,7 @@ export const FILTER_CELL_ACTION_TYPE = 'cellAction-filter';
export const COPY_CELL_ACTION_TYPE = 'cellAction-copy';
export enum CellActionsMode {
HOVER = 'hover',
HOVER_DOWN = 'hover-down',
HOVER_RIGHT = 'hover-right',
INLINE = 'inline',
}

View file

@ -0,0 +1,247 @@
/*
* 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 type { SecurityAppStore } from '../../../common/store/types';
import type { DataProvider } from '../../../../common/types';
import { TimelineId } from '../../../../common/types';
import { addProvider } from '../../../timelines/store/timeline/actions';
import { createAddToNewTimelineCellActionFactory, getToastMessage } from './add_to_new_timeline';
import type { CellActionExecutionContext } from '@kbn/cell-actions';
import { GEO_FIELD_TYPE } from '../../../timelines/components/timeline/body/renderers/constants';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { timelineActions } from '../../../timelines/store/timeline';
const services = createStartServicesMock();
const mockWarningToast = services.notifications.toasts.addWarning;
const mockDispatch = jest.fn();
const store = {
dispatch: mockDispatch,
} as unknown as SecurityAppStore;
const value = 'the-value';
const context = {
field: { name: 'user.name', value, type: 'text' },
} as CellActionExecutionContext;
const defaultAddProviderAction = {
type: addProvider.type,
payload: {
id: TimelineId.active,
providers: [
{
and: [],
enabled: true,
excluded: false,
id: 'event-field-default-timeline-1-user_name-0-the-value',
kqlQuery: '',
name: 'user.name',
queryMatch: {
field: 'user.name',
operator: ':',
value: 'the-value',
},
},
],
},
};
describe('createAddToNewTimelineCellAction', () => {
const addToTimelineCellActionFactory = createAddToNewTimelineCellActionFactory({
store,
services,
});
const addToTimelineAction = addToTimelineCellActionFactory({ id: 'testAddToTimeline', order: 1 });
beforeEach(() => {
jest.clearAllMocks();
});
it('should return display name', () => {
expect(addToTimelineAction.getDisplayName(context)).toEqual('Investigate in timeline');
});
it('should return icon type', () => {
expect(addToTimelineAction.getIconType(context)).toEqual('timeline');
});
describe('isCompatible', () => {
it('should return true if everything is okay', async () => {
expect(await addToTimelineAction.isCompatible(context)).toEqual(true);
});
it('should return false if field not allowed', async () => {
expect(
await addToTimelineAction.isCompatible({
...context,
field: { ...context.field, name: 'signal.reason' },
})
).toEqual(false);
});
});
describe('execute', () => {
it('should execute normally', async () => {
await addToTimelineAction.execute(context);
expect(mockDispatch).toHaveBeenCalledWith(defaultAddProviderAction);
expect(mockWarningToast).not.toHaveBeenCalled();
});
it('should show warning if no provider added', async () => {
await addToTimelineAction.execute({
...context,
field: {
...context.field,
type: GEO_FIELD_TYPE,
},
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
describe('should execute correctly when negateFilters is provided', () => {
it('should not exclude if negateFilters is false', async () => {
await addToTimelineAction.execute({
...context,
metadata: {
negateFilters: false,
},
});
expect(mockDispatch).toHaveBeenCalledWith(defaultAddProviderAction);
expect(mockWarningToast).not.toHaveBeenCalled();
});
it('should exclude if negateFilters is true', async () => {
await addToTimelineAction.execute({
...context,
metadata: {
negateFilters: true,
},
});
expect(mockDispatch).toHaveBeenCalledWith({
...defaultAddProviderAction,
payload: {
...defaultAddProviderAction.payload,
providers: [{ ...defaultAddProviderAction.payload.providers[0], excluded: true }],
},
});
expect(mockWarningToast).not.toHaveBeenCalled();
});
});
it('should clear the timeline', async () => {
await addToTimelineAction.execute(context);
expect(mockDispatch.mock.calls[0][0].type).toEqual(timelineActions.createTimeline.type);
});
it('should add the providers to the timeline', async () => {
await addToTimelineAction.execute({
...context,
metadata: {
andFilters: [{ field: 'kibana.alert.severity', value: 'low' }],
},
});
expect(mockDispatch).toBeCalledWith({
...defaultAddProviderAction,
payload: {
...defaultAddProviderAction.payload,
providers: [
{
...defaultAddProviderAction.payload.providers[0],
id: 'event-field-default-timeline-1-user_name-0-the-value',
queryMatch: defaultAddProviderAction.payload.providers[0].queryMatch,
and: [
{
enabled: true,
excluded: false,
id: 'event-field-default-timeline-1-kibana_alert_severity-0-low',
kqlQuery: '',
name: 'kibana.alert.severity',
queryMatch: {
field: 'kibana.alert.severity',
operator: ':',
value: 'low',
},
and: [],
},
],
},
],
},
});
});
});
describe('getToastMessage', () => {
it('handles empty input', () => {
const result = getToastMessage({ queryMatch: { value: null } } as unknown as DataProvider);
expect(result).toEqual('');
});
it('handles array input', () => {
const result = getToastMessage({
queryMatch: { value: ['hello', 'world'] },
} as unknown as DataProvider);
expect(result).toEqual('hello, world alerts');
});
it('handles single filter', () => {
const result = getToastMessage({
queryMatch: { value },
and: [{ queryMatch: { field: 'kibana.alert.severity', value: 'critical' } }],
} as unknown as DataProvider);
expect(result).toEqual(`critical severity alerts from ${value}`);
});
it('handles multiple filters', () => {
const result = getToastMessage({
queryMatch: { value },
and: [
{
queryMatch: { field: 'kibana.alert.workflow_status', value: 'open' },
},
{
queryMatch: { field: 'kibana.alert.severity', value: 'critical' },
},
],
} as unknown as DataProvider);
expect(result).toEqual(`open, critical severity alerts from ${value}`);
});
it('ignores unrelated filters', () => {
const result = getToastMessage({
queryMatch: { value },
and: [
{
queryMatch: { field: 'kibana.alert.workflow_status', value: 'open' },
},
{
queryMatch: { field: 'kibana.alert.severity', value: 'critical' },
},
// currently only supporting the above fields
{
queryMatch: { field: 'user.name', value: 'something' },
},
],
} as unknown as DataProvider);
expect(result).toEqual(`open, critical severity alerts from ${value}`);
});
it('returns entity only when unrelated filters are passed', () => {
const result = getToastMessage({
queryMatch: { value },
and: [{ queryMatch: { field: 'user.name', value: 'something' } }],
} as unknown as DataProvider);
expect(result).toEqual(`${value} alerts`);
});
it('returns entity only when no filters are passed', () => {
const result = getToastMessage({ queryMatch: { value }, and: [] } as unknown as DataProvider);
expect(result).toEqual(`${value} alerts`);
});
});
});

View file

@ -0,0 +1,117 @@
/*
* 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 { createCellActionFactory, type CellActionTemplate } from '@kbn/cell-actions';
import { timelineActions } from '../../../timelines/store/timeline';
import { addProvider } from '../../../timelines/store/timeline/actions';
import type { DataProvider } from '../../../../common/types';
import { TimelineId } from '../../../../common/types';
import type { SecurityAppStore } from '../../../common/store';
import { fieldHasCellActions } from '../../utils';
import {
ADD_TO_NEW_TIMELINE,
ADD_TO_TIMELINE_FAILED_TEXT,
ADD_TO_TIMELINE_FAILED_TITLE,
ADD_TO_TIMELINE_ICON,
ADD_TO_TIMELINE_SUCCESS_TITLE,
ALERTS_COUNT,
SEVERITY,
} from '../constants';
import { createDataProviders, isValidDataProviderField } from '../data_provider';
import { SecurityCellActionType } from '../../constants';
import type { StartServices } from '../../../types';
import type { SecurityCellAction } from '../../types';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
const severityField = 'kibana.alert.severity';
const statusField = 'kibana.alert.workflow_status';
export const getToastMessage = ({ queryMatch: { value }, and = [] }: DataProvider) => {
if (value == null) {
return '';
}
const fieldValue = Array.isArray(value) ? value.join(', ') : value.toString();
const descriptors = and.reduce<string[]>((msg, { queryMatch }) => {
if (Array.isArray(queryMatch.value)) {
return msg;
}
if (queryMatch.field === severityField) {
msg.push(SEVERITY(queryMatch.value.toString()));
}
if (queryMatch.field === statusField) {
msg.push(queryMatch.value.toString());
}
return msg;
}, []);
return ALERTS_COUNT(fieldValue, descriptors.join(', '));
};
export const createAddToNewTimelineCellActionFactory = createCellActionFactory(
({
store,
services,
}: {
store: SecurityAppStore;
services: StartServices;
}): CellActionTemplate<SecurityCellAction> => {
const { notifications: notificationsService } = services;
return {
type: SecurityCellActionType.ADD_TO_TIMELINE,
getIconType: () => ADD_TO_TIMELINE_ICON,
getDisplayName: () => ADD_TO_NEW_TIMELINE,
getDisplayNameTooltip: () => ADD_TO_NEW_TIMELINE,
isCompatible: async ({ field }) =>
fieldHasCellActions(field.name) && isValidDataProviderField(field.name, field.type),
execute: async ({ field, metadata }) => {
const dataProviders =
createDataProviders({
contextId: TimelineId.active,
fieldType: field.type,
values: field.value,
field: field.name,
negate: metadata?.negateFilters === true,
}) ?? [];
for (const andFilter of metadata?.andFilters ?? []) {
const andDataProviders =
createDataProviders({
contextId: TimelineId.active,
field: andFilter.field,
values: andFilter.value,
}) ?? [];
if (andDataProviders) {
for (const dataProvider of dataProviders) {
dataProvider.and.push(...andDataProviders);
}
}
}
if (dataProviders.length > 0) {
// clear timeline
store.dispatch(
timelineActions.createTimeline({
...timelineDefaults,
id: TimelineId.active,
})
);
store.dispatch(addProvider({ id: TimelineId.active, providers: dataProviders }));
notificationsService.toasts.addSuccess({
title: ADD_TO_TIMELINE_SUCCESS_TITLE(getToastMessage(dataProviders[0])),
});
} else {
notificationsService.toasts.addWarning({
title: ADD_TO_TIMELINE_FAILED_TITLE,
text: ADD_TO_TIMELINE_FAILED_TEXT,
});
}
},
};
}
);

View file

@ -15,6 +15,29 @@ export const ADD_TO_TIMELINE = i18n.translate(
defaultMessage: 'Add to timeline',
}
);
export const ADD_TO_NEW_TIMELINE = i18n.translate(
'xpack.securitySolution.actions.cellValue.addToNewTimeline.displayName',
{
defaultMessage: 'Investigate in timeline',
}
);
export const SEVERITY = (level: string) =>
i18n.translate('xpack.securitySolution.actions.addToTimeline.severityLevel', {
values: { level },
defaultMessage: `{level} severity`,
});
export const ALERTS_COUNT = (entity: string, description: string) =>
description !== ''
? i18n.translate('xpack.securitySolution.actions.addToTimeline.descriptiveAlertsCountMessage', {
values: { description, entity },
defaultMessage: '{description} alerts from {entity}',
})
: i18n.translate('xpack.securitySolution.actions.addToTimeline.alertsCountMessage', {
values: { entity },
defaultMessage: '{entity} alerts',
});
export const ADD_TO_TIMELINE_SUCCESS_TITLE = (value: string) =>
i18n.translate('xpack.securitySolution.actions.addToTimeline.addedFieldMessage', {

View file

@ -6,4 +6,5 @@
*/
export { createAddToTimelineCellActionFactory } from './cell_action/add_to_timeline';
export { createAddToNewTimelineCellActionFactory } from './cell_action/add_to_new_timeline';
export { createAddToTimelineLensAction } from './lens/add_to_timeline';

View file

@ -7,6 +7,7 @@
export enum SecurityCellActionsTrigger {
DEFAULT = 'security-default-cellActions',
DETAILS_FLYOUT = 'security-detailsFlyout-cellActions',
ALERTS_COUNT = 'security-alertsCount-cellActions',
}
export enum SecurityCellActionType {

View file

@ -13,6 +13,7 @@ import { createFilterInCellActionFactory, createFilterOutCellActionFactory } fro
import {
createAddToTimelineLensAction,
createAddToTimelineCellActionFactory,
createAddToNewTimelineCellActionFactory,
} from './add_to_timeline';
import { createShowTopNCellActionFactory } from './show_top_n';
import {
@ -52,6 +53,7 @@ const registerCellActions = (
filterIn: createFilterInCellActionFactory({ store, services }),
filterOut: createFilterOutCellActionFactory({ store, services }),
addToTimeline: createAddToTimelineCellActionFactory({ store, services }),
addToNewTimeline: createAddToNewTimelineCellActionFactory({ store, services }),
showTopN: createShowTopNCellActionFactory({ store, history, services }),
copyToClipboard: createCopyToClipboardCellActionFactory({ services }),
toggleColumn: createToggleColumnCellActionFactory({ store }),
@ -77,6 +79,13 @@ const registerCellActions = (
],
services,
});
registerCellActionsTrigger({
triggerId: SecurityCellActionsTrigger.ALERTS_COUNT,
cellActions,
actionsOrder: ['addToNewTimeline'],
services,
});
};
const registerCellActionsTrigger = ({
@ -95,8 +104,10 @@ const registerCellActionsTrigger = ({
actionsOrder.forEach((actionName, order) => {
const actionFactory = cellActions[actionName];
const action = actionFactory({ id: `${triggerId}-${actionName}`, order });
if (actionFactory) {
const action = actionFactory({ id: `${triggerId}-${actionName}`, order });
uiActions.addTriggerAction(triggerId, enhanceActionWithTelemetry(action, services));
uiActions.addTriggerAction(triggerId, enhanceActionWithTelemetry(action, services));
}
});
};

View file

@ -6,6 +6,12 @@
*/
import type { CellAction, CellActionExecutionContext, CellActionFactory } from '@kbn/cell-actions';
import type { QueryOperator } from '../../common/types';
export interface AndFilter {
field: string;
value: string | string[];
operator?: QueryOperator;
}
export interface SecurityMetadata extends Record<string, unknown> {
/**
@ -35,6 +41,11 @@ export interface SecurityMetadata extends Record<string, unknown> {
*/
component: string;
};
/**
* `metadata.andFilters` is used by the addToTimelineAction to add
* an "and" query to the main data provider
*/
andFilters?: AndFilter[];
}
export interface SecurityCellActionExecutionContext extends CellActionExecutionContext {
@ -42,13 +53,15 @@ export interface SecurityCellActionExecutionContext extends CellActionExecutionC
}
export type SecurityCellAction = CellAction<SecurityCellActionExecutionContext>;
// All security cell actions names
export type SecurityCellActionName =
| 'filterIn'
| 'filterOut'
| 'addToTimeline'
| 'showTopN'
| 'copyToClipboard'
| 'toggleColumn';
export interface SecurityCellActions {
filterIn?: CellActionFactory;
filterOut?: CellActionFactory;
addToTimeline?: CellActionFactory;
addToNewTimeline?: CellActionFactory;
showTopN?: CellActionFactory;
copyToClipboard?: CellActionFactory;
toggleColumn?: CellActionFactory;
}
export type SecurityCellActions = Record<SecurityCellActionName, CellActionFactory>;
// All security cell actions names
export type SecurityCellActionName = keyof SecurityCellActions;

View file

@ -10,7 +10,7 @@ exports[`entity_draggable renders correctly against snapshot 1`] = `
"value": "entity-value",
}
}
mode="hover"
mode="hover-down"
triggerId="security-default-cellActions"
visibleCellActions={5}
>

View file

@ -23,7 +23,7 @@ export const EntityComponent: React.FC<Props> = ({ entityName, entityValue }) =>
aggregatable: true,
}}
triggerId={SecurityCellActionsTrigger.DEFAULT}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
>
{`${entityName}: "${entityValue}"`}

View file

@ -10,7 +10,7 @@ exports[`draggable_score renders correctly against snapshot 1`] = `
"value": "du",
}
}
mode="hover"
mode="hover-down"
triggerId="security-default-cellActions"
visibleCellActions={5}
>
@ -28,7 +28,7 @@ exports[`draggable_score renders correctly against snapshot when the index is no
"value": "du",
}
}
mode="hover"
mode="hover-down"
triggerId="security-default-cellActions"
visibleCellActions={5}
>

View file

@ -26,7 +26,7 @@ export const ScoreComponent = ({
return (
<SecurityCellActions
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
field={{
name: score.entityName,
value: score.entityValue,

View file

@ -45,7 +45,7 @@ export const getRowItemsWithActions = ({
return (
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -56,7 +56,7 @@ export const getAlertsTypeTableColumns = (
<EuiHealth color={ALERT_TYPE_COLOR[type as AlertType]}>
<EuiText grow={false} size="xs">
<SecurityCellActions
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={4}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -38,7 +38,7 @@ export const getHostRiskScoreColumns = ({
if (hostName != null && hostName.length > 0) {
return (
<SecurityCellActions
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -38,7 +38,7 @@ export const getHostsColumns = (
if (hostName != null && hostName.length > 0) {
return (
<SecurityCellActions
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
@ -96,7 +96,7 @@ export const getHostsColumns = (
if (hostOsName != null) {
return (
<SecurityCellActions
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
@ -123,7 +123,7 @@ export const getHostsColumns = (
if (hostOsVersion != null) {
return (
<SecurityCellActions
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -42,7 +42,7 @@ export const getNetworkDnsColumns = (): NetworkDnsColumns => [
return (
<SecurityCellActions
key={escapeDataProviderId(`networkDns-table--name-${dnsName}`)}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -60,7 +60,7 @@ export const getNetworkTopCountriesColumns = (
return (
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -68,7 +68,7 @@ export const getNetworkTopNFlowColumns = (
<>
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
@ -85,7 +85,7 @@ export const getNetworkTopNFlowColumns = (
{geo && (
<SecurityCellActions
key={`${id}-${geo}`}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -183,7 +183,7 @@ const NetworkDetailsComponent: React.FC = () => {
title={
<SecurityCellActions
field={{ type: 'ip', value: ip, name: `${flowTarget}.ip` }}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
triggerId={SecurityCellActionsTrigger.DEFAULT}
>

View file

@ -41,7 +41,7 @@ export const getUserRiskScoreColumns = ({
return (
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}

View file

@ -20,6 +20,7 @@ import {
} from '@elastic/eui';
import { ALERT_SEVERITY } from '@kbn/rule-data-utils';
import { CellActionsMode } from '@kbn/cell-actions';
import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { HeaderSection } from '../../../../common/components/header_section';
@ -33,6 +34,10 @@ import * as i18n from '../translations';
import { ITEMS_PER_PAGE, SEVERITY_COLOR } from '../utils';
import type { HostAlertsItem } from './use_host_alerts_items';
import { useHostAlertsItems } from './use_host_alerts_items';
import {
SecurityCellActions,
SecurityCellActionsTrigger,
} from '../../../../common/components/cell_actions';
interface HostAlertsTableProps {
signalIndexName: string | null;
@ -143,13 +148,27 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.ALERTS_TEXT,
'data-test-subj': 'hostSeverityAlertsTable-totalAlerts',
render: (totalAlerts: number, { hostName }) => (
<EuiLink
data-test-subj="hostSeverityAlertsTable-totalAlertsLink"
disabled={totalAlerts === 0}
onClick={() => handleClick({ hostName })}
<SecurityCellActions
field={{
name: 'host.name',
value: hostName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [{ field: 'kibana.alert.workflow_status', value: 'open' }],
}}
>
<FormattedCount count={totalAlerts} />
</EuiLink>
<EuiLink
data-test-subj="hostSeverityAlertsTable-totalAlertsLink"
disabled={totalAlerts === 0}
onClick={() => handleClick({ hostName })}
>
<FormattedCount count={totalAlerts} />
</EuiLink>
</SecurityCellActions>
),
},
{
@ -157,13 +176,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_CRITICAL_LABEL,
render: (count: number, { hostName }) => (
<EuiHealth data-test-subj="hostSeverityAlertsTable-critical" color={SEVERITY_COLOR.critical}>
<EuiLink
data-test-subj="hostSeverityAlertsTable-criticalLink"
disabled={count === 0}
onClick={() => handleClick({ hostName, severity: 'critical' })}
<SecurityCellActions
field={{
name: 'host.name',
value: hostName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'critical' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<FormattedCount count={count} />
</EuiLink>
<EuiLink
data-test-subj="hostSeverityAlertsTable-criticalLink"
disabled={count === 0}
onClick={() => handleClick({ hostName, severity: 'critical' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},
@ -172,9 +208,29 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_HIGH_LABEL,
render: (count: number, { hostName }) => (
<EuiHealth data-test-subj="hostSeverityAlertsTable-high" color={SEVERITY_COLOR.high}>
<EuiLink disabled={count === 0} onClick={() => handleClick({ hostName, severity: 'high' })}>
<FormattedCount count={count} />
</EuiLink>
<SecurityCellActions
field={{
name: 'host.name',
value: hostName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'high' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ hostName, severity: 'high' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},
@ -183,12 +239,29 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_MEDIUM_LABEL,
render: (count: number, { hostName }) => (
<EuiHealth data-test-subj="hostSeverityAlertsTable-medium" color={SEVERITY_COLOR.medium}>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ hostName, severity: 'medium' })}
<SecurityCellActions
field={{
name: 'host.name',
value: hostName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'medium' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<FormattedCount count={count} />
</EuiLink>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ hostName, severity: 'medium' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},
@ -197,9 +270,29 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_LOW_LABEL,
render: (count: number, { hostName }) => (
<EuiHealth data-test-subj="hostSeverityAlertsTable-low" color={SEVERITY_COLOR.low}>
<EuiLink disabled={count === 0} onClick={() => handleClick({ hostName, severity: 'low' })}>
<FormattedCount count={count} />
</EuiLink>
<SecurityCellActions
field={{
name: 'host.name',
value: hostName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'low' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ hostName, severity: 'low' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},

View file

@ -21,6 +21,8 @@ import {
import { FormattedRelative } from '@kbn/i18n-react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
import { CellActionsMode } from '@kbn/cell-actions';
import { SecurityCellActionsTrigger } from '../../../../actions/constants';
import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters';
import { HeaderSection } from '../../../../common/components/header_section';
@ -36,6 +38,7 @@ import { HoverVisibilityContainer } from '../../../../common/components/hover_vi
import { BUTTON_CLASS as INSPECT_BUTTON_CLASS } from '../../../../common/components/inspect';
import { LastUpdatedAt } from '../../../../common/components/last_updated_at';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { SecurityCellActions } from '../../../../common/components/cell_actions';
export interface RuleAlertsTableProps {
signalIndexName: string | null;
@ -95,13 +98,27 @@ export const getTableColumns: GetTableColumns = ({
name: i18n.RULE_ALERTS_COLUMN_ALERT_COUNT,
'data-test-subj': 'severityRuleAlertsTable-alertCount',
render: (alertCount: number, { name }) => (
<EuiLink
data-test-subj="severityRuleAlertsTable-alertCountLink"
disabled={alertCount === 0}
onClick={() => openRuleInAlertsPage(name)}
<SecurityCellActions
field={{
name: ALERT_RULE_NAME,
value: name,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [{ field: 'kibana.alert.workflow_status', value: 'open' }],
}}
>
<FormattedCount count={alertCount} />
</EuiLink>
<EuiLink
data-test-subj="severityRuleAlertsTable-alertCountLink"
disabled={alertCount === 0}
onClick={() => openRuleInAlertsPage(name)}
>
<FormattedCount count={alertCount} />
</EuiLink>
</SecurityCellActions>
),
},
{

View file

@ -20,6 +20,8 @@ import {
} from '@elastic/eui';
import { ALERT_SEVERITY } from '@kbn/rule-data-utils';
import { CellActionsMode } from '@kbn/cell-actions';
import { SecurityCellActionsTrigger } from '../../../../actions/constants';
import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { HeaderSection } from '../../../../common/components/header_section';
@ -32,6 +34,7 @@ import * as i18n from '../translations';
import { ITEMS_PER_PAGE, SEVERITY_COLOR } from '../utils';
import type { UserAlertsItem } from './use_user_alerts_items';
import { useUserAlertsItems } from './use_user_alerts_items';
import { SecurityCellActions } from '../../../../common/components/cell_actions';
interface UserAlertsTableProps {
signalIndexName: string | null;
@ -142,13 +145,27 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.ALERTS_TEXT,
'data-test-subj': 'userSeverityAlertsTable-totalAlerts',
render: (totalAlerts: number, { userName }) => (
<EuiLink
data-test-subj="userSeverityAlertsTable-totalAlertsLink"
disabled={totalAlerts === 0}
onClick={() => handleClick({ userName })}
<SecurityCellActions
field={{
name: 'user.name',
value: userName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [{ field: 'kibana.alert.workflow_status', value: 'open' }],
}}
>
<FormattedCount count={totalAlerts} />
</EuiLink>
<EuiLink
data-test-subj="userSeverityAlertsTable-totalAlertsLink"
disabled={totalAlerts === 0}
onClick={() => handleClick({ userName })}
>
<FormattedCount count={totalAlerts} />
</EuiLink>
</SecurityCellActions>
),
},
{
@ -156,13 +173,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_CRITICAL_LABEL,
render: (count: number, { userName }) => (
<EuiHealth data-test-subj="userSeverityAlertsTable-critical" color={SEVERITY_COLOR.critical}>
<EuiLink
data-test-subj="userSeverityAlertsTable-criticalLink"
disabled={count === 0}
onClick={() => handleClick({ userName, severity: 'critical' })}
<SecurityCellActions
field={{
name: 'user.name',
value: userName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'critical' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<FormattedCount count={count} />
</EuiLink>
<EuiLink
data-test-subj="userSeverityAlertsTable-criticalLink"
disabled={count === 0}
onClick={() => handleClick({ userName, severity: 'critical' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},
@ -171,9 +205,29 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_HIGH_LABEL,
render: (count: number, { userName }) => (
<EuiHealth data-test-subj="userSeverityAlertsTable-high" color={SEVERITY_COLOR.high}>
<EuiLink disabled={count === 0} onClick={() => handleClick({ userName, severity: 'high' })}>
<FormattedCount count={count} />
</EuiLink>
<SecurityCellActions
field={{
name: 'user.name',
value: userName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'high' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ userName, severity: 'high' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},
@ -182,12 +236,29 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_MEDIUM_LABEL,
render: (count: number, { userName }) => (
<EuiHealth data-test-subj="userSeverityAlertsTable-medium" color={SEVERITY_COLOR.medium}>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ userName, severity: 'medium' })}
<SecurityCellActions
field={{
name: 'user.name',
value: userName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'medium' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<FormattedCount count={count} />
</EuiLink>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ userName, severity: 'medium' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},
@ -196,9 +267,29 @@ const getTableColumns: GetTableColumns = (handleClick) => [
name: i18n.STATUS_LOW_LABEL,
render: (count: number, { userName }) => (
<EuiHealth data-test-subj="userSeverityAlertsTable-low" color={SEVERITY_COLOR.low}>
<EuiLink disabled={count === 0} onClick={() => handleClick({ userName, severity: 'low' })}>
<FormattedCount count={count} />
</EuiLink>
<SecurityCellActions
field={{
name: 'user.name',
value: userName,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [
{ field: 'kibana.alert.severity', value: 'low' },
{ field: 'kibana.alert.workflow_status', value: 'open' },
],
}}
>
<EuiLink
disabled={count === 0}
onClick={() => handleClick({ userName, severity: 'low' })}
>
<FormattedCount count={count} />
</EuiLink>
</SecurityCellActions>
</EuiHealth>
),
},

View file

@ -140,17 +140,31 @@ export const getRiskScoreColumns = (
truncateText: false,
mobileOptions: { show: true },
render: (alertCount: number, risk) => (
<EuiLink
data-test-subj="risk-score-alerts"
disabled={alertCount === 0}
onClick={() =>
openEntityOnAlertsPage(
riskEntity === RiskScoreEntity.host ? risk.host.name : risk.user.name
)
}
<SecurityCellActions
field={{
name: riskEntity === RiskScoreEntity.host ? 'host.name' : 'user.name',
value: riskEntity === RiskScoreEntity.host ? risk.host.name : risk.user.name,
type: 'keyword',
aggregatable: true,
}}
mode={CellActionsMode.HOVER_RIGHT}
triggerId={SecurityCellActionsTrigger.ALERTS_COUNT}
metadata={{
andFilters: [{ field: 'kibana.alert.workflow_status', value: 'open' }],
}}
>
<FormattedCount count={alertCount} />
</EuiLink>
<EuiLink
data-test-subj="risk-score-alerts"
disabled={alertCount === 0}
onClick={() =>
openEntityOnAlertsPage(
riskEntity === RiskScoreEntity.host ? risk.host.name : risk.user.name
)
}
>
<FormattedCount count={alertCount} />
</EuiLink>
</SecurityCellActions>
),
},
];

View file

@ -5,11 +5,10 @@
* 2.0.
*/
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { EntityAnalyticsRiskScores } from '.';
import type { UserRiskScore } from '../../../../../common/search_strategy';
import { RiskScoreEntity, RiskSeverity } from '../../../../../common/search_strategy';
import type { SeverityCount } from '../../../../explore/components/risk_score/severity/types';
import { useRiskScore, useRiskScoreKpi } from '../../../../explore/containers/risk_score';
@ -146,17 +145,17 @@ describe.each([RiskScoreEntity.host, RiskScoreEntity.user])(
expect(queryByTestId('entity_analytics_content')).not.toBeInTheDocument();
});
it('renders alerts count', () => {
it('renders alerts count', async () => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() });
mockUseRiskScoreKpi.mockReturnValue({
severityCount: mockSeverityCount,
loading: false,
});
const alertsCount = 999;
const data: UserRiskScore[] = [
const data = [
{
'@timestamp': '1234567899',
user: {
[riskEntity]: {
name: 'testUsermame',
risk: {
rule_risks: [],
@ -176,10 +175,12 @@ describe.each([RiskScoreEntity.host, RiskScoreEntity.user])(
</TestProviders>
);
expect(queryByTestId('risk-score-alerts')).toHaveTextContent(alertsCount.toString());
await waitFor(() => {
expect(queryByTestId('risk-score-alerts')).toHaveTextContent(alertsCount.toString());
});
});
it('navigates to alerts page with filters when alerts count is clicked', () => {
it('navigates to alerts page with filters when alerts count is clicked', async () => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() });
mockUseRiskScoreKpi.mockReturnValue({
severityCount: mockSeverityCount,
@ -211,13 +212,15 @@ describe.each([RiskScoreEntity.host, RiskScoreEntity.user])(
fireEvent.click(getByTestId('risk-score-alerts'));
expect(mockOpenAlertsPageWithFilters.mock.calls[0][0]).toEqual([
{
title: riskEntity === RiskScoreEntity.host ? 'Host' : 'User',
fieldName: riskEntity === RiskScoreEntity.host ? 'host.name' : 'user.name',
selectedOptions: [name],
},
]);
await waitFor(() => {
expect(mockOpenAlertsPageWithFilters.mock.calls[0][0]).toEqual([
{
title: riskEntity === RiskScoreEntity.host ? 'Host' : 'User',
fieldName: riskEntity === RiskScoreEntity.host ? 'host.name' : 'user.name',
selectedOptions: [name],
},
]);
});
});
}
);

View file

@ -317,7 +317,7 @@ export const MoreContainer = React.memo<MoreContainerProps>(
<EuiFlexItem key={id}>
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}