Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Alert details] - improving session view experience in expandable flyout #200270

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import {
ENTRY_LEADER_ENTITY_ID,
ENTRY_LEADER_START,
} from '../../shared/constants/field_names';
import { useSessionPreview } from '../../right/hooks/use_session_preview';
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import { mockContextValue } from '../../shared/mocks/mock_context';
import { useLicense } from '../../../../common/hooks/use_license';

jest.mock('../../right/hooks/use_session_preview');
jest.mock('../../shared/hooks/use_session_view_config');
jest.mock('../../../../common/hooks/use_license');
jest.mock('../../../../sourcerer/containers');

Expand Down Expand Up @@ -80,7 +80,7 @@ const renderSessionView = (contextValue: DocumentDetailsContext = mockContextVal

describe('<SessionView />', () => {
beforeEach(() => {
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
jest.mocked(useSourcererDataView).mockReturnValue({
browserFields: {},
Expand Down Expand Up @@ -120,7 +120,7 @@ describe('<SessionView />', () => {

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

const { getByTestId } = renderSessionView();
expect(getByTestId(SESSION_VIEW_NO_DATA_TEST_ID)).toHaveTextContent(NO_DATA_MESSAGE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@ import React, { useCallback, useMemo } from 'react';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { TableId } from '@kbn/securitysolution-data-table';
import { EuiPanel } from '@elastic/eui';
import {
ANCESTOR_INDEX,
ENTRY_LEADER_ENTITY_ID,
ENTRY_LEADER_START,
} from '../../shared/constants/field_names';
import { getField } from '../../shared/utils';
import type { Process } from '@kbn/session-view-plugin/common';
import type { CustomProcess } from '../../session_view/context';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { SESSION_VIEW_TEST_ID } from './test_ids';
import { isActiveTimeline } from '../../../../helpers';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys';
import {
DocumentDetailsPreviewPanelKey,
DocumentDetailsSessionViewPanelKey,
} from '../../shared/constants/panel_keys';
import { useKibana } from '../../../../common/lib/kibana';
import { useDocumentDetailsContext } from '../../shared/context';
import { SourcererScopeName } from '../../../../sourcerer/store/model';
import { detectionsTimelineIds } from '../../../../timelines/containers/helpers';
import { ALERT_PREVIEW_BANNER } from '../../preview/constants';
import { useLicense } from '../../../../common/hooks/use_license';
import { useSessionPreview } from '../../right/hooks/use_session_preview';
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
import { SessionViewNoDataMessage } from '../../shared/components/session_view_no_data_message';
import { DocumentEventTypes } from '../../../../common/lib/telemetry';

Expand All @@ -37,18 +37,22 @@ export const SESSION_VIEW_ID = 'session-view';
*/
export const SessionView: FC = () => {
const { sessionView, telemetry } = useKibana().services;
const { getFieldsData, indexName, scopeId, dataFormattedForFieldBrowser } =
useDocumentDetailsContext();
const {
eventId,
indexName,
getFieldsData,
scopeId,
dataFormattedForFieldBrowser,
jumpToEntityId,
jumpToCursor,
} = useDocumentDetailsContext();

const { canReadPolicyManagement } = useUserPrivileges().endpointPrivileges;
PhilippeOberti marked this conversation as resolved.
Show resolved Hide resolved

const sessionViewConfig = useSessionPreview({ getFieldsData, dataFormattedForFieldBrowser });
const sessionViewConfig = useSessionViewConfig({ getFieldsData, dataFormattedForFieldBrowser });
const isEnterprisePlus = useLicense().isEnterprise();
const isEnabled = sessionViewConfig && isEnterprisePlus;

const ancestorIndex = getField(getFieldsData(ANCESTOR_INDEX)); // e.g in case of alert, we want to grab it's origin index
const sessionEntityId = getField(getFieldsData(ENTRY_LEADER_ENTITY_ID)) || '';
const sessionStartTime = getField(getFieldsData(ENTRY_LEADER_START)) || '';
const index = ancestorIndex || indexName;

const sourcererScope = useMemo(() => {
if (isActiveTimeline(scopeId)) {
return SourcererScopeName.timeline;
Expand All @@ -64,17 +68,24 @@ export const SessionView: FC = () => {

const { openPreviewPanel } = useExpandableFlyoutApi();
const openAlertDetailsPreview = useCallback(
(eventId?: string, onClose?: () => void) => {
openPreviewPanel({
id: DocumentDetailsPreviewPanelKey,
params: {
id: eventId,
indexName: eventDetailsIndex,
scopeId,
banner: ALERT_PREVIEW_BANNER,
isPreviewMode: true,
},
});
(evtId?: string, onClose?: () => void) => {
// In the SessionView component, when the user clicks on the
// expand button to open a alert in the preview panel, this actually also selects the row and opens
// the detailed panel in preview.
// In order to NOT modify the SessionView code, the setTimeout here guarantees that the alert details preview
// will be opened in second, so that we have a correct order in the opened preview panels
setTimeout(() => {
openPreviewPanel({
id: DocumentDetailsPreviewPanelKey,
params: {
id: evtId,
indexName: eventDetailsIndex,
scopeId,
banner: ALERT_PREVIEW_BANNER,
isPreviewMode: true,
},
});
}, 100);
telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, {
location: scopeId,
panel: 'preview',
Expand All @@ -83,14 +94,60 @@ export const SessionView: FC = () => {
[openPreviewPanel, eventDetailsIndex, scopeId, telemetry]
);

const openDetailsInPreview = useCallback(
(selectedProcess: Process | null) => {
// We cannot pass the original Process object sent from the SessionView component
// as it contains functions (that should not put into Redux)
// and also some recursive properties (that will break rison.encode when updating the URL)
const simplifiedSelectedProcess: CustomProcess | null = selectedProcess
? {
id: selectedProcess.id,
details: selectedProcess.getDetails(),
endTime: selectedProcess.getEndTime(),
}
: null;

openPreviewPanel({
id: DocumentDetailsSessionViewPanelKey,
params: {
eventId,
indexName,
selectedProcess: simplifiedSelectedProcess,
index: sessionViewConfig?.index,
sessionEntityId: sessionViewConfig?.sessionEntityId,
sessionStartTime: sessionViewConfig?.sessionStartTime,
investigatedAlertId: sessionViewConfig?.investigatedAlertId,
scopeId,
jumpToEntityId,
jumpToCursor,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed there is no preview banner here (not saying the Preview analyzer details makes sense...) but we should align both to have the same UI, wdyt?

image
image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aaaah yup I missed that, on it!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, actually I wonder if in this case it makes sense... We're not really previewing any data, we're just using the preview as a navigation...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so now I wonder if we should remove it from the analyzer graph instead... 🤔

});
},
[
openPreviewPanel,
eventId,
indexName,
sessionViewConfig?.index,
sessionViewConfig?.sessionEntityId,
sessionViewConfig?.sessionStartTime,
sessionViewConfig?.investigatedAlertId,
scopeId,
jumpToEntityId,
jumpToCursor,
]
);

return isEnabled ? (
<div data-test-subj={SESSION_VIEW_TEST_ID}>
{sessionView.getSessionView({
index,
sessionEntityId,
sessionStartTime,
...sessionViewConfig,
isFullScreen: true,
loadAlertDetails: openAlertDetailsPreview,
openDetailsInExpandableFlyout: (selectedProcess: Process | null) =>
openDetailsInPreview(selectedProcess),
canReadPolicyManagement,
resetJumpToEntityId: jumpToEntityId,
resetJumpToCursor: jumpToCursor,
})}
</div>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { TestProviders } from '../../../../common/mock';
import React from 'react';
import { DocumentDetailsContext } from '../../shared/context';
import { SessionPreviewContainer } from './session_preview_container';
import { useSessionPreview } from '../hooks/use_session_preview';
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
import { useLicense } from '../../../../common/hooks/use_license';
import { SESSION_PREVIEW_TEST_ID } from './test_ids';
import {
Expand All @@ -24,7 +24,7 @@ import { mockContextValue } from '../../shared/mocks/mock_context';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline';

jest.mock('../hooks/use_session_preview');
jest.mock('../../shared/hooks/use_session_view_config');
jest.mock('../../../../common/hooks/use_license');
jest.mock('../../../../common/hooks/use_experimental_features');
jest.mock(
Expand Down Expand Up @@ -84,7 +84,7 @@ describe('SessionPreviewContainer', () => {
});

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

const { getByTestId } = renderSessionPreview();
Expand Down Expand Up @@ -115,7 +115,7 @@ describe('SessionPreviewContainer', () => {
});

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

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

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

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

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

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

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

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

const { getByTestId } = renderSessionPreview();
Expand All @@ -212,7 +212,7 @@ describe('SessionPreviewContainer', () => {
});

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

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

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

const { getByTestId, queryByTestId } = renderSessionPreview({
Expand All @@ -253,7 +253,7 @@ describe('SessionPreviewContainer', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseUiSetting.mockReturnValue([true]);
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
});
Expand Down Expand Up @@ -304,7 +304,7 @@ describe('SessionPreviewContainer', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseUiSetting.mockReturnValue([false]);
(useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig);
(useSessionViewConfig as jest.Mock).mockReturnValue(sessionViewConfig);
(useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true });
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants';
import { useLicense } from '../../../../common/hooks/use_license';
import { SessionPreview } from './session_preview';
import { useSessionPreview } from '../hooks/use_session_preview';
import { useSessionViewConfig } from '../../shared/hooks/use_session_view_config';
import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline';
import { useDocumentDetailsContext } from '../../shared/context';
import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions';
Expand Down Expand Up @@ -48,7 +48,7 @@ export const SessionPreviewContainer: FC = () => {
);

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { FC } from 'react';
import React, { useMemo } from 'react';
import type { SessionViewPanelPaths } from '.';
import type { SessionViewPanelTabType } from './tabs';
import { FlyoutBody } from '../../shared/components/flyout_body';

export interface PanelContentProps {
/**
* Id of the tab selected in the parent component to display its content
*/
selectedTabId: SessionViewPanelPaths;
/**
* Tabs display right below the flyout's header
*/
tabs: SessionViewPanelTabType[];
}

/**
* SessionView preview panel content, that renders the process, metadata and alerts tab contents.
*/
export const PanelContent: FC<PanelContentProps> = ({ selectedTabId, tabs }) => {
const selectedTabContent = useMemo(() => {
return tabs.find((tab) => tab.id === selectedTabId)?.content;
}, [selectedTabId, tabs]);

return <FlyoutBody>{selectedTabContent}</FlyoutBody>;
};

PanelContent.displayName = 'PanelContent';
Loading