[8.6] [Session view] file/network alerts UX enhancements (#144768)

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.
[mplement UX enhancements to process alerts for file/network
alerts.](https://github.com/orgs/elastic/projects/828/views/31)
- UX enhancements to show different alerts categories(process, file, and
network)
- Each alert category has an associated icon  
- Group alerts show danger icons
- Filter menu will be shown when there at least two alert categories 
- Click an alert category from the filter menu will filter the alerts
and update alert count status message.

Network Alerts
<img width="1703" alt="image"
src="https://user-images.githubusercontent.com/17135495/200449915-6250aa0d-6e81-481f-9733-5f948b87b378.png">
File and Process Alerts
<img width="1712" alt="image"
src="https://user-images.githubusercontent.com/17135495/200452712-f6714b80-22a9-48fe-9f74-406e73482fc0.png">
Group View
<img width="1410" alt="image"
src="https://user-images.githubusercontent.com/17135495/200453470-eb8bb92f-773d-4bca-b20d-ea73f4f8b4f8.png">
List View
<img width="370" alt="image"
src="https://user-images.githubusercontent.com/17135495/200453547-3170799e-23a0-462a-9e38-c6a9fb6ba748.png">


### Checklist

Delete any items that are not applicable to this PR.
- [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


### For maintainers

- [X] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Lola 2022-11-10 16:03:41 -05:00 committed by GitHub
parent fc11d73e89
commit b1bb5917de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1440 additions and 51 deletions

View file

@ -67,3 +67,10 @@ export const TTY_LINE_SPLITTER_REGEX = /(\r?\n|\r\n?|\x1b\[\d+;\d*[Hf]?)/gi;
// when showing the count of alerts in details panel tab, if the number
// exceeds ALERT_COUNT_THRESHOLD we put a + next to it, e.g 999+
export const ALERT_COUNT_THRESHOLD = 999;
export const ALERT_ICONS: { [key: string]: string } = {
process: 'gear',
file: 'document',
network: 'globe',
};
export const DEFAULT_ALERT_FILTER_VALUE = 'all';
export const ALERT = 'alert';

View file

@ -13,6 +13,8 @@ import {
EventAction,
EventKind,
ProcessMap,
AlertTypeCount,
ProcessEventAlertCategory,
} from '../../types/process_tree';
export const mockEvents: ProcessEvent[] = [
@ -158,7 +160,7 @@ export const mockEvents: ProcessEvent[] = [
},
event: {
action: EventAction.fork,
category: 'process',
category: ['process'],
kind: EventKind.event,
id: '1',
},
@ -316,7 +318,7 @@ export const mockEvents: ProcessEvent[] = [
},
event: {
action: EventAction.exec,
category: 'process',
category: ['process'],
kind: EventKind.event,
id: '2',
},
@ -459,7 +461,7 @@ export const mockEvents: ProcessEvent[] = [
},
event: {
action: EventAction.end,
category: 'process',
category: ['process'],
kind: EventKind.event,
id: '3',
},
@ -622,7 +624,7 @@ export const mockEvents: ProcessEvent[] = [
},
event: {
action: EventAction.end,
category: 'process',
category: ['process'],
kind: EventKind.event,
id: '4',
},
@ -645,6 +647,12 @@ export const mockEvents: ProcessEvent[] = [
},
] as ProcessEvent[];
export const mockAlertTypeCounts: AlertTypeCount[] = [
{ category: ProcessEventAlertCategory.file, count: 0 },
{ category: ProcessEventAlertCategory.network, count: 2 },
{ category: ProcessEventAlertCategory.process, count: 1 },
];
export const mockAlerts: ProcessEvent[] = [
{
kibana: {
@ -797,7 +805,7 @@ export const mockAlerts: ProcessEvent[] = [
},
},
},
name: '',
name: 'vi',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
@ -811,7 +819,7 @@ export const mockAlerts: ProcessEvent[] = [
},
event: {
action: EventAction.exec,
category: 'process',
category: ['process'],
kind: EventKind.signal,
id: '5',
},
@ -998,7 +1006,7 @@ export const mockAlerts: ProcessEvent[] = [
},
event: {
action: EventAction.end,
category: 'process',
category: ['process'],
kind: EventKind.signal,
id: '6',
},
@ -1021,6 +1029,402 @@ export const mockAlerts: ProcessEvent[] = [
},
];
export const mockFileAlert = {
kibana: {
alert: {
rule: {
category: 'Custom Query Rule',
consumer: 'siem',
name: 'File telemetry',
uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf',
enabled: true,
description: 'File telemetry',
risk_score: 21,
severity: 'low',
query: "process.executable: '/usr/bin/vi'",
},
status: 'active',
workflow_status: 'open',
reason: 'process event created low alert File telemetry.',
original_time: '2021-11-23T15:25:05.202Z',
original_event: {
action: 'exit',
},
uuid: '2873463965b70d37ab9b2b3a90ac5a03b88e76e94ad33568285cadcefc38ed75',
},
},
'@timestamp': '2021-11-23T15:26:34.860Z',
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
process: {
pid: 3535,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
exit_code: 137,
executable: '/usr/bin/vi',
command_line: 'bash',
interactive: true,
entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726',
parent: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
session_leader: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
entry_leader: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
group_leader: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
file: {
path: '/home/jon/new_file.txt',
extension: 'txt',
name: 'new_file.txt',
},
event: {
action: EventAction.exec,
category: ['file'],
kind: EventKind.signal,
id: '6',
},
host: {
architecture: 'x86_64',
hostname: 'james-fleet-714-2',
id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d',
ip: ['127.0.0.1', '::1', '10.132.0.50', 'fe80::7d39:3147:4d9a:f809'],
mac: ['42:01:0a:84:00:32'],
name: 'james-fleet-714-2',
os: {
family: 'centos',
full: 'CentOS 7.9.2009',
kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021',
name: 'Linux',
platform: 'centos',
version: '7.9.2009',
},
},
};
export const mockNetworkAlert = {
kibana: {
alert: {
rule: {
category: 'Custom Query Rule',
consumer: 'siem',
name: 'Network telemetry',
uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf',
enabled: true,
description: 'Network telemetry',
risk_score: 21,
severity: 'low',
query: "process.executable: '/usr/bin/vi'",
},
status: 'active',
workflow_status: 'open',
reason: 'process event created low alert File telemetry.',
original_time: '2021-11-23T15:25:05.202Z',
original_event: {
action: 'exit',
},
uuid: '2873463965b70d37ab9b2b3a90ac5a03b88e76e94ad33568285cadcefc38ed75',
},
},
'@timestamp': '2021-11-23T15:26:34.860Z',
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
process: {
pid: 3535,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
exit_code: 137,
executable: '/usr/bin/vi',
command_line: 'bash',
interactive: true,
entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726',
parent: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
session_leader: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
entry_leader: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
group_leader: {
pid: 2442,
user: {
name: 'vagrant',
id: '1000',
},
group: {
id: '1000',
name: 'vagrant',
},
executable: '/usr/bin/bash',
command_line: 'bash',
interactive: true,
entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc',
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
name: '',
args_count: 2,
args: ['vi', 'cmd/config.ini'],
working_directory: '/home/vagrant',
start: '2021-11-23T15:26:34.860Z',
tty: {
char_device: {
major: 8,
minor: 1,
},
},
},
network: {
transport: 'TCP',
protocol: 'http',
type: 'IP4',
},
destination: {
address: '127.0.0.1',
ip: '127.0.0.1',
port: 2222,
},
source: {
address: '128.32.0.1',
ip: '128.32.0.1',
port: 1111,
},
event: {
action: EventAction.exec,
category: ['network'],
kind: EventKind.signal,
id: '6',
},
host: {
architecture: 'x86_64',
hostname: 'james-fleet-714-2',
id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d',
ip: ['127.0.0.1', '::1', '10.132.0.50', 'fe80::7d39:3147:4d9a:f809'],
mac: ['42:01:0a:84:00:32'],
name: 'james-fleet-714-2',
os: {
family: 'centos',
full: 'CentOS 7.9.2009',
kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021',
name: 'Linux',
platform: 'centos',
version: '7.9.2009',
},
},
};
export const mockData: ProcessEventsPage[] = [
{
events: mockEvents,
@ -1278,7 +1682,7 @@ export const childProcessMock: Process = {
'@timestamp': '2021-11-23T15:25:05.210Z',
event: {
kind: EventKind.event,
category: 'process',
category: ['process'],
action: EventAction.exec,
id: '1',
},
@ -1364,7 +1768,7 @@ export const processMock: Process = {
'@timestamp': '2021-11-23T15:25:04.210Z',
event: {
kind: EventKind.event,
category: 'process',
category: ['process'],
action: EventAction.exec,
id: '2',
},

View file

@ -0,0 +1,19 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ALERT_TYPE_TOOLTIP_PROCESS = i18n.translate('xpack.sessionView.processTooltip', {
defaultMessage: 'Process alert',
});
export const ALERT_TYPE_TOOLTIP_NETWORK = i18n.translate('xpack.sessionView.networkTooltip', {
defaultMessage: 'Network alert',
});
export const ALERT_TYPE_TOOLTIP_FILE = i18n.translate('xpack.sessionView.fileTooltip', {
defaultMessage: 'File alert',
});

View file

@ -12,6 +12,19 @@ export interface AlertStatusEventEntityIdMap {
};
}
export const enum ProcessEventAlertCategory {
all = 'all',
file = 'file',
network = 'network',
process = 'process',
}
export interface AlertTypeCount {
category: ProcessEventAlertCategory;
count: number;
}
export type DefaultAlertFilterType = 'all';
export const enum EventKind {
event = 'event',
signal = 'signal',
@ -156,14 +169,34 @@ export interface ProcessEventAlert {
rule?: ProcessEventAlertRule;
}
export interface ProcessEventIPAddress {
address?: string;
ip?: string;
port?: number;
}
export interface ProcessEventNetwork {
type?: string;
transport?: string;
protocol?: string;
}
export interface ProcessEvent {
'@timestamp'?: string;
event?: {
kind?: EventKind;
category?: string;
category?: string[];
action?: EventAction;
id?: string;
};
file?: {
extension?: string;
path?: string;
name?: string;
};
network?: ProcessEventNetwork;
destination?: ProcessEventIPAddress;
source?: ProcessEventIPAddress;
user?: User;
group?: Group;
host?: ProcessEventHost;

View file

@ -0,0 +1,23 @@
/*
* 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 { ProcessEventAlertCategory } from '../types/process_tree';
import { getAlertIconTooltipContent } from './alert_icon_tooltip_content';
describe('getAlertTypeTooltipContent(category)', () => {
it('should display `File alert` for tooltip content', () => {
expect(getAlertIconTooltipContent(ProcessEventAlertCategory.file)).toEqual('File alert');
});
it('should display `Process alert` for tooltip content', () => {
expect(getAlertIconTooltipContent(ProcessEventAlertCategory.process)).toEqual('Process alert');
});
it('should display `Network alert` for tooltip content', () => {
expect(getAlertIconTooltipContent(ProcessEventAlertCategory.network)).toEqual('Network alert');
});
});

View file

@ -0,0 +1,24 @@
/*
* 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 { ProcessEventAlertCategory } from '../types/process_tree';
import * as i18n from '../translations';
export const getAlertIconTooltipContent = (processEventAlertCategory: string) => {
let tooltipContent = '';
switch (processEventAlertCategory) {
case ProcessEventAlertCategory.file:
tooltipContent = i18n.ALERT_TYPE_TOOLTIP_FILE;
break;
case ProcessEventAlertCategory.network:
tooltipContent = i18n.ALERT_TYPE_TOOLTIP_NETWORK;
break;
default:
tooltipContent = i18n.ALERT_TYPE_TOOLTIP_PROCESS;
}
return tooltipContent;
};

View file

@ -16,15 +16,19 @@ import {
EuiPanel,
EuiHorizontalRule,
formatDate,
EuiToolTip,
} from '@elastic/eui';
import { ProcessEvent } from '../../../common/types/process_tree';
import { getAlertIconTooltipContent } from '../../../common/utils/alert_icon_tooltip_content';
import { ALERT_ICONS } from '../../../common/constants';
import { ProcessEvent, ProcessEventAlertCategory } from '../../../common/types/process_tree';
import { useStyles } from './styles';
import { DetailPanelAlertActions } from '../detail_panel_alert_actions';
import { dataOrDash } from '../../utils/data_or_dash';
import { useDateFormat } from '../../hooks';
import { getAlertCategoryDisplayText } from '../../utils/alert_category_display_text';
export const ALERT_LIST_ITEM_TEST_ID = 'sessionView:detailPanelAlertListItem';
export const ALERT_LIST_ITEM_ARGS_TEST_ID = 'sessionView:detailPanelAlertListItemArgs';
export const ALERT_LIST_ITEM_FILE_PATH_TEST_ID = 'sessionView:detailPanelAlertListItemFilePath';
export const ALERT_LIST_ITEM_TIMESTAMP_TEST_ID = 'sessionView:detailPanelAlertListItemTimestamp';
interface DetailPanelAlertsListItemDeps {
@ -57,9 +61,16 @@ export const DetailPanelAlertListItem = ({
const uuid = rule?.uuid || '';
const name = rule?.name || '';
const { args } = event.process ?? {};
const { args, name: processName } = event.process ?? {};
const { event: processEvent } = event;
const forceState = !isInvestigated ? 'open' : undefined;
const category = processEvent?.category?.[0];
const processEventAlertCategory = category ?? ProcessEventAlertCategory.process;
const alertCategoryDetailDisplayText =
category !== ProcessEventAlertCategory.process
? `${dataOrDash(processName)} ${getAlertCategoryDisplayText(event, category)}`
: dataOrDash(args?.join(' '));
const alertIconTooltipContent = getAlertIconTooltipContent(processEventAlertCategory);
return minimal ? (
<div data-test-subj={ALERT_LIST_ITEM_TEST_ID} css={styles.firstAlertPad}>
@ -86,7 +97,9 @@ export const DetailPanelAlertListItem = ({
hasShadow={false}
borderRadius="m"
>
<EuiText size="xs">{dataOrDash(args?.join(' '))}</EuiText>
<EuiText data-test-subj={ALERT_LIST_ITEM_ARGS_TEST_ID} size="xs">
{alertCategoryDetailDisplayText}
</EuiText>
</EuiPanel>
<EuiHorizontalRule css={styles.minimalHR} margin="m" size="full" />
</div>
@ -98,7 +111,14 @@ export const DetailPanelAlertListItem = ({
buttonContent={
<EuiText css={styles.alertTitleContainer} size="s">
<p css={styles.alertTitle}>
<EuiIcon color="danger" type="alert" css={styles.alertIcon} />
<EuiToolTip position="top" content={alertIconTooltipContent}>
<EuiIcon
color="danger"
type={ALERT_ICONS[processEventAlertCategory]}
css={styles.alertIcon}
/>
</EuiToolTip>
{dataOrDash(name)}
</p>
</EuiText>
@ -126,7 +146,7 @@ export const DetailPanelAlertListItem = ({
borderRadius="m"
>
<EuiText data-test-subj={ALERT_LIST_ITEM_ARGS_TEST_ID} size="xs">
{dataOrDash(args?.join(' '))}
{alertCategoryDetailDisplayText}
</EuiText>
</EuiPanel>
{isInvestigated && (

View file

@ -34,17 +34,26 @@ Object {
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
color="danger"
data-euiicon-type="alert"
/>
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
>
<span
color="danger"
data-euiicon-type="gear"
/>
</span>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText emotion-EuiText"
css="[object Object]"
>
cmd test alert
<div
class="euiText emotion-EuiText"
data-test-subj="sessionView:sessionViewAlertDetailRuleName-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38-text"
>
cmd test alert
</div>
</div>
</div>
<div
@ -119,17 +128,26 @@ Object {
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
color="danger"
data-euiicon-type="alert"
/>
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
>
<span
color="danger"
data-euiicon-type="gear"
/>
</span>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText emotion-EuiText"
css="[object Object]"
>
cmd test alert
<div
class="euiText emotion-EuiText"
data-test-subj="sessionView:sessionViewAlertDetailRuleName-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38-text"
>
cmd test alert
</div>
</div>
</div>
<div

View file

@ -6,7 +6,11 @@
*/
import React from 'react';
import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock';
import {
mockAlerts,
mockFileAlert,
mockNetworkAlert,
} from '../../../common/mocks/constants/session_view_process.mock';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import { ProcessTreeAlertDeps, ProcessTreeAlert } from '.';
@ -63,6 +67,43 @@ describe('ProcessTreeAlerts component', () => {
expect(selectAlert).toHaveBeenCalledTimes(1);
});
it('should get alert rule name text content when alert category is process', async () => {
renderResult = mockedContext.render(<ProcessTreeAlert {...props} />);
const alertText = renderResult.queryByTestId(
`sessionView:sessionViewAlertDetailRuleName-${mockAlert.kibana?.alert?.uuid}-text`
);
const categoryDetailPanel = renderResult.queryByTestId(
`sessionView:sessionViewAlertDetail-${mockFileAlert.kibana?.alert?.uuid}-text`
);
expect(alertText).toBeTruthy();
expect(alertText).toHaveTextContent('cmd test alert');
expect(categoryDetailPanel).toBeNull();
});
it('should get file path for text content when alert category is file', async () => {
renderResult = mockedContext.render(<ProcessTreeAlert {...props} alert={mockFileAlert} />);
const fileAlertText = renderResult.queryByTestId(
`sessionView:sessionViewAlertDetail-${mockFileAlert.kibana?.alert?.uuid}-text`
);
expect(fileAlertText).toBeTruthy();
expect(fileAlertText).toHaveTextContent('/home/jon/new_file.txt');
});
it('should get network display text for text content when alert category is network', async () => {
renderResult = mockedContext.render(<ProcessTreeAlert {...props} alert={mockNetworkAlert} />);
const networkAlertText = renderResult.queryByTestId(
`sessionView:sessionViewAlertDetail-${mockNetworkAlert.kibana?.alert?.uuid}-text`
);
expect(networkAlertText).toBeTruthy();
expect(networkAlertText).toHaveTextContent(
`${mockNetworkAlert?.destination?.address}:${mockNetworkAlert?.destination?.port}`
);
});
it('should execute onShowAlertDetails callback when clicking on expand button', async () => {
const onShowAlertDetails = jest.fn();
renderResult = mockedContext.render(

View file

@ -5,13 +5,28 @@
* 2.0.
*/
import React, { useEffect, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui';
import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree';
import React, { useEffect, useCallback, useMemo } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiBadge,
EuiIcon,
EuiText,
EuiButtonIcon,
EuiToolTip,
EuiPanel,
} from '@elastic/eui';
import { ALERT_ICONS } from '../../../common/constants';
import {
ProcessEvent,
ProcessEventAlert,
ProcessEventAlertCategory,
} from '../../../common/types/process_tree';
import { dataOrDash } from '../../utils/data_or_dash';
import { getBadgeColorFromAlertStatus } from './helpers';
import { useStyles } from './styles';
import { getAlertCategoryDisplayText } from '../../utils/alert_category_display_text';
import { getAlertIconTooltipContent } from '../../../common/utils/alert_icon_tooltip_content';
export interface ProcessTreeAlertDeps {
alert: ProcessEvent;
isInvestigated: boolean;
@ -32,7 +47,13 @@ export const ProcessTreeAlert = ({
const styles = useStyles({ isInvestigated, isSelected });
const { event } = alert;
const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {};
const category = event?.category?.[0];
const alertIconType = useMemo(() => {
if (category && category in ALERT_ICONS) return ALERT_ICONS[category];
return ALERT_ICONS.process;
}, [category]);
useEffect(() => {
if (isInvestigated && uuid) {
@ -55,8 +76,10 @@ export const ProcessTreeAlert = ({
if (!(alert.kibana && rule)) {
return null;
}
const { name } = rule;
const processEventAlertCategory = category ?? ProcessEventAlertCategory.process;
const alertCategoryDetailDisplayText = getAlertCategoryDisplayText(alert, category);
const alertIconTooltipContent = getAlertIconTooltipContent(processEventAlertCategory);
return (
<div key={uuid} css={styles.alert} data-id={uuid}>
@ -76,12 +99,37 @@ export const ProcessTreeAlert = ({
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="alert" color="danger" />
<EuiToolTip position="top" content={alertIconTooltipContent}>
<EuiIcon type={alertIconType} color="danger" />
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText css={styles.alertName} size="s">
{dataOrDash(name)}
</EuiText>
<div css={styles.processAlertDisplayContainer}>
<EuiText
data-test-subj={`sessionView:sessionViewAlertDetailRuleName-${uuid}-text`}
css={styles.alertName}
size="s"
>
{dataOrDash(name)}
</EuiText>
{alertCategoryDetailDisplayText && (
<EuiPanel
css={styles.processPanel}
color="subdued"
hasBorder
hasShadow={false}
borderRadius="m"
>
<EuiText
data-test-subj={`sessionView:sessionViewAlertDetail-${uuid}-text`}
css={styles.alertName}
size="s"
>
<span className="alertCategoryDetailText">{alertCategoryDetailDisplayText}</span>
</EuiText>
</EuiPanel>
)}
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color={getBadgeColorFromAlertStatus(status)} css={styles.alertStatus}>

View file

@ -19,7 +19,7 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
const { euiTheme, euiVars } = useEuiTheme();
const cached = useMemo(() => {
const { size, colors, font } = euiTheme;
const { size, colors, font, border } = euiTheme;
const getHighlightColors = () => {
let bgColor = 'none';
@ -69,19 +69,36 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
textTransform: 'capitalize',
};
const processAlertDisplayContainer: CSSObject = {
display: 'flex',
alignItems: 'center',
};
const alertName: CSSObject = {
color: colors.title,
'& .alertCategoryDetailText': {
fontSize: size.m,
},
};
const actionBadge: CSSObject = {
textTransform: 'capitalize',
};
const processPanel: CSSObject = {
marginLeft: '8px',
border: `${border.width.thin} solid ${colors.lightShade}`,
fontFamily: font.familyCode,
padding: `${size.xs} ${size.s}`,
};
return {
alert,
alertStatus,
alertName,
actionBadge,
processPanel,
processAlertDisplayContainer,
};
}, [euiTheme, isInvestigated, isSelected, euiVars]);

View file

@ -6,7 +6,10 @@
*/
import React from 'react';
import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock';
import {
mockAlerts,
mockAlertTypeCounts,
} from '../../../common/mocks/constants/session_view_process.mock';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import { ProcessTreeAlertsDeps, ProcessTreeAlerts } from '.';
@ -14,8 +17,9 @@ describe('ProcessTreeAlerts component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
const props: ProcessTreeAlertsDeps = {
const processTreeAlertsProps: ProcessTreeAlertsDeps = {
alerts: mockAlerts,
alertTypeCounts: mockAlertTypeCounts,
onAlertSelected: jest.fn(),
onShowAlertDetails: jest.fn(),
};
@ -26,13 +30,15 @@ describe('ProcessTreeAlerts component', () => {
describe('When ProcessTreeAlerts is mounted', () => {
it('should return null if no alerts', async () => {
renderResult = mockedContext.render(<ProcessTreeAlerts {...props} alerts={[]} />);
renderResult = mockedContext.render(
<ProcessTreeAlerts {...processTreeAlertsProps} alerts={[]} />
);
expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull();
});
it('should return an array of alert details', async () => {
renderResult = mockedContext.render(<ProcessTreeAlerts {...props} />);
renderResult = mockedContext.render(<ProcessTreeAlerts {...processTreeAlertsProps} />);
expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy();
mockAlerts.forEach((alert) => {
@ -50,7 +56,7 @@ describe('ProcessTreeAlerts component', () => {
it('should execute onAlertSelected when clicking on an alert', async () => {
const mockFn = jest.fn();
renderResult = mockedContext.render(
<ProcessTreeAlerts {...props} onAlertSelected={mockFn} />
<ProcessTreeAlerts {...processTreeAlertsProps} onAlertSelected={mockFn} />
);
expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy();
@ -63,4 +69,18 @@ describe('ProcessTreeAlerts component', () => {
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
describe('ProcessTreeAlertsFilter Render', () => {
it('should return ProcessTreeAlertsFilter component when alerts exist', async () => {
renderResult = mockedContext.render(<ProcessTreeAlerts {...processTreeAlertsProps} />);
expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetailsFilter')).toBeTruthy();
});
it('should not return ProcessTreeAlertsFilter when no alerts exist', async () => {
renderResult = mockedContext.render(
<ProcessTreeAlerts {...processTreeAlertsProps} alerts={[]} />
);
expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetailsFilter')).toBeNull();
});
});
});

View file

@ -5,16 +5,24 @@
* 2.0.
*/
import React, { useState, useEffect, useRef, MouseEvent, useCallback } from 'react';
import React, { useState, useEffect, useRef, MouseEvent, useCallback, useMemo } from 'react';
import { useStyles } from './styles';
import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree';
import {
ProcessEventAlertCategory,
DefaultAlertFilterType,
ProcessEvent,
ProcessEventAlert,
AlertTypeCount,
} from '../../../common/types/process_tree';
import { ProcessTreeAlert } from '../process_tree_alert';
import { MOUSE_EVENT_PLACEHOLDER } from '../../../common/constants';
import { DEFAULT_ALERT_FILTER_VALUE, MOUSE_EVENT_PLACEHOLDER } from '../../../common/constants';
import { ProcessTreeAlertsFilter } from '../process_tree_alerts_filter';
export interface ProcessTreeAlertsDeps {
alerts: ProcessEvent[];
investigatedAlertId?: string;
isProcessSelected?: boolean;
alertTypeCounts: AlertTypeCount[];
onAlertSelected: (e: MouseEvent) => void;
onShowAlertDetails: (alertUuid: string) => void;
}
@ -23,10 +31,14 @@ export function ProcessTreeAlerts({
alerts,
investigatedAlertId,
isProcessSelected = false,
alertTypeCounts,
onAlertSelected,
onShowAlertDetails,
}: ProcessTreeAlertsDeps) {
const [selectedAlert, setSelectedAlert] = useState<ProcessEventAlert | null>(null);
const [selectedProcessEventAlertCategory, setSelectedProcessEventAlertCategory] = useState<
ProcessEventAlertCategory | DefaultAlertFilterType
>(DEFAULT_ALERT_FILTER_VALUE);
const styles = useStyles();
useEffect(() => {
@ -70,6 +82,24 @@ export function ProcessTreeAlerts({
[onAlertSelected]
);
const handleProcessEventAlertCategorySelected = useCallback((eventCategory) => {
if (ProcessEventAlertCategory.hasOwnProperty(eventCategory)) {
setSelectedProcessEventAlertCategory(eventCategory);
} else {
setSelectedProcessEventAlertCategory(ProcessEventAlertCategory.all);
}
}, []);
const filteredProcessEventAlerts = useMemo(() => {
return alerts?.filter((processEventAlert: ProcessEvent) => {
const processEventAlertCategory = processEventAlert.event?.category?.[0];
if (selectedProcessEventAlertCategory === DEFAULT_ALERT_FILTER_VALUE) {
return true;
}
return processEventAlertCategory === selectedProcessEventAlertCategory;
});
}, [selectedProcessEventAlertCategory, alerts]);
if (alerts.length === 0) {
return null;
}
@ -80,7 +110,14 @@ export function ProcessTreeAlerts({
css={styles.container}
data-test-subj="sessionView:sessionViewAlertDetails"
>
{alerts.map((alert: ProcessEvent, idx: number) => {
<ProcessTreeAlertsFilter
totalAlertsCount={alerts.length}
alertTypeCounts={alertTypeCounts}
filteredAlertsCount={filteredProcessEventAlerts.length}
onAlertEventCategorySelected={handleProcessEventAlertCategorySelected}
/>
{filteredProcessEventAlerts.map((alert: ProcessEvent, idx: number) => {
const alertUuid = alert.kibana?.alert?.uuid || null;
return (

View file

@ -0,0 +1,290 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mockAlertTypeCounts } from '../../../common/mocks/constants/session_view_process.mock';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import { ProcessTreeAlertsFilter, ProcessTreeAlertsFilterDeps } from '.';
import userEvent from '@testing-library/user-event';
import { DEFAULT_ALERT_FILTER_VALUE } from '../../../common/constants';
describe('ProcessTreeAlertsFiltersFilter component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
const props: ProcessTreeAlertsFilterDeps = {
totalAlertsCount: 3,
alertTypeCounts: mockAlertTypeCounts,
filteredAlertsCount: 2,
onAlertEventCategorySelected: jest.fn(),
};
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
});
describe('When ProcessTreeAlertsFiltersFilter is mounted', () => {
it('should filter alerts count of out total alerts count when filtered alerts count and total alerts count are not equal', async () => {
renderResult = mockedContext.render(<ProcessTreeAlertsFilter {...props} />);
const filterCountStatus = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterStatus'
);
expect(filterCountStatus).toBeTruthy();
expect(filterCountStatus).toHaveTextContent('Showing 2 of 3 alerts');
});
it('should show only total alert counts when filtered and total count are equal', async () => {
renderResult = mockedContext.render(
<ProcessTreeAlertsFilter {...props} filteredAlertsCount={3} />
);
const filterCountStatus = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterStatus'
);
expect(filterCountStatus).toHaveTextContent('Showing 3 alerts');
expect(filterCountStatus).not.toHaveTextContent('Showing 2 of 3 alerts');
expect(filterCountStatus).toBeTruthy();
});
it('should call onAlertEventCategorySelected with alert category when filter item is clicked ', () => {
const mockAlertEventCategorySelectedEvent = jest.fn();
renderResult = mockedContext.render(
<ProcessTreeAlertsFilter
{...props}
onAlertEventCategorySelected={mockAlertEventCategorySelectedEvent}
/>
);
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-network').click();
expect(mockAlertEventCategorySelectedEvent).toHaveBeenCalledTimes(1);
expect(mockAlertEventCategorySelectedEvent).toHaveBeenCalledWith('network');
});
describe(' EuiFlexItem filter selector container ', () => {
it('should alerts filter dropdown when at least two alerts categories exist', async () => {
renderResult = mockedContext.render(<ProcessTreeAlertsFilter {...props} />);
const filterSelection = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterSelectorContainer'
);
expect(filterSelection).toBeTruthy();
});
it('should not show alerts filter selector container when there is only one alert category ', async () => {
const alertTypeCountsUpdated = mockAlertTypeCounts.map((alertType) =>
alertType.category === 'process' ? { ...alertType, count: 1 } : { ...alertType, count: 0 }
);
renderResult = mockedContext.render(
<ProcessTreeAlertsFilter {...props} alertTypeCounts={alertTypeCountsUpdated} />
);
const filterSelection = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterSelectorContainer'
);
expect(filterSelection).toBeNull();
});
it('should open filter menu popover', async () => {
renderResult = mockedContext.render(<ProcessTreeAlertsFilter {...props} />);
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenu = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterSelectorContainerMenu'
);
expect(filterMenu).toBeTruthy();
});
});
describe('EuiContextMenuItem filter when two alert categories exists', () => {
it('should display network option in filter menu', async () => {
renderResult = mockedContext.render(<ProcessTreeAlertsFilter {...props} />);
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenuItem = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterItem-network'
);
expect(filterMenuItem).toHaveTextContent('View network alerts');
expect(filterMenuItem).toBeTruthy();
});
it('should display process option in filter menu', async () => {
renderResult = mockedContext.render(<ProcessTreeAlertsFilter {...props} />);
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenuItem = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterItem-process'
);
expect(filterMenuItem).toHaveTextContent('View process alerts');
expect(filterMenuItem).toBeTruthy();
});
it('should not display file option in filter menu', async () => {
renderResult = mockedContext.render(<ProcessTreeAlertsFilter {...props} />);
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenuItem = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterItem-file'
);
expect(filterMenuItem).toBeNull();
});
});
describe('EuiContextMenuItem filter when all alert categories exist', () => {
const alertTypeCountsUpdated = mockAlertTypeCounts.map((alertType) => ({
...alertType,
count: 1,
}));
beforeEach(() => {
renderResult = mockedContext.render(
<ProcessTreeAlertsFilter {...props} alertTypeCounts={alertTypeCountsUpdated} />
);
});
it('should display network option in filter menu', async () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenuItem = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterItem-network'
);
expect(filterMenuItem).toHaveTextContent('View network alerts');
expect(filterMenuItem).toBeTruthy();
});
it('should display process option in filter menu', async () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenuItem = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterItem-process'
);
expect(filterMenuItem).toHaveTextContent('View process alerts');
expect(filterMenuItem).toBeTruthy();
});
it('should display file option in filter menu', async () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
const filterMenuItem = renderResult.queryByTestId(
'sessionView:sessionViewAlertDetailsFilterItem-file'
);
expect(filterMenuItem).toHaveTextContent('View file alerts');
expect(filterMenuItem).toBeTruthy();
});
});
describe('EmptyFilterButton display text', () => {
const alertTypeCountsUpdated = mockAlertTypeCounts.map((alertType) => ({
...alertType,
count: 1,
}));
beforeEach(() => {
renderResult = mockedContext.render(
<ProcessTreeAlertsFilter {...props} alertTypeCounts={alertTypeCountsUpdated} />
);
});
it('should set the EmptyFilterButton text content to display "View: all alerts" by default ', () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
expect(filterButton).toHaveTextContent('View: all alerts');
});
it('should set the EmptyFilterButton text content to display "View: file alerts" when file alert option is clicked', () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-file').click();
expect(filterButton).toHaveTextContent('View: file alerts');
});
it('should set the EmptyFilterButton text content to display "View: all alerts" when default filter option is clicked', () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-default').click();
expect(filterButton).toHaveTextContent(`View: ${DEFAULT_ALERT_FILTER_VALUE} alerts`);
});
it('should set the EmptyFilterButton text content to display "View: process alerts" when process alert option is clicked', () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-process').click();
expect(filterButton).toHaveTextContent('View: process alerts');
});
it('should set the EmptyFilterButton text content to display "View: network alerts" when network alert option is clicked', () => {
const filterButton = renderResult.getByTestId(
'sessionView:sessionViewAlertDetailsEmptyFilterButton'
);
userEvent.click(filterButton);
renderResult.getByTestId('sessionView:sessionViewAlertDetailsFilterItem-network').click();
expect(filterButton).toHaveTextContent('View: network alerts');
});
});
});
});

View file

@ -0,0 +1,179 @@
/*
* 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, { useState, useCallback, useMemo } from 'react';
import {
EuiContextMenuItem,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiContextMenuPanel,
EuiPopover,
EuiText,
EuiHorizontalRule,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DEFAULT_ALERT_FILTER_VALUE } from '../../../common/constants';
import {
ProcessEventAlertCategory,
DefaultAlertFilterType,
AlertTypeCount,
} from '../../../common/types/process_tree';
import { useStyles } from './styles';
import { FILTER_MENU_OPTIONS, SELECTED_PROCESS } from './translations';
export interface ProcessTreeAlertsFilterDeps {
totalAlertsCount: number;
alertTypeCounts: AlertTypeCount[];
filteredAlertsCount: number;
onAlertEventCategorySelected: (value: ProcessEventAlertCategory | DefaultAlertFilterType) => void;
}
export const ProcessTreeAlertsFilter = ({
totalAlertsCount,
alertTypeCounts,
filteredAlertsCount,
onAlertEventCategorySelected,
}: ProcessTreeAlertsFilterDeps) => {
const { filterStatus, popover } = useStyles();
const [selectedProcessEventAlertCategory, setSelectedProcessEventAlertCategory] =
useState<ProcessEventAlertCategory>(ProcessEventAlertCategory.all);
const [isPopoverOpen, setPopover] = useState(false);
const onButtonClick = () => {
setPopover(!isPopoverOpen);
};
const closePopover = () => {
setPopover(false);
};
const onSelectedProcessEventAlertCategory = useCallback(
(event) => {
const [_, selectedAlertEvent] = event.target.textContent.split(' ');
setSelectedProcessEventAlertCategory(selectedAlertEvent);
onAlertEventCategorySelected(selectedAlertEvent);
closePopover();
},
[onAlertEventCategorySelected]
);
const doesMultipleAlertTypesExist = useMemo(() => {
// Check if alerts consist of at least two alert event types
const multipleAlertTypeCount = alertTypeCounts.reduce((sumOfAlertTypes, { count }) => {
if (count > 0) {
return (sumOfAlertTypes += 1);
}
return 0;
}, 0);
return multipleAlertTypeCount > 1;
}, [alertTypeCounts]);
const alertEventCategoryFilterMenuButton = (
<EuiButtonEmpty
data-test-subj="sessionView:sessionViewAlertDetailsEmptyFilterButton"
size="s"
iconType="arrowDown"
iconSide="right"
onClick={onButtonClick}
>
{SELECTED_PROCESS[selectedProcessEventAlertCategory]}
</EuiButtonEmpty>
);
const alertEventCategoryFilterMenuItems = useMemo(() => {
const getIconType = (eventCategory: ProcessEventAlertCategory | DefaultAlertFilterType) => {
return eventCategory === selectedProcessEventAlertCategory ? 'check' : 'empty';
};
const alertEventFilterMenuItems = alertTypeCounts
.filter(({ count }) => count > 0)
.map(({ category: processEventAlertCategory }) => {
return (
<EuiContextMenuItem
data-test-subj={`sessionView:sessionViewAlertDetailsFilterItem-${processEventAlertCategory}`}
key={processEventAlertCategory}
icon={getIconType(processEventAlertCategory)}
onClick={onSelectedProcessEventAlertCategory}
>
{FILTER_MENU_OPTIONS[processEventAlertCategory]}
</EuiContextMenuItem>
);
});
return [
<EuiContextMenuItem
data-test-subj={`sessionView:sessionViewAlertDetailsFilterItem-default`}
key={DEFAULT_ALERT_FILTER_VALUE}
icon={getIconType(DEFAULT_ALERT_FILTER_VALUE)}
onClick={onSelectedProcessEventAlertCategory}
>
{FILTER_MENU_OPTIONS[ProcessEventAlertCategory.all]}
</EuiContextMenuItem>,
...alertEventFilterMenuItems,
];
}, [selectedProcessEventAlertCategory, alertTypeCounts, onSelectedProcessEventAlertCategory]);
return (
<div data-test-subj="sessionView:sessionViewAlertDetailsFilter">
<EuiFlexGroup alignItems="center">
<EuiFlexItem css={filterStatus} style={{ paddingLeft: '16px' }}>
<EuiText size="s" data-test-subj="sessionView:sessionViewAlertDetailsFilterStatus">
{totalAlertsCount === filteredAlertsCount && (
<FormattedMessage
id="xpack.sessionView.alertTotalCountStatusLabel"
defaultMessage="Showing {count} alerts"
values={{
count: <strong>{totalAlertsCount}</strong>,
}}
/>
)}
{totalAlertsCount !== filteredAlertsCount && (
<FormattedMessage
id="xpack.sessionView.alertFilteredCountStatusLabel"
defaultMessage=" Showing {count} alerts"
values={{
count: (
<strong>
{filteredAlertsCount} of {totalAlertsCount}
</strong>
),
}}
/>
)}
</EuiText>
</EuiFlexItem>
{doesMultipleAlertTypesExist && (
<EuiFlexItem
css={popover}
grow={false}
data-test-subj="sessionView:sessionViewAlertDetailsFilterSelectorContainer"
>
<EuiPopover
button={alertEventCategoryFilterMenuButton}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
data-test-subj="sessionView:sessionViewAlertDetailsFilterSelectorContainerMenu"
size="s"
className={'filterMenu'}
items={alertEventCategoryFilterMenuItems}
/>
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
</div>
);
};

View file

@ -0,0 +1,40 @@
/*
* 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.
*/
/*
* 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 { useMemo } from 'react';
import { CSSObject } from '@emotion/react';
export const useStyles = () => {
const cached = useMemo(() => {
const filterStatus: CSSObject = {
paddingLeft: '16px',
'& .text': {
fontWeight: 600,
},
};
const popover: CSSObject = {
paddingRight: '16px',
'& .filterMenu': {
width: '180px',
},
};
return {
filterStatus,
popover,
};
}, []);
return cached;
};

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 { i18n } from '@kbn/i18n';
export const SELECTED_PROCESS = {
all: i18n.translate('xpack.sessionView.alertDetailsAllSelectedCategory', {
defaultMessage: 'View: all alerts',
}),
process: i18n.translate('xpack.sessionView.alertDetailsProcessSelectedCategory', {
defaultMessage: 'View: process alerts',
}),
network: i18n.translate('xpack.sessionView.alertDetailsNetworkSelectedCategory', {
defaultMessage: 'View: network alerts',
}),
file: i18n.translate('xpack.sessionView.alertDetailsFileSelectedCategory', {
defaultMessage: 'View: file alerts',
}),
};
export const FILTER_MENU_OPTIONS = {
all: i18n.translate('xpack.sessionView.alertDetailsAllFilterItem', {
defaultMessage: 'View all alerts',
}),
process: i18n.translate('xpack.sessionView.alertDetailsProcessFilterItem', {
defaultMessage: 'View process alerts',
}),
network: i18n.translate('xpack.sessionView.alertDetailsNetworkFilterItem', {
defaultMessage: 'View network alerts',
}),
file: i18n.translate('xpack.sessionView.alertDetailsFileFilterItem', {
defaultMessage: 'View file alerts',
}),
};

View file

@ -4,10 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { EuiButton, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AlertTypeCount } from '../../../common/types/process_tree';
import { useButtonStyles } from './use_button_styles';
import { ALERT_ICONS } from '../../../common/constants';
const MAX_ALERT_COUNT = 99;
@ -53,15 +55,25 @@ export const ChildrenProcessesButton = ({
export const AlertButton = ({
isExpanded,
alertTypeCounts,
onToggle,
alertsCount,
}: {
isExpanded: boolean;
alertTypeCounts: AlertTypeCount[];
onToggle: () => void;
alertsCount: number;
}) => {
const { alertButton, buttonArrow } = useButtonStyles();
const alertIcons: string[] = useMemo(
() =>
alertTypeCounts
?.filter((alertTypeCount) => alertTypeCount.count > 0)
?.map(({ category }, i) => ALERT_ICONS[category]),
[alertTypeCounts]
);
return (
<EuiButton
className={isExpanded ? 'isExpanded' : ''}
@ -74,6 +86,9 @@ export const AlertButton = ({
{alertsCount > 1 ? ALERTS : ALERT}
{alertsCount > 1 &&
(alertsCount > MAX_ALERT_COUNT ? ` (${MAX_ALERT_COUNT}+)` : ` (${alertsCount})`)}
{alertIcons?.map((icon: string) => (
<EuiIcon className="alertIcon" key={icon} size="s" type={icon} />
))}
<EuiIcon css={buttonArrow} size="s" type="arrowDown" />
</EuiButton>
);

View file

@ -23,7 +23,12 @@ import React, {
import { EuiButton, EuiIcon, EuiToolTip, formatDate } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { Process } from '../../../common/types/process_tree';
import { chain } from 'lodash';
import {
AlertTypeCount,
Process,
ProcessEventAlertCategory,
} from '../../../common/types/process_tree';
import { dataOrDash } from '../../utils/data_or_dash';
import { useVisible } from '../../hooks/use_visible';
import { ProcessTreeAlerts } from '../process_tree_alerts';
@ -126,6 +131,18 @@ export function ProcessTreeNode({
shouldAddListener: hasInvestigatedAlert,
});
const alertTypeCounts = useMemo(() => {
const alertCounts: AlertTypeCount[] = chain(alerts)
.groupBy((alert) => alert.event?.category?.[0])
.map((processAlerts, alertCategory) => ({
category: alertCategory as ProcessEventAlertCategory,
count: processAlerts.length,
}))
.value();
return alertCounts;
}, [alerts]);
useEffect(() => {
if (process.id === selectedProcess?.id && nodeRef.current?.scrollIntoView) {
nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@ -301,6 +318,7 @@ export function ProcessTreeNode({
{hasAlerts && (
<AlertButton
onToggle={onAlertsToggle}
alertTypeCounts={alertTypeCounts}
isExpanded={alertsExpanded}
alertsCount={alerts.length}
/>
@ -312,6 +330,7 @@ export function ProcessTreeNode({
{alertsExpanded && (
<ProcessTreeAlerts
alerts={alerts}
alertTypeCounts={alertTypeCounts}
investigatedAlertId={investigatedAlertId}
isProcessSelected={isSelected}
onAlertSelected={onProcessClicked}

View file

@ -40,7 +40,7 @@ export const useButtonStyles = () => {
background: transparentize(euiVars.euiColorVis6, 0.12),
textDecoration: 'none',
},
'&.isExpanded > span svg': {
'&.isExpanded > span svg:not(.alertIcon)': {
transform: `rotate(180deg)`,
},
'&.isExpanded': {
@ -55,7 +55,6 @@ export const useButtonStyles = () => {
const buttonArrow: CSSObject = {
marginLeft: size.xs,
};
const alertButton: CSSObject = {
...button,
color: euiVars.euiColorDanger,
@ -72,6 +71,14 @@ export const useButtonStyles = () => {
background: `${euiVars.euiColorDanger}`,
},
},
'& .euiButton__text': {
display: 'flex',
alignItems: 'center',
' .alertIcon': {
marginLeft: '4px',
},
},
};
const outputButton: CSSObject = {

View file

@ -0,0 +1,61 @@
/*
* 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 {
mockAlerts,
mockFileAlert,
mockNetworkAlert,
} from '../../common/mocks/constants/session_view_process.mock';
import { getAlertCategoryDisplayText, getAlertNetworkDisplay } from './alert_category_display_text';
import { ProcessEventAlertCategory } from '../../common/types/process_tree';
describe('getAlertCategoryDisplayText(alert, category)', () => {
it('should display file path when alert category is file', () => {
expect(getAlertCategoryDisplayText(mockFileAlert, ProcessEventAlertCategory?.file)).toEqual(
mockFileAlert?.file?.path
);
});
it('should display rule name when alert category is process', () => {
expect(getAlertCategoryDisplayText(mockAlerts[0], ProcessEventAlertCategory.process)).toEqual(
undefined
);
});
it('should display rule name when alert category is undefined', () => {
expect(getAlertCategoryDisplayText(mockAlerts[0], undefined)).toEqual(undefined);
});
it('should display rule name when file path is undefined', () => {
const fileAlert = { ...mockFileAlert, file: {} };
expect(getAlertCategoryDisplayText(fileAlert, ProcessEventAlertCategory.file)).toEqual(
undefined
);
});
it('should display rule name when destination address is undefined and alert category is network', () => {
const networkAlert = { ...mockNetworkAlert, destination: undefined };
expect(getAlertCategoryDisplayText(networkAlert, ProcessEventAlertCategory.network)).toEqual(
undefined
);
});
});
describe('getAlertNetworkDisplay(destination)', () => {
it('should show destination address and port', () => {
const text = `${mockNetworkAlert.destination.address}:${mockNetworkAlert.destination.port}`;
expect(getAlertNetworkDisplay(mockNetworkAlert.destination)).toEqual(text);
});
it('should show only ip address when port does not exist', () => {
const text = `${mockNetworkAlert?.destination?.address}`;
expect(
getAlertNetworkDisplay({
...mockNetworkAlert.destination,
port: undefined,
})
).toEqual(text);
});
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ProcessEvent,
ProcessEventAlertCategory,
ProcessEventIPAddress,
} from '../../common/types/process_tree';
import { dataOrDash } from './data_or_dash';
export const getAlertCategoryDisplayText = (alert: ProcessEvent, category: string | undefined) => {
const destination = alert?.destination;
const filePath = alert?.file?.path;
if (filePath && category === ProcessEventAlertCategory.file) return dataOrDash(filePath);
if (destination?.address && category === ProcessEventAlertCategory.network)
return dataOrDash(getAlertNetworkDisplay(destination));
return;
};
export const getAlertNetworkDisplay = (destination: ProcessEventIPAddress) => {
const hasIpAddressPort = !!destination?.address && !!destination?.port;
const ipAddressPort = `${destination?.address}:${destination?.port}`;
return `${hasIpAddressPort ? ipAddressPort : destination?.address}`;
};