Skip to content

Commit d4a3c96

Browse files
[Security Solution][Alert details] - improving session view experience in expandable flyout (#200270)
## Summary This [PR](#192531) started the move of the analyzer and session view components from the table to the flyout. Shortly after we added an advanced settings (via this [PR](#194012)) to allow users to switch back and forth between the old table view and the flyout view. This current PR focuses on the session view component and enhances its user experience, when rendered in the expandable flyout. No changes should be made for the user in the table as well as the other usages of the session view component (like for example the Kubernetes dashboard). #### Old UI (in table) https://github.com/user-attachments/assets/015b32fc-69bb-4526-a42d-accad085ad43 ####. New UI (in flyout) https://github.com/user-attachments/assets/9a3eacbf-bf2b-43d4-8e74-ea933ee0d498 As can seen in the video above, when the session view component is opened in the expandable flyout, we show the tree view and the detailed panel separated. This allow for better use of the horizontal space, especially visible on a wide monitor. This is also combined with the fact that the flyout is resizable (and can take the whole screen) and the preview panel is also resizable, to provide more space to the detailed panel. Note: the session view full screen functionality is lost, but this is by design. As mentioned above, the user can resize the flyout's width to take the full screen, and the flyout's vertical space is already near full height. ## Code decisions To guarantee as much as possible that the usage of the Session View component in the table or in the other places (like the Kubernetes dashboard) were not impacted by this PR, only additive changes were made. All these changes are also protected behind `if` conditions, that should only be run when the correct props are being passed in. Some components (like the content of each of the tabs of the detailed panels - Process, Metadata and Alerts) as well as a hook, are exposed outisde of the `session_view` plugin, to be reused in the expandable flyout directly. Code changes were kept to a bare minimum in the `session_view` plugin! ## What to test - functionality of the Session View component should be exactly the same when used in the table as when used in the flyout: - clicking on a row in the tree should update the detailed panel accordingly - jumping to a process from the detailed panel should correctly update the tree - viewing the details of an alert should work - the - the UI will be mostly the same, with some small tweaks: - viewing an alert details now opens a preview panel instead of the flyout. The user can go back to the previous panel by clicking on the `Back` button in the top-left corner - the alerts tab does not show the number of alerts as it previously was. We might be able to get this to work later, but after discussing with Product this is an acceptable solution as the feature is still behind an Advanced Settings - the `Open details` has been replaced by a `expand` icon button, to be more consistent with the rest of the UI in the flyout ### Notes: - there is a small update in the analyzer graph to the icon used in the open detail button. We're now using the `expand` icon to be consistent with the Session View component (which already has another `eye` icon) ## How to test - turn on the `securitySolution:enableVisualizationsInFlyout` Advanced Settings ![Screenshot 2024-12-16 at 5 05 05 PM](https://github.com/user-attachments/assets/e5a937fa-7eaf-46b3-be11-d56224daf821) - generate alerts with data for session view (`yarn test:generate -n http://elastic:changeme@localhost:9200 -k http://elastic:changeme@localhost:5601`) --------- Co-authored-by: Paulo Silva <[email protected]>
1 parent 7de3514 commit d4a3c96

File tree

24 files changed

+1026
-183
lines changed

24 files changed

+1026
-183
lines changed

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/session_view.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import {
2121
ENTRY_LEADER_ENTITY_ID,
2222
ENTRY_LEADER_START,
2323
} from '../../shared/constants/field_names';
24-
import { useSessionPreview } from '../../right/hooks/use_session_preview';
24+
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
2525
import { useSourcererDataView } from '../../../../sourcerer/containers';
2626
import { mockContextValue } from '../../shared/mocks/mock_context';
2727
import { useLicense } from '../../../../common/hooks/use_license';
2828

29-
jest.mock('../../right/hooks/use_session_preview');
29+
jest.mock('../../shared/hooks/use_session_view_config');
3030
jest.mock('../../../../common/hooks/use_license');
3131
jest.mock('../../../../sourcerer/containers');
3232

@@ -80,7 +80,7 @@ const renderSessionView = (contextValue: DocumentDetailsContext = mockContextVal
8080

8181
describe('<SessionView />', () => {
8282
beforeEach(() => {
83-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
83+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
8484
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
8585
jest.mocked(useSourcererDataView).mockReturnValue({
8686
browserFields: {},
@@ -120,7 +120,7 @@ describe('<SessionView />', () => {
120120

121121
it('should render error message and text in header if no sessionConfig', () => {
122122
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
123-
(useSessionPreview as jest.Mock).mockReturnValue(null);
123+
(useSessionViewConfig as jest.Mock).mockReturnValue(null);
124124

125125
const { getByTestId } = renderSessionView();
126126
expect(getByTestId(SESSION_VIEW_NO_DATA_TEST_ID)).toHaveTextContent(NO_DATA_MESSAGE);

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/session_view.tsx

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,24 @@
66
*/
77

88
import type { FC } from 'react';
9-
import React, { useCallback, useMemo } from 'react';
9+
import React, { memo, useCallback, useMemo } from 'react';
1010
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
11-
import type { TableId } from '@kbn/securitysolution-data-table';
1211
import { EuiPanel } from '@elastic/eui';
13-
import {
14-
ANCESTOR_INDEX,
15-
ENTRY_LEADER_ENTITY_ID,
16-
ENTRY_LEADER_START,
17-
} from '../../shared/constants/field_names';
18-
import { getField } from '../../shared/utils';
12+
import type { Process } from '@kbn/session-view-plugin/common';
13+
import type { CustomProcess } from '../../session_view/context';
14+
import { useUserPrivileges } from '../../../../common/components/user_privileges';
1915
import { SESSION_VIEW_TEST_ID } from './test_ids';
20-
import { isActiveTimeline } from '../../../../helpers';
2116
import { useSourcererDataView } from '../../../../sourcerer/containers';
22-
import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys';
17+
import {
18+
DocumentDetailsPreviewPanelKey,
19+
DocumentDetailsSessionViewPanelKey,
20+
} from '../../shared/constants/panel_keys';
2321
import { useKibana } from '../../../../common/lib/kibana';
2422
import { useDocumentDetailsContext } from '../../shared/context';
2523
import { SourcererScopeName } from '../../../../sourcerer/store/model';
26-
import { detectionsTimelineIds } from '../../../../timelines/containers/helpers';
2724
import { ALERT_PREVIEW_BANNER } from '../../preview/constants';
2825
import { useLicense } from '../../../../common/hooks/use_license';
29-
import { useSessionPreview } from '../../right/hooks/use_session_preview';
26+
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
3027
import { SessionViewNoDataMessage } from '../../shared/components/session_view_no_data_message';
3128
import { DocumentEventTypes } from '../../../../common/lib/telemetry';
3229

@@ -35,46 +32,47 @@ export const SESSION_VIEW_ID = 'session-view';
3532
/**
3633
* Session view displayed in the document details expandable flyout left section under the Visualize tab
3734
*/
38-
export const SessionView: FC = () => {
35+
export const SessionView: FC = memo(() => {
3936
const { sessionView, telemetry } = useKibana().services;
40-
const { getFieldsData, indexName, scopeId, dataFormattedForFieldBrowser } =
41-
useDocumentDetailsContext();
37+
const {
38+
eventId,
39+
indexName,
40+
getFieldsData,
41+
scopeId,
42+
dataFormattedForFieldBrowser,
43+
jumpToEntityId,
44+
jumpToCursor,
45+
} = useDocumentDetailsContext();
46+
47+
const { canReadPolicyManagement } = useUserPrivileges().endpointPrivileges;
4248

43-
const sessionViewConfig = useSessionPreview({ getFieldsData, dataFormattedForFieldBrowser });
49+
const sessionViewConfig = useSessionViewConfig({ getFieldsData, dataFormattedForFieldBrowser });
4450
const isEnterprisePlus = useLicense().isEnterprise();
4551
const isEnabled = sessionViewConfig && isEnterprisePlus;
4652

47-
const ancestorIndex = getField(getFieldsData(ANCESTOR_INDEX)); // e.g in case of alert, we want to grab it's origin index
48-
const sessionEntityId = getField(getFieldsData(ENTRY_LEADER_ENTITY_ID)) || '';
49-
const sessionStartTime = getField(getFieldsData(ENTRY_LEADER_START)) || '';
50-
const index = ancestorIndex || indexName;
51-
52-
const sourcererScope = useMemo(() => {
53-
if (isActiveTimeline(scopeId)) {
54-
return SourcererScopeName.timeline;
55-
} else if (detectionsTimelineIds.includes(scopeId as TableId)) {
56-
return SourcererScopeName.detections;
57-
} else {
58-
return SourcererScopeName.default;
59-
}
60-
}, [scopeId]);
61-
62-
const { selectedPatterns } = useSourcererDataView(sourcererScope);
53+
const { selectedPatterns } = useSourcererDataView(SourcererScopeName.detections);
6354
const eventDetailsIndex = useMemo(() => selectedPatterns.join(','), [selectedPatterns]);
6455

65-
const { openPreviewPanel } = useExpandableFlyoutApi();
56+
const { openPreviewPanel, closePreviewPanel } = useExpandableFlyoutApi();
6657
const openAlertDetailsPreview = useCallback(
67-
(eventId?: string, onClose?: () => void) => {
68-
openPreviewPanel({
69-
id: DocumentDetailsPreviewPanelKey,
70-
params: {
71-
id: eventId,
72-
indexName: eventDetailsIndex,
73-
scopeId,
74-
banner: ALERT_PREVIEW_BANNER,
75-
isPreviewMode: true,
76-
},
77-
});
58+
(evtId?: string, onClose?: () => void) => {
59+
// In the SessionView component, when the user clicks on the
60+
// expand button to open a alert in the preview panel, this actually also selects the row and opens
61+
// the detailed panel in preview.
62+
// In order to NOT modify the SessionView code, the setTimeout here guarantees that the alert details preview
63+
// will be opened in second, so that we have a correct order in the opened preview panels
64+
setTimeout(() => {
65+
openPreviewPanel({
66+
id: DocumentDetailsPreviewPanelKey,
67+
params: {
68+
id: evtId,
69+
indexName: eventDetailsIndex,
70+
scopeId,
71+
banner: ALERT_PREVIEW_BANNER,
72+
isPreviewMode: true,
73+
},
74+
});
75+
}, 100);
7876
telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, {
7977
location: scopeId,
8078
panel: 'preview',
@@ -83,14 +81,63 @@ export const SessionView: FC = () => {
8381
[openPreviewPanel, eventDetailsIndex, scopeId, telemetry]
8482
);
8583

84+
const openDetailsInPreview = useCallback(
85+
(selectedProcess: Process | null) => {
86+
// We cannot pass the original Process object sent from the SessionView component
87+
// as it contains functions (that should not put into Redux)
88+
// and also some recursive properties (that will break rison.encode when updating the URL)
89+
const simplifiedSelectedProcess: CustomProcess | null = selectedProcess
90+
? {
91+
id: selectedProcess.id,
92+
details: selectedProcess.getDetails(),
93+
endTime: selectedProcess.getEndTime(),
94+
}
95+
: null;
96+
97+
openPreviewPanel({
98+
id: DocumentDetailsSessionViewPanelKey,
99+
params: {
100+
eventId,
101+
indexName,
102+
selectedProcess: simplifiedSelectedProcess,
103+
index: sessionViewConfig?.index,
104+
sessionEntityId: sessionViewConfig?.sessionEntityId,
105+
sessionStartTime: sessionViewConfig?.sessionStartTime,
106+
investigatedAlertId: sessionViewConfig?.investigatedAlertId,
107+
scopeId,
108+
jumpToEntityId,
109+
jumpToCursor,
110+
},
111+
});
112+
},
113+
[
114+
openPreviewPanel,
115+
eventId,
116+
indexName,
117+
sessionViewConfig?.index,
118+
sessionViewConfig?.sessionEntityId,
119+
sessionViewConfig?.sessionStartTime,
120+
sessionViewConfig?.investigatedAlertId,
121+
scopeId,
122+
jumpToEntityId,
123+
jumpToCursor,
124+
]
125+
);
126+
127+
const closeDetailsInPreview = useCallback(() => closePreviewPanel(), [closePreviewPanel]);
128+
86129
return isEnabled ? (
87130
<div data-test-subj={SESSION_VIEW_TEST_ID}>
88131
{sessionView.getSessionView({
89-
index,
90-
sessionEntityId,
91-
sessionStartTime,
132+
...sessionViewConfig,
92133
isFullScreen: true,
93134
loadAlertDetails: openAlertDetailsPreview,
135+
openDetailsInExpandableFlyout: (selectedProcess: Process | null) =>
136+
openDetailsInPreview(selectedProcess),
137+
closeDetailsInExpandableFlyout: () => closeDetailsInPreview(),
138+
canReadPolicyManagement,
139+
resetJumpToEntityId: jumpToEntityId,
140+
resetJumpToCursor: jumpToCursor,
94141
})}
95142
</div>
96143
) : (
@@ -101,6 +148,6 @@ export const SessionView: FC = () => {
101148
/>
102149
</EuiPanel>
103150
);
104-
};
151+
});
105152

106153
SessionView.displayName = 'SessionView';

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { TestProviders } from '../../../../common/mock';
1010
import React from 'react';
1111
import { DocumentDetailsContext } from '../../shared/context';
1212
import { SessionPreviewContainer } from './session_preview_container';
13-
import { useSessionPreview } from '../hooks/use_session_preview';
13+
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
1414
import { useLicense } from '../../../../common/hooks/use_license';
1515
import { SESSION_PREVIEW_TEST_ID } from './test_ids';
1616
import {
@@ -24,7 +24,7 @@ import { mockContextValue } from '../../shared/mocks/mock_context';
2424
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
2525
import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline';
2626

27-
jest.mock('../hooks/use_session_preview');
27+
jest.mock('../../shared/hooks/use_session_view_config');
2828
jest.mock('../../../../common/hooks/use_license');
2929
jest.mock('../../../../common/hooks/use_experimental_features');
3030
jest.mock(
@@ -84,7 +84,7 @@ describe('SessionPreviewContainer', () => {
8484
});
8585

8686
it('should render component and link in header', () => {
87-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
87+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
8888
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
8989

9090
const { getByTestId } = renderSessionPreview();
@@ -115,7 +115,7 @@ describe('SessionPreviewContainer', () => {
115115
});
116116

117117
it('should render error message and text in header if no sessionConfig', () => {
118-
(useSessionPreview as jest.Mock).mockReturnValue(null);
118+
(useSessionViewConfig as jest.Mock).mockReturnValue(null);
119119
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
120120

121121
const { getByTestId, queryByTestId } = renderSessionPreview();
@@ -133,7 +133,7 @@ describe('SessionPreviewContainer', () => {
133133
});
134134

135135
it('should render upsell message in header if no correct license', () => {
136-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
136+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
137137
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => false });
138138

139139
const { getByTestId, queryByTestId } = renderSessionPreview();
@@ -152,7 +152,7 @@ describe('SessionPreviewContainer', () => {
152152
});
153153

154154
it('should not render link to session viewer if flyout is open in preview', () => {
155-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
155+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
156156
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
157157

158158
const { getByTestId, queryByTestId } = renderSessionPreview({
@@ -179,7 +179,7 @@ describe('SessionPreviewContainer', () => {
179179
});
180180

181181
it('should not render link to session viewer if flyout is open in preview mode', () => {
182-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
182+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
183183
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
184184

185185
const { getByTestId, queryByTestId } = renderSessionPreview({
@@ -199,7 +199,7 @@ describe('SessionPreviewContainer', () => {
199199
describe('when visualization in flyout flag is enabled', () => {
200200
it('should open left panel vizualization tab when visualization in flyout flag is on', () => {
201201
mockUseUiSetting.mockReturnValue([true]);
202-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
202+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
203203
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
204204

205205
const { getByTestId } = renderSessionPreview();
@@ -212,7 +212,7 @@ describe('SessionPreviewContainer', () => {
212212
});
213213

214214
it('should not render link to session viewer if flyout is open in rule preview', () => {
215-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
215+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
216216
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
217217

218218
const { getByTestId, queryByTestId } = renderSessionPreview({
@@ -230,7 +230,7 @@ describe('SessionPreviewContainer', () => {
230230
});
231231

232232
it('should not render link to session viewer if flyout is open in preview mode', () => {
233-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
233+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
234234
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
235235

236236
const { getByTestId, queryByTestId } = renderSessionPreview({
@@ -253,7 +253,7 @@ describe('SessionPreviewContainer', () => {
253253
beforeEach(() => {
254254
jest.clearAllMocks();
255255
mockUseUiSetting.mockReturnValue([true]);
256-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
256+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
257257
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
258258
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
259259
});
@@ -304,7 +304,7 @@ describe('SessionPreviewContainer', () => {
304304
beforeEach(() => {
305305
jest.clearAllMocks();
306306
mockUseUiSetting.mockReturnValue([false]);
307-
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
307+
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
308308
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
309309
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
310310
});

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
1313
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants';
1414
import { useLicense } from '../../../../common/hooks/use_license';
1515
import { SessionPreview } from './session_preview';
16-
import { useSessionPreview } from '../hooks/use_session_preview';
16+
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
1717
import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline';
1818
import { useDocumentDetailsContext } from '../../shared/context';
1919
import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions';
@@ -48,7 +48,7 @@ export const SessionPreviewContainer: FC = () => {
4848
);
4949

5050
// decide whether to show the session view or not
51-
const sessionViewConfig = useSessionPreview({ getFieldsData, dataFormattedForFieldBrowser });
51+
const sessionViewConfig = useSessionViewConfig({ getFieldsData, dataFormattedForFieldBrowser });
5252
const isEnterprisePlus = useLicense().isEnterprise();
5353
const isEnabled = sessionViewConfig && isEnterprisePlus;
5454

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { FC } from 'react';
9+
import React, { useMemo } from 'react';
10+
import type { SessionViewPanelPaths } from '.';
11+
import type { SessionViewPanelTabType } from './tabs';
12+
import { FlyoutBody } from '../../shared/components/flyout_body';
13+
14+
export interface PanelContentProps {
15+
/**
16+
* Id of the tab selected in the parent component to display its content
17+
*/
18+
selectedTabId: SessionViewPanelPaths;
19+
/**
20+
* Tabs display right below the flyout's header
21+
*/
22+
tabs: SessionViewPanelTabType[];
23+
}
24+
25+
/**
26+
* SessionView preview panel content, that renders the process, metadata and alerts tab contents.
27+
*/
28+
export const PanelContent: FC<PanelContentProps> = ({ selectedTabId, tabs }) => {
29+
const selectedTabContent = useMemo(() => {
30+
return tabs.find((tab) => tab.id === selectedTabId)?.content;
31+
}, [selectedTabId, tabs]);
32+
33+
return <FlyoutBody>{selectedTabContent}</FlyoutBody>;
34+
};
35+
36+
PanelContent.displayName = 'PanelContent';

0 commit comments

Comments
 (0)