diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index de53b7190c0df..3c25b5abfc3ec 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -11,7 +11,12 @@ import styled from 'styled-components'; import type { Filter } from '@kbn/es-query'; import { inputsModel, State } from '../../store'; import { inputsActions } from '../../store/actions'; -import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; +import { + ControlColumnProps, + RowRenderer, + TimelineId, + TimelineTabs, +} from '../../../../common/types/timeline'; import { APP_ID, APP_UI_ID } from '../../../../common/constants'; import { timelineActions } from '../../../timelines/store/timeline'; import type { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; @@ -24,7 +29,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 +37,7 @@ import { useFieldBrowserOptions, FieldEditorActions, } from '../../../timelines/components/fields_browser'; +import { useDetailPanel } from '../../../timelines/components/side_panel/hooks/use_detail_panel'; const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; @@ -105,8 +110,8 @@ const StatefulEventsViewerComponent: React.FC = ({ itemsPerPage, itemsPerPageOptions, kqlMode, - showCheckboxes, sessionViewId, + showCheckboxes, sort, } = defaultModel, } = useSelector((state: State) => eventsViewerSelector(state, id)); @@ -156,11 +161,22 @@ const StatefulEventsViewerComponent: React.FC = ({ const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); const trailingControlColumns: ControlColumnProps[] = EMPTY_CONTROL_COLUMNS; + + const { openDetailsPanel, DetailsPanel } = useDetailPanel({ + isFlyoutView: true, + entityType, + sourcererScope: SourcererScopeName.timeline, + timelineId: id, + tabType: TimelineTabs.query, + }); + const graphOverlay = useMemo(() => { const shouldShowOverlay = (graphEventId != null && graphEventId.length > 0) || sessionViewId !== null; - return shouldShowOverlay ? : null; - }, [graphEventId, id, sessionViewId]); + return shouldShowOverlay ? ( + + ) : null; + }, [graphEventId, id, sessionViewId, openDetailsPanel]); const setQuery = useCallback( (inspect, loading, refetch) => { dispatch(inputsActions.setQuery({ id, inputId: 'global', inspect, loading, refetch })); @@ -240,14 +256,7 @@ const StatefulEventsViewerComponent: React.FC = ({ })} - + {DetailsPanel} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index aa1bd17302bd3..c8b845cf370a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -80,7 +80,8 @@ const ScrollableFlexItem = styled(EuiFlexItem)` width: 100%; `; -interface OwnProps { +interface GraphOverlayProps { + openDetailsPanel: (eventId?: string, onClose?: () => void) => void; timelineId: TimelineId; } @@ -132,7 +133,7 @@ NavigationComponent.displayName = 'NavigationComponent'; const Navigation = React.memo(NavigationComponent); -const GraphOverlayComponent: React.FC = ({ timelineId }) => { +const GraphOverlayComponent: React.FC = ({ timelineId, openDetailsPanel }) => { const dispatch = useDispatch(); const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); @@ -147,9 +148,12 @@ const GraphOverlayComponent: React.FC = ({ timelineId }) => { ); const sessionViewMain = useMemo(() => { return sessionViewId !== null - ? sessionView.getSessionView({ sessionEntityId: sessionViewId }) + ? sessionView.getSessionView({ + sessionEntityId: sessionViewId, + loadAlertDetails: openDetailsPanel, + }) : null; - }, [sessionView, sessionViewId]); + }, [sessionView, sessionViewId, openDetailsPanel]); const getStartSelector = useMemo(() => startSelector(), []); const getEndSelector = useMemo(() => endSelector(), []); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx new file mode 100644 index 0000000000000..4bc5ba44c1695 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx @@ -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(` + + `); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx new file mode 100644 index 0000000000000..4c5f4d048f367 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx @@ -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 ? ( + + ) : null, + [ + browserFields, + docValueFields, + entityType, + handleOnDetailsPanelClosed, + isFlyoutView, + runtimeMappings, + shouldShowDetailsPanel, + tabType, + timelineId, + ] + ); + + return { + openDetailsPanel, + handleOnDetailsPanelClosed, + shouldShowDetailsPanel, + DetailsPanel, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx index e9156a7182c4a..dbf7731160115 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx @@ -10,9 +10,11 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { timelineSelectors } from '../../../store/timeline'; import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineId } from '../../../../../common/types/timeline'; +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'; const FullWidthFlexGroup = styled(EuiFlexGroup)` margin: 0; @@ -25,25 +27,48 @@ const ScrollableFlexItem = styled(EuiFlexItem)` overflow: hidden; `; +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + interface Props { timelineId: TimelineId; } const SessionTabContent: React.FC = ({ timelineId }) => { const { sessionView } = useKibana().services; - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const sessionViewId = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewId ); + const { openDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({ + sourcererScope: SourcererScopeName.timeline, + timelineId, + tabType: TimelineTabs.session, + }); const sessionViewMain = useMemo(() => { return sessionViewId !== null - ? sessionView.getSessionView({ sessionEntityId: sessionViewId }) + ? sessionView.getSessionView({ + sessionEntityId: sessionViewId, + loadAlertDetails: openDetailsPanel, + }) : null; - }, [sessionView, sessionViewId]); + }, [openDetailsPanel, sessionView, sessionViewId]); - return {sessionViewMain}; + return ( + + {sessionViewMain} + {shouldShowDetailsPanel && ( + <> + + {DetailsPanel} + + )} + + ); }; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index 1e12baf13c2db..e4940c3ed8a23 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -464,6 +464,7 @@ export enum TimelineTabs { graph = 'graph', notes = 'notes', pinned = 'pinned', + session = 'session', eql = 'eql', }