[Security Solution] Use session view plugin to render session viewer in alerts, events and timeline (#127520)

This commit is contained in:
Kevin Qualters 2022-03-29 16:06:42 -04:00 committed by GitHub
parent bde0262191
commit 33b85f8968
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1434 additions and 536 deletions

View file

@ -11,6 +11,9 @@ export interface ProcessEcs {
Ext?: Ext;
command_line?: string[];
entity_id?: string[];
entry_leader?: ProcessSessionData;
session_leader?: ProcessSessionData;
group_leader?: ProcessSessionData;
exit_code?: number[];
hash?: ProcessHashData;
parent?: ProcessParentData;
@ -25,6 +28,12 @@ export interface ProcessEcs {
working_directory?: string[];
}
export interface ProcessSessionData {
entity_id?: string[];
pid?: string[];
name?: string[];
}
export interface ProcessHashData {
md5?: string[];
sha1?: string[];

View file

@ -482,6 +482,7 @@ export enum TimelineTabs {
notes = 'notes',
pinned = 'pinned',
eql = 'eql',
session = 'session',
}
/**

View file

@ -22,6 +22,7 @@
"licensing",
"maps",
"ruleRegistry",
"sessionView",
"taskManager",
"timelines",
"triggersActionsUi",

View file

@ -80,7 +80,7 @@ const AlertsTableComponent: React.FC<Props> = ({
const dispatch = useDispatch();
const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]);
const { filterManager } = useKibana().services.data.query;
const ACTION_BUTTON_COUNT = 4;
const ACTION_BUTTON_COUNT = 5;
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');

View file

@ -84,7 +84,7 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
}) => {
const dispatch = useDispatch();
const { globalFullScreen } = useGlobalFullScreen();
const ACTION_BUTTON_COUNT = 4;
const ACTION_BUTTON_COUNT = 5;
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
useEffect(() => {

View file

@ -9,9 +9,8 @@ import React from 'react';
import useResizeObserver from 'use-resize-observer/polyfilled';
import '../../mock/match_media';
import { waitFor } from '@testing-library/react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { mockEventViewerResponse } from './mock';
import { StatefulEventsViewer } from '.';
@ -61,37 +60,27 @@ const testProps = {
start: from,
};
describe('StatefulEventsViewer', () => {
const mount = useMountAppended();
(useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]);
test('it renders the events viewer', async () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.text()).toMatchInlineSnapshot(`"hello grid"`);
});
expect(wrapper.getByText('hello grid')).toBeTruthy();
});
// InspectButtonContainer controls displaying InspectButton components
test('it renders InspectButtonContainer', async () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`InspectButtonContainer`).exists()).toBe(true);
});
expect(wrapper.getByTestId(`hoverVisibilityContainer`)).toBeTruthy();
});
test('it closes field editor when unmounted', async () => {
@ -101,14 +90,14 @@ describe('StatefulEventsViewer', () => {
return {};
});
const wrapper = mount(
const { unmount } = render(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(mockCloseEditor).not.toHaveBeenCalled();
wrapper.unmount();
unmount();
expect(mockCloseEditor).toHaveBeenCalled();
});
});

View file

@ -24,7 +24,6 @@ import { SourcererScopeName } from '../../store/sourcerer/model';
import { useSourcererDataView } from '../../containers/sourcerer';
import type { EntityType } from '../../../../../timelines/common';
import { TGridCellAction } from '../../../../../timelines/common/types';
import { DetailsPanel } from '../../../timelines/components/side_panel';
import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering';
import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../lib/cell_actions/constants';
import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana';
@ -33,6 +32,7 @@ import {
useFieldBrowserOptions,
FieldEditorActions,
} from '../../../timelines/components/fields_browser';
import { useSessionView } from '../../../timelines/components/timeline/session_tab_content/use_session_view';
const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = [];
@ -105,6 +105,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
itemsPerPage,
itemsPerPageOptions,
kqlMode,
sessionViewId,
showCheckboxes,
sort,
} = defaultModel,
@ -155,11 +156,19 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]);
const trailingControlColumns: ControlColumnProps[] = EMPTY_CONTROL_COLUMNS;
const graphOverlay = useMemo(
() =>
graphEventId != null && graphEventId.length > 0 ? <GraphOverlay timelineId={id} /> : null,
[graphEventId, id]
);
const { DetailsPanel, SessionView, Navigation } = useSessionView({
entityType,
timelineId: id,
});
const graphOverlay = useMemo(() => {
const shouldShowOverlay =
(graphEventId != null && graphEventId.length > 0) || sessionViewId !== null;
return shouldShowOverlay ? (
<GraphOverlay timelineId={id} SessionView={SessionView} Navigation={Navigation} />
) : null;
}, [graphEventId, id, sessionViewId, SessionView, Navigation]);
const setQuery = useCallback(
(inspect, loading, refetch) => {
dispatch(inputsActions.setQuery({ id, inputId: 'global', inspect, loading, refetch }));
@ -239,14 +248,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
})}
</InspectButtonContainer>
</FullScreenContainer>
<DetailsPanel
browserFields={browserFields}
entityType={entityType}
docValueFields={docValueFields}
isFlyoutView
runtimeMappings={runtimeMappings}
timelineId={id}
/>
{DetailsPanel}
</CasesContext>
</>
);

View file

@ -321,6 +321,7 @@ export const mockGlobalState: State = {
end: '2020-07-08T08:20:18.966Z',
},
selectedEventIds: {},
sessionViewId: null,
show: false,
showCheckboxes: false,
pinnedEventIds: {},

View file

@ -2011,6 +2011,7 @@ export const mockTimelineModel: TimelineModel = {
savedObjectId: 'ef579e40-jibber-jabber',
selectAll: false,
selectedEventIds: {},
sessionViewId: null,
show: false,
showCheckboxes: false,
sort: [
@ -2132,6 +2133,7 @@ export const defaultTimelineProps: CreateTimelineProps = {
savedObjectId: null,
selectAll: false,
selectedEventIds: {},
sessionViewId: null,
show: false,
showCheckboxes: false,
sort: [{ columnId: '@timestamp', columnType: 'number', sortDirection: Direction.desc }],

View file

@ -312,6 +312,7 @@ describe('alert actions', () => {
savedObjectId: null,
selectAll: false,
selectedEventIds: {},
sessionViewId: null,
show: true,
showCheckboxes: false,
sort: [

View file

@ -176,4 +176,5 @@ export const requiredFieldsForActions = [
'file.hash.sha256',
'host.os.family',
'event.code',
'process.entry_leader.entity_id',
];

View file

@ -104,7 +104,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
const kibana = useKibana();
const [, dispatchToaster] = useStateToaster();
const { addWarning } = useAppToasts();
const ACTION_BUTTON_COUNT = 4;
const ACTION_BUTTON_COUNT = 5;
const getGlobalQuery = useCallback(
(customFilters: Filter[]) => {

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { waitFor } from '@testing-library/react';
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import {
useGlobalFullScreen,
useTimelineFullScreen,
@ -37,6 +37,24 @@ jest.mock('../../../resolver/view/use_state_syncing_actions');
const useStateSyncingActionsMock = useStateSyncingActions as jest.Mock;
jest.mock('../../../resolver/view/use_sync_selected_node');
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
services: {
sessionView: {
getSessionView: () => <div />,
},
data: {
search: {
search: jest.fn(),
},
},
},
}),
};
});
describe('GraphOverlay', () => {
const { storage } = createSecuritySolutionStorageMock();
@ -54,20 +72,18 @@ describe('GraphOverlay', () => {
});
describe('when used in an events viewer (i.e. in the Detections view, or the Host > Events view)', () => {
test('it has 100% width when NOT in full screen mode', async () => {
const wrapper = mount(
test('it has 100% width when NOT in full screen mode', () => {
const wrapper = render(
<TestProviders>
<GraphOverlay timelineId={TimelineId.test} />
<GraphOverlay timelineId={TimelineId.test} SessionView={<div />} Navigation={<div />} />
</TestProviders>
);
await waitFor(() => {
const overlayContainer = wrapper.find('[data-test-subj="overlayContainer"]').first();
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
const overlayContainer = wrapper.getByTestId('overlayContainer');
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
test('it has a fixed position when in full screen mode', async () => {
test('it has a fixed position when in full screen mode', () => {
(useGlobalFullScreen as jest.Mock).mockReturnValue({
globalFullScreen: true,
setGlobalFullScreen: jest.fn(),
@ -77,20 +93,18 @@ describe('GraphOverlay', () => {
setTimelineFullScreen: jest.fn(),
});
const wrapper = mount(
const wrapper = render(
<TestProviders>
<GraphOverlay timelineId={TimelineId.test} />
<GraphOverlay timelineId={TimelineId.test} SessionView={<div />} Navigation={<div />} />
</TestProviders>
);
await waitFor(() => {
const overlayContainer = wrapper.find('[data-test-subj="overlayContainer"]').first();
expect(overlayContainer).toHaveStyleRule('position', 'fixed');
});
const overlayContainer = wrapper.getByTestId('overlayContainer');
expect(overlayContainer).toHaveStyleRule('position', 'fixed');
});
test('it gets index pattern from default data view', () => {
mount(
render(
<TestProviders
store={createStore(
{
@ -110,7 +124,7 @@ describe('GraphOverlay', () => {
storage
)}
>
<GraphOverlay timelineId={TimelineId.test} />
<GraphOverlay timelineId={TimelineId.test} SessionView={<div />} Navigation={<div />} />
</TestProviders>
);
@ -123,20 +137,18 @@ describe('GraphOverlay', () => {
describe('when used in the active timeline', () => {
const timelineId = TimelineId.active;
test('it has 100% width when NOT in full screen mode', async () => {
const wrapper = mount(
test('it has 100% width when NOT in full screen mode', () => {
const wrapper = render(
<TestProviders>
<GraphOverlay timelineId={timelineId} />
<GraphOverlay timelineId={timelineId} SessionView={<div />} Navigation={<div />} />
</TestProviders>
);
await waitFor(() => {
const overlayContainer = wrapper.find('[data-test-subj="overlayContainer"]').first();
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
const overlayContainer = wrapper.getByTestId('overlayContainer');
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
test('it has 100% width when the active timeline is in full screen mode', async () => {
test('it has 100% width when the active timeline is in full screen mode', () => {
(useGlobalFullScreen as jest.Mock).mockReturnValue({
globalFullScreen: false,
setGlobalFullScreen: jest.fn(),
@ -146,20 +158,18 @@ describe('GraphOverlay', () => {
setTimelineFullScreen: jest.fn(),
});
const wrapper = mount(
const wrapper = render(
<TestProviders>
<GraphOverlay timelineId={timelineId} />
<GraphOverlay timelineId={timelineId} SessionView={<div />} Navigation={<div />} />
</TestProviders>
);
await waitFor(() => {
const overlayContainer = wrapper.find('[data-test-subj="overlayContainer"]').first();
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
const overlayContainer = wrapper.getByTestId('overlayContainer');
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
test('it gets index pattern from Timeline data view', () => {
mount(
render(
<TestProviders
store={createStore(
{
@ -189,11 +199,52 @@ describe('GraphOverlay', () => {
storage
)}
>
<GraphOverlay timelineId={timelineId} />
<GraphOverlay timelineId={timelineId} SessionView={<div />} Navigation={<div />} />
</TestProviders>
);
expect(useStateSyncingActionsMock.mock.calls[0][0].indices).toEqual(mockIndexNames);
});
test('it renders session view controls', () => {
(useGlobalFullScreen as jest.Mock).mockReturnValue({
globalFullScreen: false,
setGlobalFullScreen: jest.fn(),
});
(useTimelineFullScreen as jest.Mock).mockReturnValue({
timelineFullScreen: true,
setTimelineFullScreen: jest.fn(),
});
const wrapper = render(
<TestProviders
store={createStore(
{
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
[timelineId]: {
...mockGlobalState.timeline.timelineById[timelineId],
sessionViewId: 'testId',
},
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
)}
>
<GraphOverlay
timelineId={timelineId}
SessionView={<div />}
Navigation={<div>{'Close Session'}</div>}
/>
</TestProviders>
);
expect(wrapper.getByText('Close Session')).toBeTruthy();
});
});
});

View file

@ -5,25 +5,10 @@
* 2.0.
*/
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiToolTip,
EuiLoadingSpinner,
} from '@elastic/eui';
import React, { useCallback, useMemo, useEffect } from 'react';
import React, { useMemo, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiLoadingSpinner } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { FULL_SCREEN } from '../timeline/body/column_headers/translations';
import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations';
import {
FULL_SCREEN_TOGGLED_CLASS_NAME,
SCROLLING_DISABLED_CLASS_NAME,
} from '../../../../common/constants';
import {
useGlobalFullScreen,
useTimelineFullScreen,
@ -33,7 +18,6 @@ import { TimelineId } from '../../../../common/types/timeline';
import { timelineSelectors } from '../../store/timeline';
import { timelineDefaults } from '../../store/timeline/defaults';
import { isFullScreen } from '../timeline/body/column_headers';
import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions';
import { inputsActions } from '../../../common/store/actions';
import { Resolver } from '../../../resolver/view';
import {
@ -41,7 +25,6 @@ import {
startSelector,
endSelector,
} from '../../../common/components/super_date_picker/selectors';
import * as i18n from './translations';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { sourcererSelectors } from '../../../common/store';
@ -66,71 +49,35 @@ const StyledResolver = styled(Resolver)`
height: 100%;
`;
const FullScreenButtonIcon = styled(EuiButtonIcon)`
margin: 4px 0 4px 0;
const ScrollableFlexItem = styled(EuiFlexItem)`
${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`}
overflow: hidden;
width: 100%;
`;
interface OwnProps {
interface GraphOverlayProps {
timelineId: TimelineId;
SessionView: JSX.Element | null;
Navigation: JSX.Element | null;
}
interface NavigationProps {
fullScreen: boolean;
globalFullScreen: boolean;
onCloseOverlay: () => void;
timelineId: TimelineId;
timelineFullScreen: boolean;
toggleFullScreen: () => void;
}
const NavigationComponent: React.FC<NavigationProps> = ({
fullScreen,
globalFullScreen,
onCloseOverlay,
const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
timelineId,
timelineFullScreen,
toggleFullScreen,
}) => (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onCloseOverlay} size="xs">
{i18n.CLOSE_ANALYZER}
</EuiButtonEmpty>
</EuiFlexItem>
{timelineId !== TimelineId.active && (
<EuiFlexItem grow={false}>
<EuiToolTip content={fullScreen ? EXIT_FULL_SCREEN : FULL_SCREEN}>
<FullScreenButtonIcon
aria-label={
isFullScreen({ globalFullScreen, timelineId, timelineFullScreen })
? EXIT_FULL_SCREEN
: FULL_SCREEN
}
className={fullScreen ? FULL_SCREEN_TOGGLED_CLASS_NAME : ''}
color={fullScreen ? 'ghost' : 'primary'}
data-test-subj="full-screen"
iconType="fullScreen"
onClick={toggleFullScreen}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
NavigationComponent.displayName = 'NavigationComponent';
const Navigation = React.memo(NavigationComponent);
const GraphOverlayComponent: React.FC<OwnProps> = ({ timelineId }) => {
SessionView,
Navigation,
}) => {
const dispatch = useDispatch();
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const { globalFullScreen } = useGlobalFullScreen();
const { timelineFullScreen } = useTimelineFullScreen();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const graphEventId = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).graphEventId
);
const sessionViewId = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewId
);
const getStartSelector = useMemo(() => startSelector(), []);
const getEndSelector = useMemo(() => endSelector(), []);
const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []);
@ -163,24 +110,6 @@ const GraphOverlayComponent: React.FC<OwnProps> = ({ timelineId }) => {
);
const isInTimeline = timelineId === TimelineId.active;
const onCloseOverlay = useCallback(() => {
const isDataGridFullScreen = document.querySelector('.euiDataGrid--fullScreen') !== null;
// Since EUI changes these values directly as a side effect, need to add them back on close.
if (isDataGridFullScreen) {
if (timelineId === TimelineId.active) {
document.body.classList.add('euiDataGrid__restrictBody');
} else {
document.body.classList.add(SCROLLING_DISABLED_CLASS_NAME, 'euiDataGrid__restrictBody');
}
} else {
if (timelineId === TimelineId.active) {
setTimelineFullScreen(false);
} else {
setGlobalFullScreen(false);
}
}
dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' }));
}, [dispatch, timelineId, setTimelineFullScreen, setGlobalFullScreen]);
useEffect(() => {
return () => {
@ -192,20 +121,6 @@ const GraphOverlayComponent: React.FC<OwnProps> = ({ timelineId }) => {
};
}, [dispatch, timelineId]);
const toggleFullScreen = useCallback(() => {
if (timelineId === TimelineId.active) {
setTimelineFullScreen(!timelineFullScreen);
} else {
setGlobalFullScreen(!globalFullScreen);
}
}, [
timelineId,
setTimelineFullScreen,
timelineFullScreen,
setGlobalFullScreen,
globalFullScreen,
]);
const getDefaultDataViewSelector = useMemo(
() => sourcererSelectors.defaultDataViewSelector(),
[]
@ -219,21 +134,32 @@ const GraphOverlayComponent: React.FC<OwnProps> = ({ timelineId }) => {
[defaultDataView.patternList, isInTimeline, timelinePatterns]
);
if (fullScreen && !isInTimeline) {
if (!isInTimeline && sessionViewId !== null) {
if (fullScreen) {
return (
<FullScreenOverlayContainer data-test-subj="overlayContainer">
<EuiFlexGroup alignItems="flexStart" gutterSize="none" direction="column">
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
<ScrollableFlexItem grow={2}>{SessionView}</ScrollableFlexItem>
</EuiFlexGroup>
</FullScreenOverlayContainer>
);
} else {
return (
<OverlayContainer data-test-subj="overlayContainer">
<EuiFlexGroup alignItems="flexStart" gutterSize="none" direction="column">
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
<ScrollableFlexItem grow={2}>{SessionView}</ScrollableFlexItem>
</EuiFlexGroup>
</OverlayContainer>
);
}
} else if (fullScreen && !isInTimeline) {
return (
<FullScreenOverlayContainer data-test-subj="overlayContainer">
<EuiHorizontalRule margin="none" />
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<Navigation
fullScreen={fullScreen}
globalFullScreen={globalFullScreen}
onCloseOverlay={onCloseOverlay}
timelineId={timelineId}
timelineFullScreen={timelineFullScreen}
toggleFullScreen={toggleFullScreen}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="none" />
{graphEventId !== undefined ? (
@ -256,16 +182,7 @@ const GraphOverlayComponent: React.FC<OwnProps> = ({ timelineId }) => {
<OverlayContainer data-test-subj="overlayContainer">
<EuiHorizontalRule margin="none" />
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<Navigation
fullScreen={fullScreen}
globalFullScreen={globalFullScreen}
onCloseOverlay={onCloseOverlay}
timelineId={timelineId}
timelineFullScreen={timelineFullScreen}
toggleFullScreen={toggleFullScreen}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="none" />
{graphEventId !== undefined ? (

View file

@ -380,6 +380,313 @@ Array [
runtimeMappings={Object {}}
tabType="query"
timelineId="test"
>
<CasesContextMock
owner={
Array [
"securitySolution",
]
}
userCanCrud={false}
>
<EuiFlyoutHeader
hasBorder={false}
>
<div
className="euiFlyoutHeader"
>
<ExpandableEventTitle
isAlert={false}
loading={true}
ruleName=""
timestamp=""
>
<Styled(EuiFlexGroup)
gutterSize="none"
justifyContent="spaceBetween"
wrap={true}
>
<EuiFlexGroup
className="c0"
gutterSize="none"
justifyContent="spaceBetween"
wrap={true}
>
<div
className="euiFlexGroup euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap c0"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</Styled(EuiFlexGroup)>
</ExpandableEventTitle>
</div>
</EuiFlyoutHeader>
<Styled(EuiFlyoutBody)>
<EuiFlyoutBody
className="c1"
>
<div
className="euiFlyoutBody c1"
>
<div
className="euiFlyoutBody__overflow"
tabIndex={0}
>
<div
className="euiFlyoutBody__overflowContent"
>
<ExpandableEvent
browserFields={Object {}}
detailsData={null}
event={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
hostRisk={null}
isAlert={false}
isDraggable={false}
loading={true}
timelineId="test"
timelineTabType="flyout"
>
<EuiLoadingContent
lines={10}
>
<span
className="euiLoadingContent"
>
<span
className="euiLoadingContent__singleLine"
key="0"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="1"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="2"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="3"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="4"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="5"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="6"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="7"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="8"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="9"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
</span>
</EuiLoadingContent>
</ExpandableEvent>
</div>
</div>
</div>
</EuiFlyoutBody>
</Styled(EuiFlyoutBody)>
<Connect(Component)
detailsData={null}
detailsEcsData={null}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddIsolationStatusClick={[Function]}
refetchFlyoutData={[Function]}
timelineId="test"
>
<Memo()
detailsData={null}
detailsEcsData={null}
dispatch={[Function]}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
globalQuery={Array []}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddIsolationStatusClick={[Function]}
refetchFlyoutData={[Function]}
timelineId="test"
timelineQuery={
Object {
"id": "",
"inspect": null,
"isInspected": false,
"loading": false,
"refetch": null,
"selectedInspectIndex": 0,
}
}
>
<EuiFlyoutFooter
data-test-subj="side-panel-flyout-footer"
>
<div
className="euiFlyoutFooter"
data-test-subj="side-panel-flyout-footer"
>
<EuiFlexGroup
justifyContent="flexEnd"
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--justifyContentFlexEnd euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiFlyoutFooter>
</Memo()>
</Connect(Component)>
</CasesContextMock>
</EventDetailsPanelComponent>
</div>
</EuiFlyout>,
.c0 {
-webkit-flex: 0 1 auto;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
margin-top: 8px;
}
.c1 .euiFlyoutBody__overflow {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
}
.c1 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent {
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 0 16px 16px;
}
<div
data-eui="EuiFlyout"
data-test-subj="timeline:details-panel:flyout"
role="dialog"
>
<button
aria-label="Close this dialog"
data-test-subj="euiFlyoutCloseButton"
onClick={[Function]}
type="button"
/>
<EventDetailsPanelComponent
browserFields={Object {}}
docValueFields={Array []}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
isDraggable={false}
isFlyoutView={true}
runtimeMappings={Object {}}
tabType="query"
timelineId="test"
>
<CasesContextMock
owner={
Array [
"securitySolution",
]
}
userCanCrud={false}
>
<EuiFlyoutHeader
hasBorder={false}
@ -615,296 +922,7 @@ Array [
</EuiFlyoutFooter>
</Memo()>
</Connect(Component)>
</EventDetailsPanelComponent>
</div>
</EuiFlyout>,
.c0 {
-webkit-flex: 0 1 auto;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
margin-top: 8px;
}
.c1 .euiFlyoutBody__overflow {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
}
.c1 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent {
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 0 16px 16px;
}
<div
data-eui="EuiFlyout"
data-test-subj="timeline:details-panel:flyout"
role="dialog"
>
<button
aria-label="Close this dialog"
data-test-subj="euiFlyoutCloseButton"
onClick={[Function]}
type="button"
/>
<EventDetailsPanelComponent
browserFields={Object {}}
docValueFields={Array []}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
isDraggable={false}
isFlyoutView={true}
runtimeMappings={Object {}}
tabType="query"
timelineId="test"
>
<EuiFlyoutHeader
hasBorder={false}
>
<div
className="euiFlyoutHeader"
>
<ExpandableEventTitle
isAlert={false}
loading={true}
ruleName=""
timestamp=""
>
<Styled(EuiFlexGroup)
gutterSize="none"
justifyContent="spaceBetween"
wrap={true}
>
<EuiFlexGroup
className="c0"
gutterSize="none"
justifyContent="spaceBetween"
wrap={true}
>
<div
className="euiFlexGroup euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap c0"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</Styled(EuiFlexGroup)>
</ExpandableEventTitle>
</div>
</EuiFlyoutHeader>
<Styled(EuiFlyoutBody)>
<EuiFlyoutBody
className="c1"
>
<div
className="euiFlyoutBody c1"
>
<div
className="euiFlyoutBody__overflow"
tabIndex={0}
>
<div
className="euiFlyoutBody__overflowContent"
>
<ExpandableEvent
browserFields={Object {}}
detailsData={null}
event={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
hostRisk={null}
isAlert={false}
isDraggable={false}
loading={true}
timelineId="test"
timelineTabType="flyout"
>
<EuiLoadingContent
lines={10}
>
<span
className="euiLoadingContent"
>
<span
className="euiLoadingContent__singleLine"
key="0"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="1"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="2"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="3"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="4"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="5"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="6"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="7"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="8"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
<span
className="euiLoadingContent__singleLine"
key="9"
>
<span
className="euiLoadingContent__singleLineBackground"
/>
</span>
</span>
</EuiLoadingContent>
</ExpandableEvent>
</div>
</div>
</div>
</EuiFlyoutBody>
</Styled(EuiFlyoutBody)>
<Connect(Component)
detailsData={null}
detailsEcsData={null}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddIsolationStatusClick={[Function]}
refetchFlyoutData={[Function]}
timelineId="test"
>
<Memo()
detailsData={null}
detailsEcsData={null}
dispatch={[Function]}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
globalQuery={Array []}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddIsolationStatusClick={[Function]}
refetchFlyoutData={[Function]}
timelineId="test"
timelineQuery={
Object {
"id": "",
"inspect": null,
"isInspected": false,
"loading": false,
"refetch": null,
"selectedInspectIndex": 0,
}
}
>
<EuiFlyoutFooter
data-test-subj="side-panel-flyout-footer"
>
<div
className="euiFlyoutFooter"
data-test-subj="side-panel-flyout-footer"
>
<EuiFlexGroup
justifyContent="flexEnd"
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--justifyContentFlexEnd euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiFlyoutFooter>
</Memo()>
</Connect(Component)>
</CasesContextMock>
</EventDetailsPanelComponent>
</div>,
]

View file

@ -193,7 +193,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
}
return isFlyoutView ? (
<>
<CasesContext owner={[APP_ID]} userCanCrud={casesPermissions?.crud ?? false}>
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
{isHostIsolationPanelOpen ? (
backToAlertDetailsLink
@ -249,7 +249,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
onAddIsolationStatusClick={showHostIsolationPanel}
timelineId={timelineId}
/>
</>
</CasesContext>
) : (
<CasesContext owner={[APP_ID]} userCanCrud={casesPermissions?.crud ?? false}>
<ExpandableEventTitle

View file

@ -0,0 +1,150 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { useDetailPanel, UseDetailPanelConfig } from './use_detail_panel';
import { timelineActions } from '../../../store/timeline';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { TimelineId, TimelineTabs } from '../../../../../common/types';
const mockDispatch = jest.fn();
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/hooks/use_selector');
jest.mock('../../../store/timeline');
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('../../../../common/containers/sourcerer', () => {
const mockSourcererReturn = {
browserFields: {},
docValueFields: [],
loading: true,
indexPattern: {},
selectedPatterns: [],
missingPatterns: [],
};
return {
useSourcererDataView: jest.fn().mockReturnValue(mockSourcererReturn),
};
});
describe('useDetailPanel', () => {
const defaultProps: UseDetailPanelConfig = {
sourcererScope: SourcererScopeName.detections,
timelineId: TimelineId.test,
};
const mockGetExpandedDetail = jest.fn().mockImplementation(() => ({}));
beforeEach(() => {
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => {
return mockGetExpandedDetail();
});
});
afterEach(() => {
(useDeepEqualSelector as jest.Mock).mockClear();
});
test('should return openDetailsPanel fn, handleOnDetailsPanelClosed fn, shouldShowDetailsPanel, and the DetailsPanel component', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {
return useDetailPanel(defaultProps);
});
await waitForNextUpdate();
expect(result.current.openDetailsPanel).toBeDefined();
expect(result.current.handleOnDetailsPanelClosed).toBeDefined();
expect(result.current.shouldShowDetailsPanel).toBe(false);
expect(result.current.DetailsPanel).toBeNull();
});
});
test('should fire redux action to open details panel', async () => {
const testEventId = '123';
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {
return useDetailPanel(defaultProps);
});
await waitForNextUpdate();
result.current?.openDetailsPanel(testEventId);
expect(mockDispatch).toHaveBeenCalled();
expect(timelineActions.toggleDetailPanel).toHaveBeenCalled();
});
});
test('should call provided onClose callback provided to openDetailsPanel fn', async () => {
const testEventId = '123';
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {
return useDetailPanel(defaultProps);
});
await waitForNextUpdate();
const mockOnClose = jest.fn();
result.current?.openDetailsPanel(testEventId, mockOnClose);
result.current?.handleOnDetailsPanelClosed();
expect(mockOnClose).toHaveBeenCalled();
});
});
test('should call the last onClose callback provided to openDetailsPanel fn', async () => {
// Test that the onClose ref is properly updated
const testEventId = '123';
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {
return useDetailPanel(defaultProps);
});
await waitForNextUpdate();
const mockOnClose = jest.fn();
const secondMockOnClose = jest.fn();
result.current?.openDetailsPanel(testEventId, mockOnClose);
result.current?.handleOnDetailsPanelClosed();
expect(mockOnClose).toHaveBeenCalled();
result.current?.openDetailsPanel(testEventId, secondMockOnClose);
result.current?.handleOnDetailsPanelClosed();
expect(secondMockOnClose).toHaveBeenCalled();
});
});
test('should show the details panel', async () => {
mockGetExpandedDetail.mockImplementation(() => ({
[TimelineTabs.session]: {
panelView: 'somePanel',
},
}));
const updatedProps = {
...defaultProps,
tabType: TimelineTabs.session,
};
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {
return useDetailPanel(updatedProps);
});
await waitForNextUpdate();
expect(result.current.DetailsPanel).toMatchInlineSnapshot(`
<Memo(DetailsPanel)
browserFields={Object {}}
docValueFields={Array []}
handleOnPanelClosed={[Function]}
tabType="session"
timelineId="test"
/>
`);
});
});
});

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useCallback, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import type { EntityType } from '../../../../../../timelines/common';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { activeTimeline } from '../../../containers/active_timeline_context';
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { DetailsPanel as DetailsPanelComponent } from '..';
export interface UseDetailPanelConfig {
entityType?: EntityType;
isFlyoutView?: boolean;
sourcererScope: SourcererScopeName;
timelineId: TimelineId;
tabType?: TimelineTabs;
}
export interface UseDetailPanelReturn {
openDetailsPanel: (eventId?: string, onClose?: () => void) => void;
handleOnDetailsPanelClosed: () => void;
DetailsPanel: JSX.Element | null;
shouldShowDetailsPanel: boolean;
}
export const useDetailPanel = ({
entityType,
isFlyoutView,
sourcererScope,
timelineId,
tabType,
}: UseDetailPanelConfig): UseDetailPanelReturn => {
const { browserFields, docValueFields, selectedPatterns, runtimeMappings } =
useSourcererDataView(sourcererScope);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const dispatch = useDispatch();
const expandedDetail = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedDetail
);
const onPanelClose = useRef(() => {});
const shouldShowDetailsPanel = useMemo(() => {
if (
tabType &&
expandedDetail &&
expandedDetail[tabType] &&
!!expandedDetail[tabType]?.panelView
) {
return true;
}
return false;
}, [expandedDetail, tabType]);
const loadDetailsPanel = useCallback(
(eventId?: string) => {
if (eventId) {
dispatch(
timelineActions.toggleDetailPanel({
panelView: 'eventDetail',
tabType,
timelineId,
params: {
eventId,
indexName: selectedPatterns.join(','),
},
})
);
}
},
[dispatch, selectedPatterns, tabType, timelineId]
);
const openDetailsPanel = useCallback(
(eventId?: string, onClose?: () => void) => {
loadDetailsPanel(eventId);
onPanelClose.current = onClose ?? (() => {});
},
[loadDetailsPanel]
);
const handleOnDetailsPanelClosed = useCallback(() => {
if (onPanelClose.current) onPanelClose.current();
dispatch(timelineActions.toggleDetailPanel({ tabType, timelineId }));
if (
tabType &&
expandedDetail[tabType]?.panelView &&
timelineId === TimelineId.active &&
shouldShowDetailsPanel
) {
activeTimeline.toggleExpandedDetail({});
}
}, [dispatch, timelineId, expandedDetail, tabType, shouldShowDetailsPanel]);
const DetailsPanel = useMemo(
() =>
shouldShowDetailsPanel ? (
<DetailsPanelComponent
browserFields={browserFields}
docValueFields={docValueFields}
entityType={entityType}
handleOnPanelClosed={handleOnDetailsPanelClosed}
isFlyoutView={isFlyoutView}
runtimeMappings={runtimeMappings}
tabType={tabType}
timelineId={timelineId}
/>
) : null,
[
browserFields,
docValueFields,
entityType,
handleOnDetailsPanelClosed,
isFlyoutView,
runtimeMappings,
shouldShowDetailsPanel,
tabType,
timelineId,
]
);
return {
openDetailsPanel,
handleOnDetailsPanelClosed,
shouldShowDetailsPanel,
DetailsPanel,
};
};

View file

@ -24,6 +24,7 @@ import { useShallowEqualSelector } from '../../../../../common/hooks/use_selecto
import {
setActiveTabTimeline,
updateTimelineGraphEventId,
updateTimelineSessionViewSessionId,
} from '../../../../store/timeline/actions';
import {
useGlobalFullScreen,
@ -128,6 +129,35 @@ const ActionsComponent: React.FC<ActionProps> = ({
}
}, [dispatch, ecsData._id, timelineId, setGlobalFullScreen, setTimelineFullScreen]);
const entryLeader = useMemo(() => {
const { process } = ecsData;
const entryLeaderIds = process?.entry_leader?.entity_id;
if (entryLeaderIds !== undefined && entryLeaderIds.length > 0) {
return entryLeaderIds[0];
} else {
return null;
}
}, [ecsData]);
const openSessionView = useCallback(() => {
const dataGridIsFullScreen = document.querySelector('.euiDataGrid--fullScreen');
if (timelineId === TimelineId.active) {
if (dataGridIsFullScreen) {
setTimelineFullScreen(true);
}
if (entryLeader !== null) {
dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.session }));
}
} else {
if (dataGridIsFullScreen) {
setGlobalFullScreen(true);
}
}
if (entryLeader !== null) {
dispatch(updateTimelineSessionViewSessionId({ id: timelineId, eventId: entryLeader }));
}
}, [dispatch, timelineId, entryLeader, setGlobalFullScreen, setTimelineFullScreen]);
return (
<ActionsContainer>
{showCheckboxes && !tGridEnabled && (
@ -220,6 +250,21 @@ const ActionsComponent: React.FC<ActionProps> = ({
</EventsTdContent>
</div>
) : null}
{entryLeader !== null ? (
<div>
<EventsTdContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}>
<EuiToolTip data-test-subj="expand-event-tool-tip" content={i18n.OPEN_SESSION_VIEW}>
<EuiButtonIcon
aria-label={i18n.VIEW_DETAILS_FOR_ROW({ ariaRowindex, columnValues })}
data-test-subj="session-view-button"
iconType="console"
onClick={openSessionView}
size="s"
/>
</EuiToolTip>
</EventsTdContent>
</div>
) : null}
</>
</ActionsContainer>
);

View file

@ -108,7 +108,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
const { queryFields, selectAll } = useDeepEqualSelector((state) =>
getManageTimeline(state, id)
);
const ACTION_BUTTON_COUNT = 5;
const ACTION_BUTTON_COUNT = 6;
const onRowSelected: OnRowSelected = useCallback(
({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => {

View file

@ -28,6 +28,13 @@ export const COPY_TO_CLIPBOARD = i18n.translate(
}
);
export const OPEN_SESSION_VIEW = i18n.translate(
'xpack.securitySolution.timeline.body.openSessionViewLabel',
{
defaultMessage: 'Open Session View',
}
);
export const INVESTIGATE = i18n.translate(
'xpack.securitySolution.timeline.body.actions.investigateLabel',
{

View file

@ -181,7 +181,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({
runtimeMappings,
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline);
const ACTION_BUTTON_COUNT = 5;
const ACTION_BUTTON_COUNT = 6;
const isBlankTimeline: boolean = isEmpty(eqlQuery);

View file

@ -6,27 +6,54 @@
*/
import React, { useMemo } from 'react';
import { EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { timelineSelectors } from '../../../store/timeline';
import { useShallowEqualSelector } from '../../../../common/hooks/use_selector';
import { TimelineId } from '../../../../../common/types/timeline';
import { GraphOverlay } from '../../graph_overlay';
import { useSessionView } from '../session_tab_content/use_session_view';
interface GraphTabContentProps {
timelineId: TimelineId;
}
const ScrollableFlexItem = styled(EuiFlexItem)`
${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`}
overflow: hidden;
`;
const VerticalRule = styled.div`
width: 2px;
height: 100%;
background: ${({ theme }) => theme.eui.euiColorLightShade};
`;
const GraphTabContentComponent: React.FC<GraphTabContentProps> = ({ timelineId }) => {
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const graphEventId = useShallowEqualSelector(
(state) => getTimeline(state, timelineId)?.graphEventId
);
const { shouldShowDetailsPanel, DetailsPanel, Navigation, SessionView } = useSessionView({
timelineId,
});
if (!graphEventId) {
return null;
}
return <GraphOverlay timelineId={timelineId} />;
return (
<>
<GraphOverlay timelineId={timelineId} Navigation={Navigation} SessionView={SessionView} />
{shouldShowDetailsPanel && (
<>
<VerticalRule />
<ScrollableFlexItem grow={1}>{DetailsPanel}</ScrollableFlexItem>
</>
)}
</>
);
};
GraphTabContentComponent.displayName = 'GraphTabContentComponent';

View file

@ -74,9 +74,18 @@ const StatefulTimelineComponent: React.FC<Props> = ({
savedObjectId,
timelineType,
description,
sessionViewId,
} = useDeepEqualSelector((state) =>
pick(
['indexNames', 'dataViewId', 'graphEventId', 'savedObjectId', 'timelineType', 'description'],
[
'indexNames',
'dataViewId',
'graphEventId',
'savedObjectId',
'timelineType',
'description',
'sessionViewId',
],
getTimeline(state, timelineId) ?? timelineDefaults
)
);
@ -193,6 +202,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
<TabsContent
graphEventId={graphEventId}
sessionViewId={sessionViewId}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}

View file

@ -125,7 +125,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline);
const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen();
const ACTION_BUTTON_COUNT = 5;
const ACTION_BUTTON_COUNT = 6;
const filterQuery = useMemo(() => {
if (isEmpty(pinnedEventIds)) {

View file

@ -202,7 +202,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
} = useSourcererDataView(SourcererScopeName.timeline);
const { uiSettings } = useKibana().services;
const ACTION_BUTTON_COUNT = 5;
const ACTION_BUTTON_COUNT = 6;
const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []);
const { filterManager: activeFilterManager } = useDeepEqualSelector((state) =>

View file

@ -0,0 +1,58 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { TimelineId } from '../../../../../common/types/timeline';
import { useSessionView } from './use_session_view';
const FullWidthFlexGroup = styled(EuiFlexGroup)`
margin: 0;
width: 100%;
overflow: hidden;
`;
const ScrollableFlexItem = styled(EuiFlexItem)`
${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`}
overflow: hidden;
width: 100%;
`;
const VerticalRule = styled.div`
width: 2px;
height: 100%;
background: ${({ theme }) => theme.eui.euiColorLightShade};
`;
interface Props {
timelineId: TimelineId;
}
const SessionTabContent: React.FC<Props> = ({ timelineId }) => {
const { SessionView, shouldShowDetailsPanel, DetailsPanel, Navigation } = useSessionView({
timelineId,
});
return (
<FullWidthFlexGroup gutterSize="none">
<EuiFlexGroup alignItems="flexStart" gutterSize="none" direction="column">
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
<ScrollableFlexItem>{SessionView}</ScrollableFlexItem>
</EuiFlexGroup>
{shouldShowDetailsPanel && (
<>
<VerticalRule />
<ScrollableFlexItem grow={1}>{DetailsPanel}</ScrollableFlexItem>
</>
)}
</FullWidthFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export default SessionTabContent;

View file

@ -13,3 +13,10 @@ export const CLOSE_ANALYZER = i18n.translate(
defaultMessage: 'Close analyzer',
}
);
export const CLOSE_SESSION = i18n.translate(
'xpack.securitySolution.timeline.graphOverlay.closeSessionButton',
{
defaultMessage: 'Close Session',
}
);

View file

@ -0,0 +1,137 @@
/*
* 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, { memo } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { TimelineId } from '../../../../../common/types/timeline';
import { mockTimelineModel } from '../../../../common/mock';
import { useKibana } from '../../../../common/lib/kibana';
import {
useTimelineFullScreen,
useGlobalFullScreen,
} from '../../../../common/containers/use_full_screen';
import { useSessionView } from './use_session_view';
const mockDispatch = jest.fn();
jest.mock('../../../../common/hooks/use_selector', () => ({
useDeepEqualSelector: () => {
return 'test';
},
useShallowEqualSelector: () => mockTimelineModel,
}));
jest.mock('../../../../common/containers/use_full_screen');
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('../../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../../common/lib/kibana');
return {
...originalModule,
useKibana: jest.fn().mockReturnValue({
services: {
application: {
navigateToApp: jest.fn(),
getUrlForApp: jest.fn(),
capabilities: {
siem: { crud_alerts: true, read_alerts: true },
},
},
sessionView: {
getSessionView: jest.fn().mockReturnValue(<div />),
},
data: {
search: jest.fn(),
query: jest.fn(),
},
uiSettings: {
get: jest.fn(),
},
savedObjects: {
client: {},
},
timelines: {
getLastUpdated: jest.fn(),
getLoadingPanel: jest.fn(),
getFieldBrowser: jest.fn(),
getUseDraggableKeyboardWrapper: () =>
jest.fn().mockReturnValue({
onBlur: jest.fn(),
onKeyDown: jest.fn(),
}),
},
},
}),
};
});
jest.mock('../../side_panel/hooks/use_detail_panel', () => {
return {
useDetailPanel: () => ({
openDetailsPanel: () => {},
handleOnDetailsPanelClosed: () => {},
DetailsPanel: () => <div />,
shouldShowDetailsPanel: false,
}),
};
});
describe('useSessionView', () => {
let setTimelineFullScreen: jest.Mock;
let setGlobalFullScreen: jest.Mock;
let kibana: ReturnType<typeof useKibana>;
const Wrapper = memo(({ children }) => {
kibana = useKibana();
return <>{children}</>;
});
Wrapper.displayName = 'Wrapper';
beforeEach(() => {
setTimelineFullScreen = jest.fn();
setGlobalFullScreen = jest.fn();
(useTimelineFullScreen as jest.Mock).mockImplementation(() => ({
setTimelineFullScreen,
}));
(useGlobalFullScreen as jest.Mock).mockImplementation(() => ({
setGlobalFullScreen,
}));
});
it('removes the full screen class from the overlay', () => {
renderHook(
() => {
const testProps = {
timelineId: TimelineId.active,
};
return useSessionView(testProps);
},
{ wrapper: Wrapper }
);
expect(kibana.services.sessionView.getSessionView).toHaveBeenCalled();
});
it('calls setTimelineFullScreen with false when onCloseOverlay is called and the app is not in full screen mode', () => {
const { result } = renderHook(
() => {
const testProps = {
timelineId: TimelineId.active,
};
return useSessionView(testProps);
},
{ wrapper: Wrapper }
);
const navigation = result.current.Navigation;
expect(navigation).toBeTruthy();
});
});

View file

@ -0,0 +1,230 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import { EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import type { EntityType } from '../../../../../../timelines/common';
import { timelineSelectors } from '../../../store/timeline';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { useDetailPanel } from '../../side_panel/hooks/use_detail_panel';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { isFullScreen } from '../body/column_headers';
import {
SCROLLING_DISABLED_CLASS_NAME,
FULL_SCREEN_TOGGLED_CLASS_NAME,
} from '../../../../../common/constants';
import { FULL_SCREEN } from '../../timeline/body/column_headers/translations';
import { EXIT_FULL_SCREEN } from '../../../../common/components/exit_full_screen/translations';
import {
useTimelineFullScreen,
useGlobalFullScreen,
} from '../../../../common/containers/use_full_screen';
import {
updateTimelineGraphEventId,
updateTimelineSessionViewSessionId,
setActiveTabTimeline,
} from '../../../../timelines/store/timeline/actions';
import { detectionsTimelineIds } from '../../../containers/helpers';
import * as i18n from './translations';
const FullScreenButtonIcon = styled(EuiButtonIcon)`
margin: 4px 0 4px 0;
`;
interface NavigationProps {
fullScreen: boolean;
globalFullScreen: boolean;
onCloseOverlay: () => void;
timelineId: TimelineId;
timelineFullScreen: boolean;
toggleFullScreen: () => void;
graphEventId?: string;
activeTab: TimelineTabs;
}
const NavigationComponent: React.FC<NavigationProps> = ({
fullScreen,
globalFullScreen,
onCloseOverlay,
timelineId,
timelineFullScreen,
toggleFullScreen,
graphEventId,
activeTab,
}) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onCloseOverlay} size="xs">
{activeTab === TimelineTabs.graph ? i18n.CLOSE_ANALYZER : i18n.CLOSE_SESSION}
</EuiButtonEmpty>
</EuiFlexItem>
{timelineId !== TimelineId.active && (
<EuiFlexItem grow={false}>
<EuiToolTip content={fullScreen ? EXIT_FULL_SCREEN : FULL_SCREEN}>
<FullScreenButtonIcon
aria-label={
isFullScreen({ globalFullScreen, timelineId, timelineFullScreen })
? EXIT_FULL_SCREEN
: FULL_SCREEN
}
className={fullScreen ? FULL_SCREEN_TOGGLED_CLASS_NAME : ''}
color={fullScreen ? 'ghost' : 'primary'}
data-test-subj="full-screen"
iconType="fullScreen"
onClick={toggleFullScreen}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
NavigationComponent.displayName = 'NavigationComponent';
const Navigation = React.memo(NavigationComponent);
export const useSessionView = ({
timelineId,
entityType,
}: {
timelineId: TimelineId;
entityType?: EntityType;
}) => {
const { sessionView } = useKibana().services;
const dispatch = useDispatch();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const { graphEventId, sessionViewId, activeTab, prevActiveTab } = useDeepEqualSelector(
(state) => getTimeline(state, timelineId) ?? timelineDefaults
);
const onCloseOverlay = useCallback(() => {
const isDataGridFullScreen = document.querySelector('.euiDataGrid--fullScreen') !== null;
// Since EUI changes these values directly as a side effect, need to add them back on close.
if (isDataGridFullScreen) {
if (timelineId === TimelineId.active) {
document.body.classList.add('euiDataGrid__restrictBody');
} else {
document.body.classList.add(SCROLLING_DISABLED_CLASS_NAME, 'euiDataGrid__restrictBody');
}
} else {
if (timelineId === TimelineId.active) {
setTimelineFullScreen(false);
} else {
setGlobalFullScreen(false);
}
}
if (timelineId !== TimelineId.active) {
dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' }));
dispatch(updateTimelineSessionViewSessionId({ id: timelineId, eventId: null }));
} else {
if (activeTab === TimelineTabs.graph) {
dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' }));
if (prevActiveTab === TimelineTabs.session && !sessionViewId) {
dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }));
}
} else if (activeTab === TimelineTabs.session) {
dispatch(updateTimelineSessionViewSessionId({ id: timelineId, eventId: null }));
if (prevActiveTab === TimelineTabs.graph && !graphEventId) {
dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }));
} else {
dispatch(setActiveTabTimeline({ id: timelineId, activeTab: prevActiveTab }));
}
}
}
}, [
dispatch,
timelineId,
setTimelineFullScreen,
setGlobalFullScreen,
activeTab,
prevActiveTab,
graphEventId,
sessionViewId,
]);
const fullScreen = useMemo(
() => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }),
[globalFullScreen, timelineId, timelineFullScreen]
);
const toggleFullScreen = useCallback(() => {
if (timelineId === TimelineId.active) {
setTimelineFullScreen(!timelineFullScreen);
} else {
setGlobalFullScreen(!globalFullScreen);
}
}, [
timelineId,
setTimelineFullScreen,
timelineFullScreen,
setGlobalFullScreen,
globalFullScreen,
]);
const sourcererScope = useMemo(() => {
if (timelineId === TimelineId.active) {
return SourcererScopeName.timeline;
} else if (detectionsTimelineIds.includes(timelineId)) {
return SourcererScopeName.detections;
} else {
return SourcererScopeName.default;
}
}, [timelineId]);
const { openDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({
isFlyoutView: timelineId !== TimelineId.active,
entityType,
sourcererScope,
timelineId,
tabType: timelineId === TimelineId.active ? TimelineTabs.session : TimelineTabs.query,
});
const sessionViewComponent = useMemo(() => {
return sessionViewId !== null
? sessionView.getSessionView({
sessionEntityId: sessionViewId,
loadAlertDetails: openDetailsPanel,
})
: null;
}, [openDetailsPanel, sessionView, sessionViewId]);
const navigation = useMemo(() => {
return (
<Navigation
fullScreen={fullScreen}
globalFullScreen={globalFullScreen}
activeTab={activeTab}
onCloseOverlay={onCloseOverlay}
timelineId={timelineId}
timelineFullScreen={timelineFullScreen}
toggleFullScreen={toggleFullScreen}
graphEventId={graphEventId}
/>
);
}, [
fullScreen,
globalFullScreen,
activeTab,
graphEventId,
onCloseOverlay,
timelineFullScreen,
timelineId,
toggleFullScreen,
]);
return {
onCloseOverlay,
openDetailsPanel,
shouldShowDetailsPanel,
SessionView: sessionViewComponent,
DetailsPanel,
Navigation: navigation,
};
};

View file

@ -51,6 +51,7 @@ const EqlTabContent = lazy(() => import('../eql_tab_content'));
const GraphTabContent = lazy(() => import('../graph_tab_content'));
const NotesTabContent = lazy(() => import('../notes_tab_content'));
const PinnedTabContent = lazy(() => import('../pinned_tab_content'));
const SessionTabContent = lazy(() => import('../session_tab_content'));
interface BasicTimelineTab {
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
@ -59,6 +60,7 @@ interface BasicTimelineTab {
timelineId: TimelineId;
timelineType: TimelineType;
graphEventId?: string;
sessionViewId?: string | null;
timelineDescription: string;
}
@ -106,6 +108,13 @@ const NotesTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) =>
));
NotesTab.displayName = 'NotesTab';
const SessionTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) => (
<Suspense fallback={<EuiLoadingContent lines={10} />}>
<SessionTabContent timelineId={timelineId} />
</Suspense>
));
SessionTab.displayName = 'SessionTab';
const PinnedTab: React.FC<{
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
@ -132,6 +141,8 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
return <GraphTab timelineId={timelineId} />;
case TimelineTabs.notes:
return <NotesTab timelineId={timelineId} />;
case TimelineTabs.session:
return <SessionTab timelineId={timelineId} />;
default:
return null;
}
@ -140,7 +151,8 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
);
const isGraphOrNotesTabs = useMemo(
() => [TimelineTabs.graph, TimelineTabs.notes].includes(activeTimelineTab),
() =>
[TimelineTabs.graph, TimelineTabs.notes, TimelineTabs.session].includes(activeTimelineTab),
[activeTimelineTab]
);
@ -223,6 +235,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
timelineFullScreen,
timelineType,
graphEventId,
sessionViewId,
timelineDescription,
}) => {
const dispatch = useDispatch();
@ -262,33 +275,36 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
[appNotes, allTimelineNoteIds, timelineDescription]
);
const setActiveTab = useCallback(
(tab: TimelineTabs) => {
dispatch(timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: tab }));
},
[dispatch, timelineId]
);
const setQueryAsActiveTab = useCallback(() => {
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query })
);
}, [dispatch, timelineId]);
setActiveTab(TimelineTabs.query);
}, [setActiveTab]);
const setEqlAsActiveTab = useCallback(() => {
dispatch(timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.eql }));
}, [dispatch, timelineId]);
setActiveTab(TimelineTabs.eql);
}, [setActiveTab]);
const setGraphAsActiveTab = useCallback(() => {
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })
);
}, [dispatch, timelineId]);
setActiveTab(TimelineTabs.graph);
}, [setActiveTab]);
const setNotesAsActiveTab = useCallback(() => {
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes })
);
}, [dispatch, timelineId]);
setActiveTab(TimelineTabs.notes);
}, [setActiveTab]);
const setPinnedAsActiveTab = useCallback(() => {
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.pinned })
);
}, [dispatch, timelineId]);
setActiveTab(TimelineTabs.pinned);
}, [setActiveTab]);
const setSessionAsActiveTab = useCallback(() => {
setActiveTab(TimelineTabs.session);
}, [setActiveTab]);
useEffect(() => {
if (!graphEventId && activeTab === TimelineTabs.graph) {
@ -331,6 +347,15 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
>
{i18n.ANALYZER_TAB}
</EuiTab>
<EuiTab
data-test-subj={`timelineTabs-${TimelineTabs.session}`}
onClick={setSessionAsActiveTab}
isSelected={activeTab === TimelineTabs.session}
disabled={sessionViewId === null}
key={TimelineTabs.session}
>
{i18n.SESSION_TAB}
</EuiTab>
<StyledEuiTab
data-test-subj={`timelineTabs-${TimelineTabs.notes}`}
onClick={setNotesAsActiveTab}

View file

@ -38,3 +38,10 @@ export const PINNED_TAB = i18n.translate(
defaultMessage: 'Pinned',
}
);
export const SESSION_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.sessionTabTimelineTitle',
{
defaultMessage: 'Session View',
}
);

View file

@ -81,6 +81,11 @@ export const updateTimelineGraphEventId = actionCreator<{ id: string; graphEvent
'UPDATE_TIMELINE_GRAPH_EVENT_ID'
);
export const updateTimelineSessionViewSessionId = actionCreator<{
id: string;
eventId: string | null;
}>('UPDATE_TIMELINE_SESSION_VIEW_SESSION_ID');
export const unPinEvent = actionCreator<{ id: string; eventId: string }>('UN_PIN_EVENT');
export const updateTimeline = actionCreator<{

View file

@ -65,6 +65,7 @@ export const timelineDefaults: SubsetTimelineModel &
savedObjectId: null,
selectAll: false,
selectedEventIds: {},
sessionViewId: null,
show: false,
showCheckboxes: false,
sort: [

View file

@ -159,6 +159,7 @@ describe('Epic Timeline', () => {
dateRange: { start: '2019-10-30T21:06:27.644Z', end: '2019-10-31T21:06:27.644Z' },
savedObjectId: '11169110-fc22-11e9-8ca9-072f15ce2685',
selectedEventIds: {},
sessionViewId: null,
show: true,
showCheckboxes: false,
sort: [{ columnId: '@timestamp', columnType: 'number', sortDirection: Direction.desc }],

View file

@ -287,6 +287,26 @@ export const updateGraphEventId = ({
};
};
export const updateSessionViewSessionId = ({
id,
eventId,
timelineById,
}: {
id: string;
eventId: string | null;
timelineById: TimelineById;
}): TimelineById => {
const timeline = timelineById[id];
return {
...timelineById,
[id]: {
...timeline,
sessionViewId: eventId,
},
};
};
const queryMatchCustomizer = (dp1: QueryMatch, dp2: QueryMatch) => {
if (dp1.field === dp2.field && dp1.value === dp2.value && dp1.operator === dp2.operator) {
return true;

View file

@ -63,6 +63,7 @@ export type TimelineModel = TGridModelForTimeline & {
resolveTimelineConfig?: ResolveTimelineConfig;
showSaveModal?: boolean;
savedQueryId?: string | null;
sessionViewId: string | null;
/** When true, show the timeline flyover */
show: boolean;
/** status: active | draft */
@ -118,6 +119,7 @@ export type SubsetTimelineModel = Readonly<
| 'dateRange'
| 'selectAll'
| 'selectedEventIds'
| 'sessionViewId'
| 'show'
| 'showCheckboxes'
| 'sort'

View file

@ -123,6 +123,7 @@ const basicTimeline: TimelineModel = {
savedObjectId: null,
selectAll: false,
selectedEventIds: {},
sessionViewId: null,
show: true,
showCheckboxes: false,
sort: [

View file

@ -44,6 +44,7 @@ import {
updateTimeline,
updateTimelineGraphEventId,
updateTitleAndDescription,
updateTimelineSessionViewSessionId,
toggleModalSaveTimeline,
updateEqlOptions,
setTimelineUpdatedAt,
@ -77,6 +78,7 @@ import {
updateGraphEventId,
updateFilters,
updateTimelineEventType,
updateSessionViewSessionId,
} from './helpers';
import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types';
@ -146,6 +148,10 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
...state,
timelineById: updateGraphEventId({ id, graphEventId, timelineById: state.timelineById }),
}))
.case(updateTimelineSessionViewSessionId, (state, { id, eventId }) => ({
...state,
timelineById: updateSessionViewSessionId({ id, eventId, timelineById: state.timelineById }),
}))
.case(pinEvent, (state, { id, eventId }) => ({
...state,
timelineById: pinTimelineEvent({ id, eventId, timelineById: state.timelineById }),

View file

@ -25,7 +25,7 @@ import type {
import type { CasesUiStart } from '../../cases/public';
import type { SecurityPluginSetup } from '../../security/public';
import type { TimelinesUIStart } from '../../timelines/public';
import type { SessionViewUIStart } from '../../session_view/public';
import type { SessionViewStart } from '../../session_view/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { MlPluginSetup, MlPluginStart } from '../../ml/public';
@ -66,12 +66,12 @@ export interface StartPlugins {
newsfeed?: NewsfeedPublicPluginStart;
triggersActionsUi: TriggersActionsStart;
timelines: TimelinesUIStart;
sessionView: SessionViewStart;
uiActions: UiActionsStart;
ml?: MlPluginStart;
spaces?: SpacesPluginStart;
dataViewFieldEditor: IndexPatternFieldEditorStart;
osquery?: OsqueryPluginStart;
sessionView: SessionViewUIStart;
}
export type StartServices = CoreStart &

View file

@ -82,7 +82,6 @@ export const buildProcessTree = (
events.forEach((event) => {
const process = processMap[event.process.entity_id];
const parentProcess = processMap[event.process.parent?.entity_id];
// if session leader, or process already has a parent, return
if (process.id === sessionEntityId || process.parent) {
return;
@ -110,12 +109,14 @@ export const buildProcessTree = (
// with this new page of events processed, lets try re-parent any orphans
orphans?.forEach((process) => {
const parentProcess = processMap[process.getDetails().process.parent.entity_id];
const parentProcessId = process.getDetails().process.parent?.entity_id;
if (parentProcess) {
if (parentProcessId) {
const parentProcess = processMap[parentProcessId];
process.parent = parentProcess; // handy for recursive operations (like auto expand)
parentProcess.children.push(process);
if (parentProcess !== undefined) {
parentProcess.children.push(process);
}
} else {
newOrphans.push(process);
}

View file

@ -152,11 +152,11 @@ export class ProcessImpl implements Process {
group_leader: groupLeader,
} = event.process;
const parentIsASessionLeader = parent.pid === sessionLeader.pid; // possibly bash, zsh or some other shell
const processIsAGroupLeader = pid === groupLeader.pid;
const parentIsASessionLeader = parent && sessionLeader && parent.pid === sessionLeader.pid;
const processIsAGroupLeader = groupLeader && pid === groupLeader.pid;
const sessionIsInteractive = !!tty;
return sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader;
return !!(sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader);
}
getMaxAlertLevel() {
@ -184,6 +184,7 @@ export class ProcessImpl implements Process {
// to be used as a source for the most up to date details
// on the processes lifecycle.
getDetailsMemo = memoizeOne((events: ProcessEvent[]) => {
// TODO: add these to generator
const actionsToFind = [EventAction.fork, EventAction.exec, EventAction.end];
const filtered = events.filter((processEvent) => {
return actionsToFind.includes(processEvent.event.action);
@ -192,7 +193,7 @@ export class ProcessImpl implements Process {
// because events is already ordered by @timestamp we take the last event
// which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event.
// If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time)
return filtered[filtered.length - 1] || ({} as ProcessEvent);
return filtered[filtered.length - 1];
});
}

View file

@ -26,7 +26,7 @@ export const useStyles = () => {
maxWidth: 800,
maxHeight: 378,
overflowY: 'auto',
backgroundColor: 'white',
backgroundColor: colors.emptyShade,
};
return {

View file

@ -205,7 +205,7 @@ export function ProcessTreeNode({
const shouldRenderChildren = childrenExpanded && children?.length > 0;
const childrenTreeDepth = depth + 1;
const showUserEscalation = user.id !== parent.user.id;
const showUserEscalation = user.id && user.id !== parent.user?.id;
const interactiveSession = !!tty;
const sessionIcon = interactiveSession ? 'consoleApp' : 'compute';
const iconTestSubj = hasExec

View file

@ -8,11 +8,11 @@ import React, { useState, useCallback, useEffect } from 'react';
import {
EuiEmptyPrompt,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiResizableContainer,
EuiPanel,
EuiHorizontalRule,
EuiFlexGroup,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SectionLoading } from '../../shared_imports';

View file

@ -11,17 +11,17 @@ import { CSSObject } from '@emotion/react';
import { euiLightVars as theme } from '@kbn/ui-theme';
interface StylesDeps {
height: number | undefined;
height: string | undefined;
}
export const useStyles = ({ height = 500 }: StylesDeps) => {
export const useStyles = ({ height = '500px' }: StylesDeps) => {
const { euiTheme } = useEuiTheme();
const cached = useMemo(() => {
const { border } = euiTheme;
const processTree: CSSObject = {
height: `${height}px`,
height: `${height}`,
position: 'relative',
};
@ -34,6 +34,11 @@ export const useStyles = ({ height = 500 }: StylesDeps) => {
zIndex: 2,
};
const nonGrowGroup: CSSObject = {
display: 'flex',
flexGrow: 0,
alignItems: 'stretch',
};
const searchBar: CSSObject = {
position: 'relative',
margin: `${euiTheme.size.m} ${euiTheme.size.xs} !important`,
@ -55,6 +60,7 @@ export const useStyles = ({ height = 500 }: StylesDeps) => {
return {
processTree,
detailPanel,
nonGrowGroup,
resizeHandle,
searchBar,
buttonsEyeDetail,

View file

@ -7,7 +7,7 @@
import { SessionViewPlugin } from './plugin';
export type { SessionViewUIStart } from './types';
export type { SessionViewStart } from './types';
export function plugin() {
return new SessionViewPlugin();

View file

@ -4,20 +4,16 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ReactElement, ReactNode } from 'react';
import { ReactNode } from 'react';
import { CoreStart } from '../../../../src/core/public';
import { ProcessEvent, Teletype } from '../common/types/process_tree';
export type SessionViewServices = CoreStart;
export interface SessionViewUIStart {
getSessionView: (sessionEntityId: string) => ReactElement;
}
export interface SessionViewDeps {
// the root node of the process tree to render. e.g process.entry.entity_id or process.session_leader.entity_id
sessionEntityId: string;
height?: number;
height?: string;
// if provided, the session view will jump to and select the provided event if it belongs to the session leader
// session view will fetch a page worth of events starting from jumpToEvent as well as a page backwards.
jumpToEvent?: ProcessEvent;
@ -72,3 +68,7 @@ export interface DetailPanelProcessLeader {
entryMetaSourceIp: string;
executable: string;
}
export interface SessionViewStart {
getSessionView: (props: SessionViewDeps) => JSX.Element;
}

View file

@ -10,6 +10,9 @@ import { Ext } from '../file';
export interface ProcessEcs {
Ext?: Ext;
entity_id?: string[];
entry_leader?: ProcessSessionData;
session_leader?: ProcessSessionData;
group_leader?: ProcessSessionData;
exit_code?: number[];
hash?: ProcessHashData;
parent?: ProcessParentData;
@ -23,6 +26,12 @@ export interface ProcessEcs {
working_directory?: string[];
}
export interface ProcessSessionData {
entity_id?: string[];
pid?: string[];
name?: string[];
}
export interface ProcessHashData {
md5?: string[];
sha1?: string[];

View file

@ -466,6 +466,7 @@ export enum TimelineTabs {
graph = 'graph',
notes = 'notes',
pinned = 'pinned',
session = 'session',
eql = 'eql',
}

View file

@ -58,6 +58,7 @@ const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
${({ $isFullScreen }) =>
$isFullScreen &&

View file

@ -218,6 +218,15 @@ export const TIMELINE_EVENTS_FIELDS = [
'process.start',
'process.title',
'process.working_directory',
'process.entry_leader.entity_id',
'process.entry_leader.name',
'process.entry_leader.pid',
'process.session_leader.entity_id',
'process.session_leader.name',
'process.session_leader.pid',
'process.group_leader.entity_id',
'process.group_leader.name',
'process.group_leader.pid',
'zeek.session_id',
'zeek.connection.local_resp',
'zeek.connection.local_orig',