diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 1af1cdd9a06a8..70ef734d366d5 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -75,7 +75,10 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the alert details page currently only accessible via the alert details flyout and alert table context menu */ alertDetailsPageEnabled: false, - + /** + * Enables the new security flyout over the current alert details flyout + */ + securityFlyoutEnabled: false, /** * Enables the `get-file` endpoint response action */ diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index f194ef0e463eb..dd45fadec9d76 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -11,6 +11,8 @@ import { EuiThemeProvider, useEuiTheme } from '@elastic/eui'; import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { SecurityFlyout } from '../../../flyout'; import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation'; import { TimelineId } from '../../../../common/types/timeline'; import { getTimelineShowStatusByIdSelector } from '../../../timelines/components/flyout/selectors'; @@ -25,6 +27,7 @@ import { useShowTimeline } from '../../../common/utils/timeline/use_show_timelin import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; const NO_DATA_PAGE_MAX_WIDTH = 950; @@ -59,6 +62,8 @@ export const SecuritySolutionTemplateWrapper: React.FC getTimelineShowStatusByIdSelector(), []); const { show: isShowingTimelineOverlay } = useDeepEqualSelector((state) => getTimelineShowStatus(state, TimelineId.active) @@ -107,6 +112,7 @@ export const SecuritySolutionTemplateWrapper: React.FC )} + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx index 09bb1baa3cbc0..4f09b1aa43495 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx @@ -19,6 +19,8 @@ import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/ import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; import type { ColumnHeaderOptions, OnRowSelected } from '../../../../../common/types/timeline'; import { dataTableActions } from '../../../store/data_table'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; +import { openSecurityFlyoutByScope } from '../../../store/flyout/actions'; type Props = EuiDataGridCellValueElementProps & { columnHeaders: ColumnHeaderOptions[]; @@ -71,6 +73,7 @@ const RowActionComponent = ({ }, [data, pageRowIndex]); const dispatch = useDispatch(); + const isSecurityFlyoutEnabled = useIsExperimentalFeatureEnabled('securityFlyoutEnabled'); const columnValues = useMemo( () => @@ -96,14 +99,29 @@ const RowActionComponent = ({ }, }; - dispatch( - dataTableActions.toggleDetailPanel({ - ...updatedExpandedDetail, - tabType, - id: tableId, - }) - ); - }, [dispatch, eventId, indexName, tabType, tableId]); + if (isSecurityFlyoutEnabled && eventId && indexName) { + dispatch( + openSecurityFlyoutByScope({ + flyoutScope: 'globalFlyout', + right: { + panelKind: 'event', + params: { + eventId, + indexName, + }, + }, + }) + ); + } else { + dispatch( + dataTableActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType, + id: tableId, + }) + ); + } + }, [dispatch, eventId, indexName, isSecurityFlyoutEnabled, tabType, tableId]); const Action = controlColumn.rowCellRender; diff --git a/x-pack/plugins/security_solution/public/common/components/expandable_flyout/index.tsx b/x-pack/plugins/security_solution/public/common/components/expandable_flyout/index.tsx new file mode 100644 index 0000000000000..a0acdc33e7730 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/expandable_flyout/index.tsx @@ -0,0 +1,78 @@ +/* + * 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 } from 'react'; +import type { EuiFlyoutProps } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFlyout } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useExpandableFlyoutContext } from '../../../flyout/context'; +import type { SecurityFlyoutPanel } from '../../store/flyout/model'; + +// The expandable flyout should only worry about visual information and rendering components based on the ID provided. +// This *should* be able to be exported to a package +export interface ExpandableFlyoutViews { + panelKind?: string; + component: (props: SecurityFlyoutPanel) => React.ReactElement; // TODO: genericize SecurityFlyoutPanel to allow it to work in any solution + size: number; +} + +export interface ExpandableFlyoutProps extends EuiFlyoutProps { + panels: ExpandableFlyoutViews[]; +} + +export const ExpandableFlyout: React.FC = ({ panels, ...flyoutProps }) => { + const { flyoutPanels } = useExpandableFlyoutContext(); + const { left, right, preview } = flyoutPanels; + + const leftSection = useMemo( + () => panels.find((panel) => panel.panelKind === left?.panelKind), + [left, panels] + ); + + const rightSection = useMemo( + () => panels.find((panel) => panel.panelKind === right?.panelKind), + [right, panels] + ); + + // const previewSection = useMemo( + // () => panels.find((panel) => panel.panelKind === preview?.panelKind), + // [preview, panels] + // ); + + const flyoutSize = (leftSection?.size ?? 0) + (rightSection?.size ?? 0); + return ( + + + {leftSection && left ? ( + + + {leftSection.component({ ...left })} + + + ) : null} + {rightSection && right ? ( + + + {rightSection.component({ ...right })} + + + ) : null} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts b/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts index c48cc90a32f64..938fa69878610 100644 --- a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts +++ b/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts @@ -6,7 +6,7 @@ */ import { useQuery } from '@tanstack/react-query'; import { useHttp } from '../../lib/kibana'; -import { useTimelineDataFilters } from '../../../timelines/containers/use_timeline_data_filters'; +import { useGlobalOrTimelineFilters } from '../../hooks/use_global_or_timeline_filters'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; @@ -99,7 +99,7 @@ export function useAlertPrevalenceFromProcessTree({ }: UseAlertPrevalenceFromProcessTree): UserAlertPrevalenceFromProcessTreeResult { const http = useHttp(); - const { selectedPatterns } = useTimelineDataFilters(isActiveTimeline); + const { selectedPatterns } = useGlobalOrTimelineFilters(isActiveTimeline); const alertAndOriginalIndices = [...new Set(selectedPatterns.concat(indices))]; const { loading, id, schema } = useAlertDocumentAnalyzerSchema({ documentId, diff --git a/x-pack/plugins/security_solution/public/common/hooks/flyouts/index.ts b/x-pack/plugins/security_solution/public/common/hooks/flyouts/index.ts new file mode 100644 index 0000000000000..4c4be8b9abc45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/flyouts/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { useInitFlyoutsFromUrlParam } from './use_init_flyouts_url_params'; +import { useSyncFlyoutsUrlParam } from './use_sync_flyouts_url_params'; + +export const useFlyoutsUrlStateSync = () => { + useInitFlyoutsFromUrlParam(); + useSyncFlyoutsUrlParam(); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/flyouts/use_init_flyouts_url_params.ts b/x-pack/plugins/security_solution/public/common/hooks/flyouts/use_init_flyouts_url_params.ts new file mode 100644 index 0000000000000..d5bdb8eb51a03 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/flyouts/use_init_flyouts_url_params.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; + +import { useDispatch } from 'react-redux'; +import { initializeSecurityFlyoutFromUrl } from '../../store/flyout/actions'; +import type { SecurityFlyoutState } from '../../store/flyout/model'; +import { useInitializeUrlParam } from '../../utils/global_query_string'; +import { URL_PARAM_KEY } from '../use_url_state'; + +export const useInitFlyoutsFromUrlParam = () => { + const dispatch = useDispatch(); + + const onInitialize = useCallback( + (initialState: Required | null) => { + if (initialState != null) { + dispatch(initializeSecurityFlyoutFromUrl(initialState)); + } + }, + [dispatch] + ); + + useInitializeUrlParam(URL_PARAM_KEY.flyouts, onInitialize); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/flyouts/use_sync_flyouts_url_params.ts b/x-pack/plugins/security_solution/public/common/hooks/flyouts/use_sync_flyouts_url_params.ts new file mode 100644 index 0000000000000..0d27daf48000d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/flyouts/use_sync_flyouts_url_params.ts @@ -0,0 +1,30 @@ +/* + * 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 { useEffect } from 'react'; + +import { useSelector } from 'react-redux'; +import { useUpdateUrlParam } from '../../utils/global_query_string'; +import { URL_PARAM_KEY } from '../use_url_state'; +import { flyoutsSelector } from '../../store/flyout/selectors'; +import type { SecurityFlyoutReducerByScope } from '../../store/flyout/model'; +import { areUrlParamsValidSecurityFlyoutParams } from '../../store/flyout/helpers'; + +export const useSyncFlyoutsUrlParam = () => { + const updateUrlParam = useUpdateUrlParam(URL_PARAM_KEY.flyouts); + const flyouts = useSelector(flyoutsSelector); + + useEffect(() => { + if (areUrlParamsValidSecurityFlyoutParams(flyouts)) { + // TODO: It may be better to allow for graceful failure of either flyout rather than making them interdependent + // When they shouldn't be in any way + updateUrlParam(flyouts); + } else { + updateUrlParam(null); + } + }, [flyouts, updateUrlParam]); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_global_or_timeline_filters.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_global_or_timeline_filters.test.tsx new file mode 100644 index 0000000000000..377acc2ca9e24 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_global_or_timeline_filters.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 } from '@testing-library/react-hooks'; +import { + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, + kibanaObservable, + createSecuritySolutionStorageMock, +} from '../mock'; +import { useGlobalOrTimelineFilters } from './use_global_or_timeline_filters'; +import { createStore } from '../store'; +import React from 'react'; +import { SourcererScopeName } from '../store/sourcerer/model'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname }) }; +}); + +const defaultDataViewPattern = 'test-dataview-patterns'; +const timelinePattern = 'test-timeline-patterns'; +const alertsPagePatterns = '.siem-signals-spacename'; +const pathname = '/alerts'; +const { storage } = createSecuritySolutionStorageMock(); +const store = createStore( + { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + defaultDataView: { + ...mockGlobalState.sourcerer.defaultDataView, + patternList: [defaultDataViewPattern], + }, + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + selectedPatterns: [timelinePattern], + }, + }, + }, + }, + SUB_PLUGINS_REDUCER, + kibanaObservable, + storage +); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('useGlobalOrTimelineFilters', () => { + describe('on alerts page', () => { + it('returns default data view patterns and alerts page patterns when isActiveTimelines is falsy', () => { + const isActiveTimelines = false; + const { result } = renderHook(() => useGlobalOrTimelineFilters(isActiveTimelines), { + wrapper, + }); + + expect(result.current.selectedPatterns).toEqual([alertsPagePatterns, defaultDataViewPattern]); + }); + + it('returns default data view patterns and timelinePatterns when isActiveTimelines is truthy', () => { + const isActiveTimelines = true; + const { result } = renderHook(() => useGlobalOrTimelineFilters(isActiveTimelines), { + wrapper, + }); + + expect(result.current.selectedPatterns).toEqual([timelinePattern, defaultDataViewPattern]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_global_or_timeline_filters.ts b/x-pack/plugins/security_solution/public/common/hooks/use_global_or_timeline_filters.ts new file mode 100644 index 0000000000000..2883b65c2e19d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_global_or_timeline_filters.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useDeepEqualSelector } from './use_selector'; +import { + isLoadingSelector, + startSelector, + endSelector, +} from '../components/super_date_picker/selectors'; +import { SourcererScopeName } from '../store/sourcerer/model'; +import { useSourcererDataView, getScopeFromPath } from '../containers/sourcerer'; +import { sourcererSelectors } from '../store'; + +export function useGlobalOrTimelineFilters(isTimeline: boolean) { + const getStartSelector = useMemo(() => startSelector(), []); + const getEndSelector = useMemo(() => endSelector(), []); + const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []); + + const shouldUpdate = useDeepEqualSelector((state) => { + if (isTimeline) { + return getIsLoadingSelector(state.inputs.timeline); + } else { + return getIsLoadingSelector(state.inputs.global); + } + }); + const from = useDeepEqualSelector((state) => { + if (isTimeline) { + return getStartSelector(state.inputs.timeline); + } else { + return getStartSelector(state.inputs.global); + } + }); + const to = useDeepEqualSelector((state) => { + if (isTimeline) { + return getEndSelector(state.inputs.timeline); + } else { + return getEndSelector(state.inputs.global); + } + }); + const getDefaultDataViewSelector = useMemo( + () => sourcererSelectors.defaultDataViewSelector(), + [] + ); + const defaultDataView = useDeepEqualSelector(getDefaultDataViewSelector); + const { pathname } = useLocation(); + const { selectedPatterns: nonTimelinePatterns } = useSourcererDataView( + getScopeFromPath(pathname) + ); + + const { selectedPatterns: timelinePatterns } = useSourcererDataView(SourcererScopeName.timeline); + + const selectedPatterns = useMemo(() => { + return isTimeline + ? [...new Set([...timelinePatterns, ...defaultDataView.patternList])] + : [...new Set([...nonTimelinePatterns, ...defaultDataView.patternList])]; + }, [isTimeline, timelinePatterns, nonTimelinePatterns, defaultDataView.patternList]); + + return { + selectedPatterns, + from, + to, + shouldUpdate, + }; +} diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts index ff491d55c314a..b02418d00cda0 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts @@ -12,6 +12,7 @@ import { useUpdateTimerangeOnPageChange } from './search_bar/use_update_timerang import { useInitTimelineFromUrlParam } from './timeline/use_init_timeline_url_param'; import { useSyncTimelineUrlParam } from './timeline/use_sync_timeline_url_param'; import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_by_id_on_url_change'; +import { useFlyoutsUrlStateSync } from './flyouts'; export const useUrlState = () => { useSyncGlobalQueryString(); @@ -20,6 +21,7 @@ export const useUrlState = () => { useUpdateTimerangeOnPageChange(); useInitTimelineFromUrlParam(); useSyncTimelineUrlParam(); + useFlyoutsUrlStateSync(); useQueryTimelineByIdOnUrlChange(); }; @@ -28,6 +30,7 @@ export enum URL_PARAM_KEY { filters = 'filters', savedQuery = 'savedQuery', sourcerer = 'sourcerer', + flyouts = 'flyouts', timeline = 'timeline', timerange = 'timerange', pageFilter = 'pageFilters', diff --git a/x-pack/plugins/security_solution/public/common/store/actions.ts b/x-pack/plugins/security_solution/public/common/store/actions.ts index 2f8d9e9736da5..ea22f0a29fa70 100644 --- a/x-pack/plugins/security_solution/public/common/store/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/actions.ts @@ -12,6 +12,7 @@ export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; export { inputsActions } from './inputs'; export { sourcererActions } from './sourcerer'; +export { flyoutsActions } from './flyout'; import type { RoutingAction } from './routing'; export type AppAction = EndpointAction | RoutingAction | PolicyDetailsAction; diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/actions.ts b/x-pack/plugins/security_solution/public/common/store/flyout/actions.ts new file mode 100644 index 0000000000000..5f8eb5eee0a63 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/actions.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import actionCreatorFactory from 'typescript-fsa'; +import type { SecurityFlyout, SecurityFlyoutActionWithScope, SecurityFlyoutState } from './model'; + +const actionCreator = actionCreatorFactory('x-pack/security_solution/local/flyout'); + +export const initializeSecurityFlyoutFromUrl = actionCreator( + 'INITIALIZE_SECURITY_FLYOUT_FROM_URL' +); + +// Security flyout can be opened both in the global scope and the timeline scope +export const openSecurityFlyoutByScope = actionCreator< + SecurityFlyoutActionWithScope +>('OPEN_SECURITY_FLYOUT_BY_SCOPE'); + +export const closeSecurityFlyoutByScope = + actionCreator('CLOSE_FLYOUT'); diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/helpers.ts b/x-pack/plugins/security_solution/public/common/store/flyout/helpers.ts new file mode 100644 index 0000000000000..150b4265778a9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/helpers.ts @@ -0,0 +1,52 @@ +/* + * 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 { isObject } from 'lodash/fp'; +import type { SecurityFlyout, SecurityFlyoutPanel } from './model'; + +export const isValidSecurityFlyoutPanel = (panel: SecurityFlyoutPanel) => { + const hasParams = isObject(panel?.params); + const eventPanels = ['event', 'table', 'visualize', 'json']; + + if (eventPanels.includes(panel.panelKind) && hasParams) { + return panel?.params?.eventId && panel?.params?.indexName; + } + + return false; +}; +// Helper to parse the flyout types to confirm they have the expected parameters +const isValidSecurityFlyout = (flyout: Record) => { + if (!flyout) return false; + + const flyoutPanel = flyout as SecurityFlyout; + if (flyoutPanel.left && !isValidSecurityFlyoutPanel(flyoutPanel.left)) return false; + if (flyoutPanel.right && !isValidSecurityFlyoutPanel(flyoutPanel.right)) return false; + if (flyoutPanel.preview && !isValidSecurityFlyoutPanel(flyoutPanel.preview)) return false; + + return true; +}; + +// This is primarily used for testing information from the url state. +export const areUrlParamsValidSecurityFlyoutParams = (unknownFlyoutReducer: unknown): boolean => { + // Confirm the unknownFlyoutReducer object exists + if (!unknownFlyoutReducer || !isObject(unknownFlyoutReducer)) return false; + + const objectFlyout = unknownFlyoutReducer as { + globalFlyout?: Record; + timelineFlyout?: Record; + }; + + const { globalFlyout, timelineFlyout } = objectFlyout; + + if (globalFlyout && isObject(globalFlyout) && !isValidSecurityFlyout(globalFlyout)) { + return false; + } + if (timelineFlyout && isObject(timelineFlyout) && !isValidSecurityFlyout(timelineFlyout)) { + return false; + } + + return true; +}; diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/index.ts b/x-pack/plugins/security_solution/public/common/store/flyout/index.ts new file mode 100644 index 0000000000000..ac6175ce2ffcb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/index.ts @@ -0,0 +1,12 @@ +/* + * 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 * as flyoutsActions from './actions'; +import * as flyoutsSelectors from './selectors'; +import * as flyoutsModel from './model'; + +export { flyoutsActions, flyoutsSelectors, flyoutsModel }; diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/model.ts b/x-pack/plugins/security_solution/public/common/store/flyout/model.ts new file mode 100644 index 0000000000000..ef796efb1f0ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/model.ts @@ -0,0 +1,35 @@ +/* + * 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 { EventPanel, VisualizePanel, TablePanel } from './panel-models'; + +export * from './panel-models'; + +export type SecurityFlyoutPanel = + | EventPanel + | VisualizePanel + | TablePanel + | Record; // Empty object + +export interface SecurityFlyout { + left?: SecurityFlyoutPanel; + right?: SecurityFlyoutPanel; + preview?: SecurityFlyoutPanel; +} + +export type SecurityFlyoutScopes = 'globalFlyout' | 'timelineFlyout'; + +export interface SecurityFlyoutReducerByScope { + globalFlyout?: SecurityFlyout; + timelineFlyout?: SecurityFlyout; +} + +export type SecurityFlyoutState = SecurityFlyoutReducerByScope; + +export type SecurityFlyoutActionWithScope = { + flyoutScope: SecurityFlyoutScopes; +} & T; diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/panel-models.ts b/x-pack/plugins/security_solution/public/common/store/flyout/panel-models.ts new file mode 100644 index 0000000000000..e01b73c0c24c3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/panel-models.ts @@ -0,0 +1,67 @@ +/* + * 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. + */ + +interface SecurityFlyoutPanel { + /** + * The type of flyout to show + */ + panelKind?: string; // TODO: Should this be a different name like ID or Category? + /** + * Any parameters necessary for the initial requests within the flyout + */ + params?: Record; + /** + * Tracks the path for what to show in a panel. We may have multiple tabs or :details etc, so easiest to just use a stack + */ + path?: string[]; + /** + * Tracks visual state such as whether the panel is collapsed + */ + state?: Record; +} + +// TODO: QUESTION: Should these live here or should they be co-located with their components? +// These are really only necessary if this panel can live independent of a parent event component or wrapper +// In the nested tabs scenario this isn't reallly necessary, as a parent event component would be the only thing that needs this query +// ...but, thinking through this more, theres an opportunity for us to create independent re-usable components or views. +// For instance (There is a case for re-using the table and/or json views for example) in other places +// As well as re-using minimized visualize panels in the application as well +// Given our history of creating a component/view then wanting to utilize it somewhere else in the application, I think it's worth the effort + +export interface VisualizePanel extends SecurityFlyoutPanel { + panelKind?: 'visualize'; + params?: { + eventId: string; + indexName: string; + }; +} + +export type EventPanelPaths = 'overview' | 'table' | 'json'; + +export interface EventPanel extends SecurityFlyoutPanel { + panelKind?: 'event'; + path?: EventPanelPaths[]; + params?: { + eventId: string; + indexName: string; + }; +} + +export interface TablePanel extends SecurityFlyoutPanel { + panelKind?: 'table'; + params?: { eventId: string; indexName: string }; +} + +export interface JSONPanel extends SecurityFlyoutPanel { + panelKind?: 'json'; + params?: { eventId: string; indexName: string }; +} + +export interface OverviewPanel extends SecurityFlyoutPanel { + panelKind?: 'overview'; + params?: { eventId: string; indexName: string }; +} diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/reducer.ts b/x-pack/plugins/security_solution/public/common/store/flyout/reducer.ts new file mode 100644 index 0000000000000..8e5758b38cab0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/reducer.ts @@ -0,0 +1,51 @@ +/* + * 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 { cloneDeep, merge } from 'lodash'; +import { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { + initializeSecurityFlyoutFromUrl, + closeSecurityFlyoutByScope, + openSecurityFlyoutByScope, +} from './actions'; +import { areUrlParamsValidSecurityFlyoutParams } from './helpers'; +import type { SecurityFlyoutState } from './model'; + +export const initialFlyoutsState: SecurityFlyoutState = {}; + +export const flyoutsReducer = reducerWithInitialState(initialFlyoutsState) + /** + * Open the flyout for the given flyoutScope + */ + .case(initializeSecurityFlyoutFromUrl, (state, newFlyoutState) => { + if (areUrlParamsValidSecurityFlyoutParams(newFlyoutState)) { + return merge({}, state, newFlyoutState); + } + return state; + }) + /** + * Open the flyout for the given flyoutScope + */ + .case(openSecurityFlyoutByScope, (state, { flyoutScope, ...panelProps }) => { + // We use merge as the spread operator copies nested objects, rather than creating a new object + // The nested object may be opaque to a future developer so this protects against stale references + const newState = merge({}, state, { + [flyoutScope]: { + ...panelProps, + }, + }); + return newState; + }) + /** + * Remove the flyoutScope from state to close the flyout and remove it from url state + */ + .case(closeSecurityFlyoutByScope, (state, { flyoutScope }) => { + const newState = cloneDeep(state); + delete newState[flyoutScope]; + return newState; + }) + .build(); diff --git a/x-pack/plugins/security_solution/public/common/store/flyout/selectors.ts b/x-pack/plugins/security_solution/public/common/store/flyout/selectors.ts new file mode 100644 index 0000000000000..2d5e8d7b819c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/flyout/selectors.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createSelector } from 'reselect'; +import type { State } from '../types'; +import type { SecurityFlyoutState } from './model'; + +const selectFlyouts = (state: State): SecurityFlyoutState => state.flyouts; + +export const flyoutsSelector = createSelector(selectFlyouts, (flyouts) => flyouts); + +export const globalFlyoutSelector = createSelector( + flyoutsSelector, + (flyouts) => flyouts.globalFlyout +); + +export const timelineFlyoutSelector = createSelector( + flyoutsSelector, + (flyouts) => flyouts.timelineFlyout +); diff --git a/x-pack/plugins/security_solution/public/common/store/model.ts b/x-pack/plugins/security_solution/public/common/store/model.ts index 2633d35e5b168..f5d65cc938d80 100644 --- a/x-pack/plugins/security_solution/public/common/store/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/model.ts @@ -9,4 +9,5 @@ export { appModel } from './app'; export { dragAndDropModel } from './drag_and_drop'; export { inputsModel } from './inputs'; export { sourcererModel } from './sourcerer'; +export { flyoutsModel } from './flyout'; export * from './types'; diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index ae75e32ce5958..95dd38a0b61f6 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -29,6 +29,7 @@ import { getScopePatternListSelection } from './sourcerer/helpers'; import { globalUrlParamReducer, initialGlobalUrlParam } from './global_url_param'; import type { DataTableState } from './data_table/types'; import { dataTableReducer } from './data_table/reducer'; +import { flyoutsReducer, initialFlyoutsState } from './flyout/reducer'; export type SubPluginsInitReducer = HostsPluginReducer & UsersPluginReducer & @@ -81,6 +82,7 @@ export const createInitialState = ( ...pluginsInitState, app: { ...initialAppState, enableExperimental }, dragAndDrop: initialDragAndDropState, + flyouts: initialFlyoutsState, inputs: createInitialInputsState(enableExperimental.socTrendsEnabled), sourcerer: { ...sourcererModel.initialSourcererState, @@ -122,6 +124,7 @@ export const createReducer: ( combineReducers({ app: appReducer, dragAndDrop: dragAndDropReducer, + flyouts: flyoutsReducer, inputs: inputsReducer, sourcerer: sourcererReducer, globalUrlParam: globalUrlParamReducer, diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 4229d4d6e3ca1..760ddfb6f9978 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -22,6 +22,7 @@ import type { ManagementPluginState } from '../../management'; import type { UsersPluginState } from '../../explore/users/store'; import type { GlobalUrlParam } from './global_url_param'; import type { DataTableState } from './data_table/types'; +import type { SecurityFlyoutState } from './flyout/model'; export type State = HostsPluginState & UsersPluginState & @@ -31,6 +32,7 @@ export type State = HostsPluginState & ManagementPluginState & { app: AppState; dragAndDrop: DragAndDropState; + flyouts: SecurityFlyoutState; inputs: InputsState; sourcerer: SourcererState; globalUrlParam: GlobalUrlParam; diff --git a/x-pack/plugins/security_solution/public/flyout/context.tsx b/x-pack/plugins/security_solution/public/flyout/context.tsx new file mode 100644 index 0000000000000..aebf56d932dac --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/context.tsx @@ -0,0 +1,80 @@ +/* + * 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, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { merge } from 'lodash'; +import { noop } from 'lodash/fp'; +import type { SecurityFlyout, SecurityFlyoutScopes } from '../common/store/flyout/model'; +import { openSecurityFlyoutByScope } from '../common/store/flyout/actions'; + +interface ExpandableFlyoutContext { + closeFlyout: () => void; + flyoutScope?: SecurityFlyoutScopes; + flyoutPanels: SecurityFlyout; + updateFlyoutPanels: (configUpdate: Partial) => void; +} + +const ExpandableFlyoutContext = createContext({ + closeFlyout: noop, + flyoutScope: undefined, + flyoutPanels: { + left: undefined, + right: undefined, + preview: undefined, + }, + updateFlyoutPanels: noop, +}); + +interface ExpandableFlyoutProviderProps { + closeFlyout: () => void; + flyoutScope: SecurityFlyoutScopes; + scopedFlyout: SecurityFlyout; + children: React.ReactNode; +} + +export const ExpandableFlyoutProvider = ({ + closeFlyout, + flyoutScope, + scopedFlyout, + children, +}: ExpandableFlyoutProviderProps) => { + const [flyoutPanels, updatePanels] = useState(scopedFlyout); + const dispatch = useDispatch(); + + const updateFlyoutPanels = useCallback( + (panelsUpdate: Partial) => { + const update = merge({}, flyoutPanels, panelsUpdate); + dispatch(openSecurityFlyoutByScope({ flyoutScope, ...update })); + }, + [flyoutPanels, dispatch, flyoutScope] + ); + + useEffect(() => { + updatePanels(scopedFlyout); + }, [scopedFlyout]); + + const contextValue = useMemo( + () => ({ + closeFlyout, + flyoutScope, + flyoutPanels, + updateFlyoutPanels, + }), + [closeFlyout, flyoutScope, flyoutPanels, updateFlyoutPanels] + ); + + return ( + + {children} + + ); +}; + +// If there is no data, then the nothing will load +export const useExpandableFlyoutContext = () => + useContext>(ExpandableFlyoutContext); diff --git a/x-pack/plugins/security_solution/public/flyout/event/components/back_to_alert_details.tsx b/x-pack/plugins/security_solution/public/flyout/event/components/back_to_alert_details.tsx new file mode 100644 index 0000000000000..293e30c18c5c1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/components/back_to_alert_details.tsx @@ -0,0 +1,28 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useExpandableFlyoutContext } from '../../context'; + +import { EventDetailsPanelKey } from '../panels/event'; + +export const BackToAlertDetailsButton = () => { + const { updateFlyoutPanels } = useExpandableFlyoutContext(); + const onClick = useCallback( + () => + updateFlyoutPanels({ + right: { panelKind: EventDetailsPanelKey }, + }), + [updateFlyoutPanels] + ); + return ( + + {'Back to alert details'} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/helpers.tsx b/x-pack/plugins/security_solution/public/flyout/event/helpers.tsx new file mode 100644 index 0000000000000..b12cb6b012b52 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/helpers.tsx @@ -0,0 +1,88 @@ +/* + * 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 { some } from 'lodash/fp'; +import { useMemo } from 'react'; +import type { TimelineEventsDetailsItem } from '../../../common/search_strategy'; +import { getFieldValue } from '../../detections/components/host_isolation/helpers'; +import { DEFAULT_ALERTS_INDEX, DEFAULT_PREVIEW_INDEX } from '../../../common/constants'; + +export interface GetBasicDataFromDetailsData { + alertId: string; + agentId?: string; + isAlert: boolean; + hostName: string; + userName: string; + ruleName: string; + timestamp: string; + data: TimelineEventsDetailsItem[] | null; +} + +// This shouldn't be necessary. There should just be a single helper that gets these values +export const useBasicDataFromDetailsData = ( + data: TimelineEventsDetailsItem[] | null +): GetBasicDataFromDetailsData => { + const isAlert = some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, data); + + const ruleName = useMemo( + () => getFieldValue({ category: 'kibana', field: 'kibana.alert.rule.name' }, data), + [data] + ); + + const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, data), [data]); + + const agentId = useMemo( + () => getFieldValue({ category: 'agent', field: 'agent.id' }, data), + [data] + ); + + const hostName = useMemo( + () => getFieldValue({ category: 'host', field: 'host.name' }, data), + [data] + ); + + const userName = useMemo( + () => getFieldValue({ category: 'user', field: 'user.name' }, data), + [data] + ); + + const timestamp = useMemo( + () => getFieldValue({ category: 'base', field: '@timestamp' }, data), + [data] + ); + + return useMemo( + () => ({ + alertId, + agentId, + isAlert, + hostName, + userName, + ruleName, + timestamp, + data, + }), + [agentId, alertId, hostName, isAlert, ruleName, timestamp, userName, data] + ); +}; + +/* +The referenced alert _index in the flyout uses the `.internal.` such as +`.internal.alerts-security.alerts-spaceId` in the alert page flyout and +.internal.preview.alerts-security.alerts-spaceId` in the rule creation preview flyout +but we always want to use their respective aliase indices rather than accessing their backing .internal. indices. +*/ +export const getAlertIndexAlias = ( + index: string, + spaceId: string = 'default' +): string | undefined => { + if (index.startsWith(`.internal${DEFAULT_ALERTS_INDEX}`)) { + return `${DEFAULT_ALERTS_INDEX}-${spaceId}`; + } else if (index.startsWith(`.internal${DEFAULT_PREVIEW_INDEX}`)) { + return `${DEFAULT_PREVIEW_INDEX}-${spaceId}`; + } +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/event/content.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/event/content.tsx new file mode 100644 index 0000000000000..e9bee2daac99f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/event/content.tsx @@ -0,0 +1,28 @@ +/* + * 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 { EuiFlyoutBody } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import type { EventPanelPaths } from '../../../../common/store/flyout/model'; +import { eventTabs } from './tabs'; + +export const EventTabbedContent = ({ selectedTabId }: { selectedTabId: EventPanelPaths }) => { + const selectedTabContent = useMemo(() => { + return eventTabs.find((obj) => obj.id === selectedTabId)?.content; + }, [selectedTabId]); + + return ( + + {selectedTabContent} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/event/context.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/event/context.tsx new file mode 100644 index 0000000000000..14c2c86a79e74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/event/context.tsx @@ -0,0 +1,117 @@ +/* + * 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, { createContext, useCallback, useContext, useMemo } from 'react'; +import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { noop } from 'lodash/fp'; +import type { BrowserFields } from '../../../../../common/search_strategy'; +import { SecurityPageName } from '../../../../../common/constants'; +import type { Ecs } from '../../../../../common/ecs'; +import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; +import { getAlertIndexAlias } from '../../helpers'; +import { useSpaceId } from '../../../../common/hooks/use_space_id'; +import type { EventPanel } from '../../../../common/store/flyout/model'; +import { useTimelineEventsDetails } from '../../../../timelines/containers/details'; +import { useGetFieldsData } from '../../../../common/hooks/use_get_fields_data'; +import { getFieldBrowserFormattedValue } from '../../utils/get_field_browser_formatted_value'; +import { useRouteSpy } from '../../../../common/utils/route/use_route_spy'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; + +interface EventDetailsPanelContextValues { + browserFields: BrowserFields | null; + dataAsNestedObject: Ecs | null; + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null; + getData: ({ category, field }: { category: string; field: string }) => string | void; + getFieldsData: (field: string) => unknown | unknown[]; + refetchFlyoutData: () => Promise | void; + searchHit: SearchHit | undefined; +} + +const EventDetailsFlyoutContext = createContext({ + browserFields: null, + dataAsNestedObject: null, + dataFormattedForFieldBrowser: null, + getData: noop, + getFieldsData: noop, + refetchFlyoutData: noop, + searchHit: undefined, +}); + +type EventDetailsPanelProviderProps = { + children: React.ReactNode; +} & Partial; + +export const EventDetailsPanelProvider = ({ + eventId, + indexName, + children, +}: EventDetailsPanelProviderProps) => { + const currentSpaceId = useSpaceId(); + const [{ pageName }] = useRouteSpy(); + const sourcererScope = + pageName === SecurityPageName.detections + ? SourcererScopeName.detections + : SourcererScopeName.default; + const sourcererDataView = useSourcererDataView(sourcererScope); + const eventIndex = indexName ? getAlertIndexAlias(indexName, currentSpaceId) ?? indexName : ''; + + // TODO: Convert this to use react-query so multiple queries within a period will hit the local cache + // Over hitting the back end. + const [loading, dataFormattedForFieldBrowser, searchHit, dataAsNestedObject, refetchFlyoutData] = + useTimelineEventsDetails({ + indexName: eventIndex, + eventId: eventId ?? '', + runtimeMappings: sourcererDataView.runtimeMappings, + skip: !eventId, + }); + + const getData = useCallback( + ({ category, field }: { category: string; field: string }) => + getFieldBrowserFormattedValue({ category, field }, dataFormattedForFieldBrowser), + [dataFormattedForFieldBrowser] + ); + + const getFieldsData = useGetFieldsData(searchHit?.fields); + + const contextValue = useMemo( + () => ({ + browserFields: sourcererDataView.browserFields, + dataAsNestedObject, + dataFormattedForFieldBrowser, + getData, + getFieldsData, // TODO: See if there is a way for us to type this properly rather than trying to type at time of use + refetchFlyoutData, + searchHit, + }), + [ + dataAsNestedObject, + dataFormattedForFieldBrowser, + getData, + getFieldsData, + refetchFlyoutData, + searchHit, + sourcererDataView.browserFields, + ] + ); + + // TODO: rely on only one source of data, but will require updating all the subcomponents + const dataNotFound = + !loading && (!dataFormattedForFieldBrowser || !searchHit || !dataAsNestedObject); + + if (dataNotFound) return <>{'Could not find data'}; + + return ( + + {loading ? <>{'Loading...'} : children} + + ); +}; + +// If there is no data, then the nothing will load +export const useEventDetailsPanelContext = () => + useContext>(EventDetailsFlyoutContext); diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/event/header.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/event/header.tsx new file mode 100644 index 0000000000000..7774d3cab5582 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/event/header.tsx @@ -0,0 +1,216 @@ +/* + * 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 { + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiPopover, + EuiSpacer, + EuiTab, + EuiTabs, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { css } from '@emotion/react'; +import { isEmpty } from 'lodash'; +import { ALERT_RISK_SCORE, ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { SeverityBadge } from '../../../../detections/components/rules/severity_badge'; +import { useEventDetailsPanelContext } from './context'; +import { useBasicDataFromDetailsData } from '../../helpers'; +import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; +import * as i18n from './translations'; +import { EventVisualizePanelKey } from '../visualize'; +import { useExpandableFlyoutContext } from '../../../context'; +import type { EventPanelPaths } from '../../../../common/store/flyout/model'; +import { eventTabs } from './tabs'; + +const HeaderTopRow = ({ handleOnEventClosed }: { handleOnEventClosed?: () => void }) => { + const [isPopoverOpen, updateIsPopoverOpen] = useState(false); + const { updateFlyoutPanels, closeFlyout } = useExpandableFlyoutContext(); + const { searchHit } = useEventDetailsPanelContext(); + const { _id, _index } = searchHit ?? {}; + const closePopover = () => updateIsPopoverOpen(false); + const openPopover = () => updateIsPopoverOpen(true); + + const close = () => { + if (handleOnEventClosed) handleOnEventClosed(); + closeFlyout(); + }; + + const panels = [ + { + id: 0, + title: 'Alert details', + items: [ + { + name: 'Visualize', + onClick: () => + updateFlyoutPanels({ + left: + _id && _index + ? { + panelKind: EventVisualizePanelKey, + params: { eventId: _id, indexName: _index }, + } + : undefined, + }), + }, + ], + }, + ]; + + const button = useMemo( + () => ( + + {i18n.EXPAND_DETAILS} + + ), + [] + ); + + return ( + <> +
+ + + +
+
+ {handleOnEventClosed && ( + + )} +
+ + ); +}; + +const HeaderTitleSection = () => { + const { dataFormattedForFieldBrowser, getFieldsData } = useEventDetailsPanelContext(); + const { isAlert, ruleName, timestamp } = useBasicDataFromDetailsData( + dataFormattedForFieldBrowser + ); + const alertRiskScore = getFieldsData(ALERT_RISK_SCORE) as string; + const alertSeverity = getFieldsData(ALERT_SEVERITY) as Severity; + + return ( + <> + +

{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}

+
+ + {timestamp && } + + + + + +
{`${i18n.SEVERITY_TITLE}:`}
+
+ +
+
+ + + +
{`${i18n.RISK_SCORE_TITLE}:`}
+
+ {alertRiskScore} +
+
+
+ + ); +}; + +export const EventHeader = React.memo( + ({ + selectedTabId, + setSelectedTabId, + handleOnEventClosed, + }: { + selectedTabId: EventPanelPaths; + setSelectedTabId: (selected: EventPanelPaths) => void; + handleOnEventClosed?: () => void; + }) => { + const onSelectedTabChanged = (id: EventPanelPaths) => setSelectedTabId(id); + + const renderTabs = eventTabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + key={index} + > + {tab.name} + + )); + + const { euiTheme } = useEuiTheme(); + return ( + + + + + + + + + + + + + + {renderTabs} + + + ); + } +); + +EventHeader.displayName = 'EventHeader'; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/event/index.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/event/index.tsx new file mode 100644 index 0000000000000..96308e0fbdf45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/event/index.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from 'react'; +import type { EventPanel } from '../../../../common/store/flyout/model'; +import { useExpandableFlyoutContext } from '../../../context'; +import { EventTabbedContent } from './content'; +import { EventHeader } from './header'; +import type { EventTabsType } from './tabs'; +import { eventTabIds } from './tabs'; + +export const EventDetailsPanelKey: EventPanel['panelKind'] = 'event'; + +// TODO: You can pull path from the expandableFlyoutContext params instead of passing it +export const EventDetailsPanel: React.FC = React.memo(({ path }) => { + const { updateFlyoutPanels } = useExpandableFlyoutContext(); + + const selectedTabId = useMemo(() => { + const defaultTab = eventTabIds[0]; + if (!path) return defaultTab; + return eventTabIds.find((tabId) => tabId === path[0]) ?? defaultTab; + }, [path]); + + const setSelectedTabId = (tabId: EventTabsType[number]['id']) => { + updateFlyoutPanels({ + right: { path: [tabId] }, + }); + }; + + return ( + <> + + + + ); +}); + +EventDetailsPanel.displayName = 'EventDetailsPanel'; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/event/tabs.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/event/tabs.tsx new file mode 100644 index 0000000000000..5bfade04fb0ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/event/tabs.tsx @@ -0,0 +1,45 @@ +/* + * 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 type { EventPanelPaths } from '../../../../common/store/flyout/model'; +import * as i18n from './translations'; +import { EventTableTab } from '../table'; +import { EventJSONTab } from '../json'; +import { EventOverviewTab } from '../overview'; + +// See: x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx + +export type EventTabsType = Array<{ + id: EventPanelPaths; + 'data-test-subj': string; + name: string; + content: React.ReactElement; +}>; + +export const eventTabs: EventTabsType = [ + { + id: 'overview', + 'data-test-subj': 'overviewTab', + name: i18n.OVERVIEW_TAB, + content: , + }, + { + id: 'table', + 'data-test-subj': 'tableTab', + name: i18n.TABLE_TAB, + content: , + }, + { + id: 'json', + 'data-test-subj': 'jsonViewTab', + name: i18n.JSON_TAB, + content: , + }, +]; + +export const eventTabIds = eventTabs.map((tab) => tab.id); diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/event/translations.ts b/x-pack/plugins/security_solution/public/flyout/event/panels/event/translations.ts new file mode 100644 index 0000000000000..a6ae13c7ece47 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/event/translations.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const MESSAGE = i18n.translate('xpack.securitySolution.flyout.eventDetails.messageTitle', { + defaultMessage: 'Message', +}); + +export const OPEN_ALERT_DETAILS_PAGE = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.openAlertDetails', + { + defaultMessage: 'Open alert details page', + } +); + +export const CLOSE = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.closeEventDetailsLabel', + { + defaultMessage: 'close', + } +); + +export const EVENT_DETAILS = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.eventTitleLabel', + { + defaultMessage: 'Event details', + } +); + +export const ALERT_DETAILS = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.alertTitleLabel', + { + defaultMessage: 'Alert details', + } +); + +export const RISK_SCORE_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.riskScore.title', + { + defaultMessage: 'Risk score', + } +); + +export const SEVERITY_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.severity.title', + { + defaultMessage: 'Severity', + } +); + +export const ALERT_REASON_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.alertReason.title', + { + defaultMessage: 'Reason', + } +); + +export const HIGHLIGHTED_FIELDS_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.highlightedFields.title', + { + defaultMessage: 'Highlighted fields', + } +); + +export const EXPAND_DETAILS = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.summaryView', + { + defaultMessage: 'Expand details', + } +); + +export const OVERVIEW_TAB = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.overviewTab', + { + defaultMessage: 'Overview', + } +); + +export const TABLE_TAB = i18n.translate('xpack.securitySolution.flyout.eventDetails.tableTab', { + defaultMessage: 'Table', +}); + +export const JSON_TAB = i18n.translate('xpack.securitySolution.flyout.eventDetails.jsonTab', { + defaultMessage: 'JSON', +}); diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/index.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/index.tsx new file mode 100644 index 0000000000000..73b5890321982 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/index.tsx @@ -0,0 +1,65 @@ +/* + * 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 type { ExpandableFlyoutProps } from '../../../common/components/expandable_flyout'; +import type { + EventPanel, + JSONPanel, + TablePanel, + VisualizePanel, +} from '../../../common/store/flyout/model'; +import { EventDetailsPanel, EventDetailsPanelKey } from './event'; +import { EventDetailsPanelProvider } from './event/context'; +import { EventJSONPanel, EventJSONPanelKey } from './json'; +import { EventTablePanel, EventTablePanelKey } from './table'; +import { EventVisualizePanelKey, EventVisualizePanel } from './visualize'; + +// TODO: We have these wrappers "EventDetailsPanelProvider" to avoid prop drilling of data currently +// And allow the panels or tabs to live independently of each other. This wrapper can be avoided by +// storing the data in redux, but as redux just serves as a temporary in memory cache, and may add +// additional unnecessary bloat to our store, and bring in issues such as managing when the data should be updated +// and/or re-cached. Since react-query already solves this, and can cache the data based on the query params, we can use that instead + +export const expandableFlyoutPanels: ExpandableFlyoutProps['panels'] = [ + { + panelKind: EventDetailsPanelKey, + size: 550, + component: (props) => ( + + + + ), + }, + { + panelKind: EventVisualizePanelKey, + size: 1000, + component: (props) => ( + + + + ), + }, + { + panelKind: EventTablePanelKey, + size: 550, + component: (props) => ( + + + + ), + }, + { + panelKind: EventJSONPanelKey, + size: 550, + component: (props) => ( + + + + ), + }, +]; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/json/index.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/json/index.tsx new file mode 100644 index 0000000000000..55a139e71558e --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/json/index.tsx @@ -0,0 +1,60 @@ +/* + * 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 { css } from '@emotion/react'; +import type { JSONPanel } from '../../../../common/store/flyout/model'; +import { BackToAlertDetailsButton } from '../../components/back_to_alert_details'; +import { useEventDetailsPanelContext } from '../event/context'; +import { JsonView } from '../../../../common/components/event_details/json_view'; + +export const EventJSONPanelKey: JSONPanel['panelKind'] = 'json'; + +// TODO: If we want JSON as a panel, use the below + +export const EventJSONPanel: React.FC = React.memo(() => { + const { searchHit } = useEventDetailsPanelContext(); + return ( + <> + {searchHit && ( +
+ +
+ +
+
+ )} + + ); +}); + +EventJSONPanel.displayName = 'EventJSON'; + +// TODO: If we want JSON as a tab, use the below: + +export const EventJSONTab: React.FC = React.memo(() => { + const { searchHit } = useEventDetailsPanelContext(); + return ( +
+ +
+ ); +}); + +EventJSONTab.displayName = 'EventJSONTab'; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/overview/highlighted-fields.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/highlighted-fields.tsx new file mode 100644 index 0000000000000..0068d27f398bf --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/highlighted-fields.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EuiFlexGroup, EuiPanel } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import { AlertSummaryView } from '../../../../common/components/event_details/alert_summary_view'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { useExpandableFlyoutContext } from '../../../context'; +import { useEventDetailsPanelContext } from '../event/context'; +import * as i18n from '../event/translations'; + +export const HighlightedFields = () => { + const [isPanelExpanded, setIsPanelExpanded] = useState(false); + + const { flyoutScope, updateFlyoutPanels } = useExpandableFlyoutContext(); + const { dataFormattedForFieldBrowser, browserFields, searchHit } = useEventDetailsPanelContext(); + const eventId = searchHit?._id as string; + + const goToTableTab = useCallback(() => { + updateFlyoutPanels({ right: { path: ['table'] } }); + }, [updateFlyoutPanels]); + + const isVisible = dataFormattedForFieldBrowser && browserFields && eventId && flyoutScope; + + return isVisible ? ( + + + + {isPanelExpanded && ( + + )} + + + ) : null; +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/overview/index.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/index.tsx new file mode 100644 index 0000000000000..5cf4926070939 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/index.tsx @@ -0,0 +1,55 @@ +/* + * 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 { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import type { OverviewPanel } from '../../../../common/store/flyout/model'; +import { BackToAlertDetailsButton } from '../../components/back_to_alert_details'; +import { HighlightedFields } from './highlighted-fields'; +import { MitreDetails } from './mitre-details'; +import { ReasonDetails } from './reason-details'; +import { RuleDetails } from './rule-details'; + +// TODO: If we want the table as a panel, use the below +export const EventOverviewPanelKey: OverviewPanel['panelKind'] = 'overview'; + +export const EventOverviewPanel: React.FC = React.memo(() => { + return ( +
+ + + + +

{'Session Viewer Preview Placeholder'}

+ + + + +
+ ); +}); + +EventOverviewPanel.displayName = 'EventOverview'; + +// TODO: If we want the table as a tab, use the below: + +export const EventOverviewTab: React.FC = React.memo(() => { + return ( + <> + + + +

{'Session Viewer Preview Placeholder'}

+ + + + + + ); +}); + +EventOverviewTab.displayName = 'EventOverviewTab'; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/overview/mitre-details.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/mitre-details.tsx new file mode 100644 index 0000000000000..ca4d8ac1ccb0e --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/mitre-details.tsx @@ -0,0 +1,46 @@ +/* + * 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 { EuiFlexGroup, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { getMitreComponentParts } from '../../../../detections/mitre/get_mitre_threat_component'; +import { useEventDetailsPanelContext } from '../event/context'; + +export const MitreDetails = () => { + const { searchHit } = useEventDetailsPanelContext(); + const threatDetails = useMemo(() => getMitreComponentParts(searchHit), [searchHit]); + const { euiTheme } = useEuiTheme(); + return ( + + {threatDetails && threatDetails[0] && ( + <> + +
{threatDetails[0].title}
+
+
+ {threatDetails[0].description} +
+ + )} +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/overview/reason-details.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/reason-details.tsx new file mode 100644 index 0000000000000..544830ada1733 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/reason-details.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiFlexGroup, EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo, useState } from 'react'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { defaultRowRenderers } from '../../../../timelines/components/timeline/body/renderers'; +import { getRowRenderer } from '../../../../timelines/components/timeline/body/renderers/get_row_renderer'; +import { useExpandableFlyoutContext } from '../../../context'; +import { useEventDetailsPanelContext } from '../event/context'; +import * as i18n from '../event/translations'; + +export const ReasonDetails = () => { + const [isPanelExpanded, setIsPanelExpanded] = useState(false); + const { flyoutScope } = useExpandableFlyoutContext(); + const { dataAsNestedObject } = useEventDetailsPanelContext(); + + const renderer = useMemo( + () => + dataAsNestedObject != null + ? getRowRenderer({ data: dataAsNestedObject, rowRenderers: defaultRowRenderers }) + : null, + [dataAsNestedObject] + ); + + return ( + + + + {isPanelExpanded && renderer != null && dataAsNestedObject != null && ( +
+ {renderer.renderRow({ + contextId: 'event-details', + data: dataAsNestedObject, + isDraggable: flyoutScope === 'timelineFlyout', + scopeId: flyoutScope ?? '', + })} +
+ )} +
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/overview/rule-details.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/rule-details.tsx new file mode 100644 index 0000000000000..ad9d5893ac355 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/overview/rule-details.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; +import { LineClamp } from '../../../../common/components/line_clamp'; +import { useEventDetailsPanelContext } from '../event/context'; + +export const RuleDetails = () => { + const { getFieldsData } = useEventDetailsPanelContext(); + const description = getFieldsData('kibana.alert.rule.description') as string; + return ( + + + +
{'Rule description'}
+
+ + + {description} + +
+ + + {'View investigation guide'} + +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/table/index.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/table/index.tsx new file mode 100644 index 0000000000000..2fa5c7388d66e --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/table/index.tsx @@ -0,0 +1,63 @@ +/* + * 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 type { TablePanel } from '../../../../common/store/flyout/model'; +import { TimelineTabs } from '../../../../../common/types'; +import { EventFieldsBrowser } from '../../../../common/components/event_details/event_fields_browser'; +import { BackToAlertDetailsButton } from '../../components/back_to_alert_details'; +import { useEventDetailsPanelContext } from '../event/context'; + +// TODO: If we want the table as a panel, use the below +export const EventTablePanelKey: TablePanel['panelKind'] = 'table'; + +export const EventTablePanel: React.FC = React.memo(() => { + const { browserFields, searchHit, dataFormattedForFieldBrowser } = useEventDetailsPanelContext(); + const databaseDocumentID = searchHit?._id as string; // Is + return ( + browserFields && + dataFormattedForFieldBrowser && ( +
+ + +
+ ) + ); +}); + +EventTablePanel.displayName = 'EventTable'; + +// TODO: If we want the table as a tab, use the below: + +export const EventTableTab: React.FC = React.memo(() => { + const { browserFields, searchHit, dataFormattedForFieldBrowser } = useEventDetailsPanelContext(); + const databaseDocumentID = searchHit?._id as string; // Is + return ( + browserFields && + dataFormattedForFieldBrowser && ( + + ) + ); +}); + +EventTableTab.displayName = 'EventTableTab'; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/analyze_event.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/analyze_event.tsx new file mode 100644 index 0000000000000..c78b12b0da6a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/analyze_event.tsx @@ -0,0 +1,30 @@ +/* + * 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 { useGlobalOrTimelineFilters } from '../../../../common/hooks/use_global_or_timeline_filters'; +import { Resolver } from '../../../../resolver/view'; +import { useEventDetailsPanelContext } from '../event/context'; + +// TODO: Add full screen + +export const ANALYZE_EVENT_ID = 'analyze_event'; + +export const AnalyzeEvent = () => { + const { selectedPatterns, from, to, shouldUpdate } = useGlobalOrTimelineFilters(false); + const { searchHit } = useEventDetailsPanelContext(); + const databaseDocumentID = searchHit?._id as string; // Is the eventID - We won't render without this + return ( + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/index.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/index.tsx new file mode 100644 index 0000000000000..00e83bbb894d8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/index.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import type { VisualizePanel } from '../../../../common/store/flyout/model'; +import { AnalyzeEvent, ANALYZE_EVENT_ID } from './analyze_event'; +import { VisualizeNavigation } from './navigation'; +import { SessionView, SESSION_VIEW_ID } from './session_view'; + +export const EventVisualizePanelKey: VisualizePanel['panelKind'] = 'visualize'; + +export const EventVisualizePanel: React.FC = React.memo(() => { + const [activeVisualizationId, setActiveVisualizationId] = useState(ANALYZE_EVENT_ID); + return ( + <> + + {activeVisualizationId === ANALYZE_EVENT_ID && } + {activeVisualizationId === SESSION_VIEW_ID && } + {/* + */} + + ); +}); + +EventVisualizePanel.displayName = 'EventDetailsPanel'; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/navigation.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/navigation.tsx new file mode 100644 index 0000000000000..2a75858bccfc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/navigation.tsx @@ -0,0 +1,63 @@ +/* + * 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 { css } from '@emotion/css'; +import { EuiButtonGroup, EuiPanel } from '@elastic/eui'; +import { ANALYZE_EVENT, SESSION_VIEW, GRAPH } from './translations'; +import * as i18n from './translations'; +import { ANALYZE_EVENT_ID } from './analyze_event'; +import { SESSION_VIEW_ID } from './session_view'; + +export const VisualizeNavigation = ({ + activeVisualizationId, + setActiveVisualizationId, +}: { + activeVisualizationId: string; + setActiveVisualizationId(id: string): void; +}) => { + const visualizeButtons = [ + { + id: ANALYZE_EVENT_ID, + label: ANALYZE_EVENT, + }, + { + id: SESSION_VIEW_ID, + label: SESSION_VIEW, + }, + { + id: 'graph', + label: GRAPH, + }, + ]; + + const onChangeCompressed = (optionId: string) => { + setActiveVisualizationId(optionId); + }; + + return ( + + onChangeCompressed(id)} + buttonSize="compressed" + isFullWidth + /> + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/session_view.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/session_view.tsx new file mode 100644 index 0000000000000..9ac6504f3ae9a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/session_view.tsx @@ -0,0 +1,68 @@ +/* + * 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, { useCallback, useEffect, useRef, useState } from 'react'; +import { css } from '@emotion/react'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useEventDetailsPanelContext } from '../event/context'; + +// TODO: Add full screen + +export const SESSION_VIEW_ID = 'session_view'; + +export const SessionView = () => { + const containerRef = useRef(null); + const [height, setHeight] = useState(undefined); + const { sessionView } = useKibana().services; + const { searchHit, getFieldsData } = useEventDetailsPanelContext(); + const databaseDocumentID = searchHit?._id as string; // Is the eventID - We won't render without this + const processEntityId = getFieldsData('process.entity_id') as string; + const sessionEntityId = getFieldsData('process.entry_leader.entity_id') as string; + const timestamp = (getFieldsData('kibana.alert.original_time') ?? + getFieldsData('@timestamp')) as string; + + const hasResized = useRef(false); + + const resizeFn = useCallback(() => { + if (!hasResized.current) { + if (containerRef.current?.scrollHeight) { + setHeight(containerRef.current.scrollHeight); + hasResized.current = true; + } + } + }, []); + useEffect(() => { + if (containerRef.current) { + containerRef.current.addEventListener('resize', resizeFn); + } + return () => { + containerRef.current?.removeEventListener('resize', resizeFn); + }; + }); + + if (sessionEntityId === undefined) return null; + return ( +
+ {sessionView.getSessionView({ + sessionEntityId, + height, + jumpToEntityId: processEntityId, + jumpToCursor: timestamp, + investigatedAlertId: databaseDocumentID, + loadAlertDetails: () => {}, // This will be the "Preview loader", + isFullScreen: false, + canAccessEndpointManagement: false, + })} +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/translations.ts b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/translations.ts new file mode 100644 index 0000000000000..1a4bd70b9085f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const ANALYZE_EVENT = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.visualize.Analyzer', + { + defaultMessage: 'Analyze Event', + } +); + +export const SESSION_VIEW = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.visualize.sessionViewer', + { + defaultMessage: 'Session View', + } +); + +export const GRAPH = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.visualize.sessionViewer', + { + defaultMessage: 'Graph', + } +); + +export const VISUALIZATION_OPTIONS = i18n.translate( + 'xpack.securitySolution.flyout.eventDetails.visualize.visualizationOptions', + { + defaultMessage: 'Visualization options', + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/visualizer.tsx b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/visualizer.tsx new file mode 100644 index 0000000000000..3a2a43e4e4ba0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/panels/visualize/visualizer.tsx @@ -0,0 +1,209 @@ +/* + * 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, useEffect, useRef, useLayoutEffect } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { useDispatch } from 'react-redux'; +import styled, { css } from 'styled-components'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../../common/containers/use_full_screen'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { dataTableSelectors } from '../../../../common/store/data_table'; +import { tableDefaults } from '../../../../common/store/data_table/defaults'; +import { inputsActions } from '../../../../common/store/inputs'; +import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { + isInTableScope, + isTimelineScope, + isActiveTimeline, + getScopedActions, +} from '../../../../helpers'; +import { isFullScreen } from '../../../../timelines/components/timeline/body/column_headers'; +import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; +import { timelineSelectors } from '../../../../timelines/store/timeline'; +import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; +import { Resolver } from '../../../../resolver/view'; + +const SESSION_VIEW_FULL_SCREEN = 'sessionViewFullScreen'; + +const OverlayStyle = css` + display: flex; + flex-direction: column; + flex: 1; + width: 100%; +`; + +const OverlayContainer = styled.div` + ${OverlayStyle} +`; + +const FullScreenOverlayStyles = css` + position: fixed; + top: 0; + bottom: 2em; + left: 0; + right: 0; + z-index: ${euiThemeVars.euiZLevel3}; +`; + +const FullScreenOverlayContainer = styled.div` + ${FullScreenOverlayStyles} +`; + +const StyledResolver = styled(Resolver)` + height: 100%; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + ${({ theme }) => `background-color: ${theme.eui.euiColorEmptyShade};`} + overflow: hidden; + width: 100%; + &.${SESSION_VIEW_FULL_SCREEN} { + ${({ theme }) => `padding: 0 ${theme.eui.euiSizeM}`} + } +`; + +interface GraphOverlayProps { + scopeId: string; + SessionView: JSX.Element | null; + Navigation: JSX.Element | null; +} + +const GraphOverlayComponent: React.FC = ({ + SessionView, + Navigation, + scopeId, +}) => { + const dispatch = useDispatch(); + const { globalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen } = useTimelineFullScreen(); + + const getScope = useMemo(() => { + if (isInTableScope(scopeId)) { + return dataTableSelectors.getTableByIdSelector(); + } else if (isTimelineScope(scopeId)) { + return timelineSelectors.getTimelineByIdSelector(); + } + }, [scopeId]); + + const defaults = isInTableScope(scopeId) ? tableDefaults : timelineDefaults; + + const { graphEventId, sessionViewConfig } = useDeepEqualSelector( + (state) => (getScope && getScope(state, scopeId)) ?? defaults + ); + + const fullScreen = useMemo( + () => + isFullScreen({ + globalFullScreen, + isActiveTimelines: isActiveTimeline(scopeId), + timelineFullScreen, + }), + [globalFullScreen, scopeId, timelineFullScreen] + ); + + useEffect(() => { + return () => { + const scopedActions = getScopedActions(scopeId); + if (scopedActions) { + dispatch(scopedActions.updateGraphEventId({ id: scopeId, graphEventId: '' })); + } + if (isActiveTimeline(scopeId)) { + dispatch(inputsActions.setFullScreen({ id: InputsModelId.timeline, fullScreen: false })); + } else { + dispatch(inputsActions.setFullScreen({ id: InputsModelId.global, fullScreen: false })); + } + }; + }, [dispatch, scopeId]); + + const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters( + isActiveTimeline(scopeId) + ); + + const sessionContainerRef = useRef(null); + + useLayoutEffect(() => { + if (fullScreen && sessionContainerRef.current) { + sessionContainerRef.current.setAttribute('style', FullScreenOverlayStyles.join('')); + } else if (sessionContainerRef.current) { + sessionContainerRef.current.setAttribute('style', OverlayStyle.join('')); + } + }, [fullScreen]); + + if (!isActiveTimeline(scopeId) && sessionViewConfig !== null) { + return ( + + + + {Navigation} + + + + {SessionView} + + + + ); + } else if (fullScreen && !isActiveTimeline(scopeId)) { + return ( + + + + {Navigation} + + + {graphEventId !== undefined ? ( + + ) : ( + + + + )} + + ); + } else { + return ( + + + + {Navigation} + + + {graphEventId !== undefined ? ( + + ) : ( + + + + )} + + ); + } +}; + +export const GraphOverlay = React.memo(GraphOverlayComponent); diff --git a/x-pack/plugins/security_solution/public/flyout/event/use_host_isolation_tools.tsx b/x-pack/plugins/security_solution/public/flyout/event/use_host_isolation_tools.tsx new file mode 100644 index 0000000000000..3e035481fa365 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/use_host_isolation_tools.tsx @@ -0,0 +1,108 @@ +/* + * 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 { useCallback, useMemo, useReducer } from 'react'; + +import { useWithCaseDetailsRefresh } from '../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; + +interface HostIsolationStateReducer { + isolateAction: 'isolateHost' | 'unisolateHost'; + isHostIsolationPanelOpen: boolean; + isIsolateActionSuccessBannerVisible: boolean; +} + +type HostIsolationActions = + | { + type: 'setIsHostIsolationPanel'; + isHostIsolationPanelOpen: boolean; + } + | { + type: 'setIsolateAction'; + isolateAction: 'isolateHost' | 'unisolateHost'; + } + | { + type: 'setIsIsolateActionSuccessBannerVisible'; + isIsolateActionSuccessBannerVisible: boolean; + }; + +const initialHostIsolationState: HostIsolationStateReducer = { + isolateAction: 'isolateHost', + isHostIsolationPanelOpen: false, + isIsolateActionSuccessBannerVisible: false, +}; + +function hostIsolationReducer(state: HostIsolationStateReducer, action: HostIsolationActions) { + switch (action.type) { + case 'setIsolateAction': + return { ...state, isolateAction: action.isolateAction }; + case 'setIsHostIsolationPanel': + return { ...state, isHostIsolationPanelOpen: action.isHostIsolationPanelOpen }; + case 'setIsIsolateActionSuccessBannerVisible': + return { + ...state, + isIsolateActionSuccessBannerVisible: action.isIsolateActionSuccessBannerVisible, + }; + default: + throw new Error(); + } +} + +const useHostIsolationTools = () => { + const [ + { isolateAction, isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible }, + dispatch, + ] = useReducer(hostIsolationReducer, initialHostIsolationState); + + const showAlertDetails = useCallback(() => { + dispatch({ type: 'setIsHostIsolationPanel', isHostIsolationPanelOpen: false }); + dispatch({ + type: 'setIsIsolateActionSuccessBannerVisible', + isIsolateActionSuccessBannerVisible: false, + }); + }, []); + + const showHostIsolationPanel = useCallback((action) => { + if (action === 'isolateHost' || action === 'unisolateHost') { + dispatch({ type: 'setIsHostIsolationPanel', isHostIsolationPanelOpen: true }); + dispatch({ type: 'setIsolateAction', isolateAction: action }); + } + }, []); + + const caseDetailsRefresh = useWithCaseDetailsRefresh(); + + const handleIsolationActionSuccess = useCallback(() => { + dispatch({ + type: 'setIsIsolateActionSuccessBannerVisible', + isIsolateActionSuccessBannerVisible: true, + }); + // If a case details refresh ref is defined, then refresh actions and comments + if (caseDetailsRefresh) { + caseDetailsRefresh.refreshCase(); + } + }, [caseDetailsRefresh]); + + return useMemo( + () => ({ + isolateAction, + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + handleIsolationActionSuccess, + showAlertDetails, + showHostIsolationPanel, + }), + [ + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + isolateAction, + handleIsolationActionSuccess, + showAlertDetails, + showHostIsolationPanel, + ] + ); +}; + +export { useHostIsolationTools }; diff --git a/x-pack/plugins/security_solution/public/flyout/event/utils/get_field_browser_formatted_value.ts b/x-pack/plugins/security_solution/public/flyout/event/utils/get_field_browser_formatted_value.ts new file mode 100644 index 0000000000000..82aa69f96cbf1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/utils/get_field_browser_formatted_value.ts @@ -0,0 +1,50 @@ +/* + * 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 { find } from 'lodash/fp'; +import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; + +// TODO: This is copied and renamed from: x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts +// REPLACE that one + +export const getFieldBrowserFormattedValues = ( + { + category, + field, + }: { + category: string; + field: string; + }, + data: TimelineEventsDetailsItem[] | null +) => { + const categoryCompat = + category === 'signal' ? 'kibana' : category === 'kibana' ? 'signal' : category; + const fieldCompat = + category === 'signal' + ? field.replace('signal', 'kibana.alert').replace('rule.id', 'rule.uuid') + : category === 'kibana' + ? field.replace('kibana.alert', 'signal').replace('rule.uuid', 'rule.id') + : field; + return ( + find({ category, field }, data)?.values ?? + find({ category: categoryCompat, field: fieldCompat }, data)?.values + ); +}; + +export const getFieldBrowserFormattedValue = ( + { + category, + field, + }: { + category: string; + field: string; + }, + data: TimelineEventsDetailsItem[] | null +) => { + const currentField = getFieldBrowserFormattedValues({ category, field }, data); + return currentField && currentField.length > 0 ? currentField[0] : ''; +}; diff --git a/x-pack/plugins/security_solution/public/flyout/event/utils/use_get_fields_data.ts b/x-pack/plugins/security_solution/public/flyout/event/utils/use_get_fields_data.ts new file mode 100644 index 0000000000000..31bc4ae3219e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/event/utils/use_get_fields_data.ts @@ -0,0 +1,135 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import { getOr } from 'lodash/fp'; +import type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; + +/** + * Since the fields api may return a string array as well as an object array + * Getting the nestedPath of an object array would require first getting the top level `fields` key + * The field api keys do not provide an index value for the original order of each object + * for example, we might expect fields to reference kibana.alert.parameters.0.index, but the index information is represented by the array position. + * This should be generally fine, but given the flattened nature of the top level key, utilities like `get` or `getOr` won't work since the path isn't actually nested + * This utility allows users to not only get simple fields, but if they provide a path like `kibana.alert.parameters.index`, it will return an array of all index values + * for each object in the parameters array. As an added note, this work stemmed from a hope to be able to purely use the fields api in place of the data produced by + * `getDataFromFieldsHits` found in `x-pack/plugins/timelines/common/utils/field_formatters.ts` + */ +const getAllDotIndicesInReverse = (dotField: string): number[] => { + const dotRegx = RegExp('[.]', 'g'); + const indicesOfAllDotsInString = []; + let result = dotRegx.exec(dotField); + while (result) { + indicesOfAllDotsInString.push(result.index); + result = dotRegx.exec(dotField); + } + /** + * Put in reverse so we start look up from the most likely to be found; + * [[kibana.alert.parameters, index], ['kibana.alert', 'parameters.index'], ['kibana', 'alert.parameters.index']] + */ + return indicesOfAllDotsInString.reverse(); +}; + +/** + * We get the dot paths so we can look up each path to see if any of the nested fields exist + * */ + +const getAllPotentialDotPaths = (dotField: string): string[][] => { + const reverseDotIndices = getAllDotIndicesInReverse(dotField); + + // The nested array paths seem to be at most a tuple (i.e.: `kibana.alert.parameters`, `some.nested.parameters.field`) + const pathTuples = reverseDotIndices.map((dotIndex: number) => { + return [dotField.slice(0, dotIndex), dotField.slice(dotIndex + 1)]; + }); + + return pathTuples; +}; + +const getNestedValue = (startPath: string, endPath: string, data: Record) => { + const foundPrimaryPath = data[startPath]; + if (Array.isArray(foundPrimaryPath)) { + // If the nested path points to an array of objects return the nested value of every object in the array + return foundPrimaryPath + .map((nestedObj) => getOr(null, endPath, nestedObj)) // TODO:QUESTION: does it make sense to leave undefined or null values as array position could be important? + .filter((val) => val !== null); + } else { + // The nested path is just a nested object, so use getOr + return getOr(undefined, endPath, foundPrimaryPath); + } +}; + +/** + * we get the field value from a fields response and by breaking down to look at each individual path, + * we're able to get both top level fields as well as nested fields that don't provide index information. + * In the case where a user enters kibana.alert.parameters.someField, a mapped array of the subfield value will be returned + */ +const getFieldsValue = ( + dotField: string, + data: SearchHit['fields'] | undefined, + cacheNestedField: (fullPath: string, value: unknown) => void +) => { + if (!dotField || !data) return undefined; + + // If the dotField exists and is not a nested object return it + if (Object.hasOwn(data, dotField)) return data[dotField]; + else { + const pathTuples = getAllPotentialDotPaths(dotField); + for (const [startPath, endPath] of pathTuples) { + const foundPrimaryPath = Object.hasOwn(data, startPath) ? data[startPath] : null; + if (foundPrimaryPath) { + const nestedValue = getNestedValue(startPath, endPath, data); + // We cache only the values that need extra work to find. This can be an array of values or a single value + cacheNestedField(dotField, nestedValue); + return nestedValue; + } + } + } + + // Return undefined if nothing is found + return undefined; +}; + +export type GetFieldsDataValue = string | string[] | null | undefined; +export type GetFieldsData = (field: string) => GetFieldsDataValue; + +export const useGetFieldsData = (fieldsData: SearchHit['fields'] | undefined): GetFieldsData => { + // TODO: Move cache to top level container such as redux or context. Make it store type agnostic if possible + // TODO: Handle updates where data is re-requested and the cache is reset. + const cachedOriginalData = useMemo(() => fieldsData, [fieldsData]); + const cachedExpensiveNestedValues: Record = useMemo(() => ({}), []); + + // Speed up any lookups elsewhere by caching the field. + const cacheNestedValues = useCallback( + (fullPath: string, value: unknown) => { + cachedExpensiveNestedValues[fullPath] = value; + }, + [cachedExpensiveNestedValues] + ); + + return useCallback( + (field: string) => { + let fieldsValue; + // Get an expensive value from the cache if it exists, otherwise search for the value + if (Object.hasOwn(cachedExpensiveNestedValues, field)) { + fieldsValue = cachedExpensiveNestedValues[field]; + } else { + fieldsValue = cachedOriginalData + ? getFieldsValue(field, cachedOriginalData, cacheNestedValues) + : undefined; + } + + if (Array.isArray(fieldsValue)) { + // Return the value if it's singular, otherwise return an expected array of values + if (fieldsValue.length === 0) return undefined; + else return fieldsValue; + } + // Otherwise return the given fieldsValue if it isn't an array + return fieldsValue; + }, + [cacheNestedValues, cachedExpensiveNestedValues, cachedOriginalData] + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx new file mode 100644 index 0000000000000..0cd7c44435bc5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/index.tsx @@ -0,0 +1,56 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { flyoutsSelector } from '../common/store/flyout/selectors'; +import { closeSecurityFlyoutByScope } from '../common/store/flyout/actions'; +import type { SecurityFlyoutScopes } from '../common/store/flyout/model'; +import { ExpandableFlyoutProvider } from './context'; +import { ExpandableFlyout } from '../common/components/expandable_flyout'; +import { expandableFlyoutPanels } from './event/panels'; + +interface SecurityFlyoutProps { + className?: string; + flyoutScope: SecurityFlyoutScopes; + handleOnFlyoutClosed?: () => void; +} + +/** + * This flyout is launched from both the primary security pages as well as the timeline. + */ +export const SecurityFlyout = React.memo( + ({ flyoutScope, handleOnFlyoutClosed, className }: SecurityFlyoutProps) => { + const dispatch = useDispatch(); + const flyouts = useSelector(flyoutsSelector); + + const scopedFlyout = useMemo(() => flyouts[flyoutScope], [flyoutScope, flyouts]); + + const closeFlyout = useCallback(() => { + if (handleOnFlyoutClosed) handleOnFlyoutClosed(); + dispatch(closeSecurityFlyoutByScope({ flyoutScope })); + }, [dispatch, flyoutScope, handleOnFlyoutClosed]); + + if (!scopedFlyout) return null; + + return ( + + + + ); + } +); + +SecurityFlyout.displayName = 'SecurityFlyout'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 0d8dd0f637a5e..98a8f7444133a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -11,6 +11,7 @@ import React, { useCallback, useEffect } from 'react'; import styled, { createGlobalStyle } from 'styled-components'; import { useDispatch } from 'react-redux'; +import { SecurityFlyout } from '../../../../flyout'; import { SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME, TIMELINE_EUI_THEME_ZINDEX_LEVEL, @@ -34,6 +35,10 @@ const StyledEuiFlyout = styled(EuiFlyout)` z-index: ${({ theme }) => theme.eui[TIMELINE_EUI_THEME_ZINDEX_LEVEL]}; `; +const StyledSecurityFlyout = styled(SecurityFlyout)` + z-index: ${({ theme }) => theme.eui[TIMELINE_EUI_THEME_ZINDEX_LEVEL] + 1}; +`; + // SIDE EFFECT: the following creates a global class selector const IndexPatternFieldEditorOverlayGlobalStyle = createGlobalStyle<{ theme: { eui: { euiZLevel5: number } }; @@ -76,6 +81,7 @@ const FlyoutPaneComponent: React.FC = ({ style={{ display: visible ? 'block' : 'none' }} > + = ({ }; }, [dispatch, scopeId]); - const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters( + const { from, to, shouldUpdate, selectedPatterns } = useGlobalOrTimelineFilters( isActiveTimeline(scopeId) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 8fda27f20dd39..5085a5b64eb1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -18,6 +18,7 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import type { TimelineResultNote } from '../types'; import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; @@ -29,6 +30,7 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { openSecurityFlyoutByScope } from '../../../../common/store/flyout/actions'; export const NotePreviewsContainer = styled.section` padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; @@ -46,21 +48,37 @@ const ToggleEventDetailsButtonComponent: React.FC timelineId, }) => { const dispatch = useDispatch(); + const isSecurityFlyoutEnabled = useIsExperimentalFeatureEnabled('securityFlyoutEnabled'); const { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); const handleClick = useCallback(() => { - dispatch( - timelineActions.toggleDetailPanel({ - panelView: 'eventDetail', - tabType: TimelineTabs.notes, - id: timelineId, - params: { - eventId, - indexName: selectedPatterns.join(','), - }, - }) - ); - }, [dispatch, eventId, selectedPatterns, timelineId]); + if (isSecurityFlyoutEnabled) { + dispatch( + openSecurityFlyoutByScope({ + flyoutScope: 'timelineFlyout', + right: { + panelKind: 'event', + params: { + eventId, + indexName: selectedPatterns.join(','), + }, + }, + }) + ); + } else { + dispatch( + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', + tabType: TimelineTabs.notes, + id: timelineId, + params: { + eventId, + indexName: selectedPatterns.join(','), + }, + }) + ); + } + }, [dispatch, eventId, isSecurityFlyoutEnabled, selectedPatterns, timelineId]); return ( { const [loading, detailsData, rawEventData, ecsData, refetchFlyoutData] = useTimelineEventsDetails( { - entityType: EntityType.EVENTS, indexName: alert.indexName ?? '', eventId: alert.id ?? '', runtimeMappings, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 29a1940413dad..bf68ce100c8a2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -10,7 +10,6 @@ import React, { useMemo } from 'react'; import deepEqual from 'fast-deep-equal'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { EntityType } from '@kbn/timelines-plugin/common'; import type { BrowserFields } from '../../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; @@ -24,7 +23,6 @@ import { HostIsolationPanel } from '../../../../detections/components/host_isola interface EventDetailsPanelProps { browserFields: BrowserFields; - entityType?: EntityType; expandedEvent: { eventId: string; indexName: string; @@ -41,7 +39,6 @@ interface EventDetailsPanelProps { const EventDetailsPanelComponent: React.FC = ({ browserFields, - entityType = 'events', // Default to events so only alerts have to pass entityType in expandedEvent, handleOnEventClosed, isDraggable, @@ -56,7 +53,6 @@ const EventDetailsPanelComponent: React.FC = ({ const eventIndex = getAlertIndexAlias(indexName, currentSpaceId) ?? indexName; const [loading, detailsData, rawEventData, ecsData, refetchFlyoutData] = useTimelineEventsDetails( { - entityType, indexName: eventIndex ?? '', eventId: expandedEvent.eventId ?? '', runtimeMappings, 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 index 77f42e1fb003a..561db1272ed51 100644 --- 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 @@ -154,7 +154,6 @@ export const useDetailPanel = ({ shouldShowDetailsPanel ? ( void; isFlyoutView?: boolean; runtimeMappings: MappingRuntimeFields; @@ -43,7 +41,6 @@ interface DetailsPanelProps { export const DetailsPanel = React.memo( ({ browserFields, - entityType, handleOnPanelClosed, isFlyoutView, runtimeMappings, @@ -96,7 +93,6 @@ export const DetailsPanel = React.memo( visiblePanel = ( = ({ }) => { const trGroupRef = useRef(null); const dispatch = useDispatch(); + const isSecurityFlyoutEnabled = useIsExperimentalFeatureEnabled('securityFlyoutEnabled'); // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created const [activeStatefulEventContext] = useState({ timelineID: timelineId, @@ -199,18 +202,32 @@ const StatefulEventComponent: React.FC = ({ }, }; - dispatch( - timelineActions.toggleDetailPanel({ - ...updatedExpandedDetail, - tabType, - id: timelineId, - }) - ); - + if (isSecurityFlyoutEnabled) { + dispatch( + openSecurityFlyoutByScope({ + flyoutScope: 'timelineFlyout', + right: { + panelKind: 'event', + params: { + eventId, + indexName, + }, + }, + }) + ); + } else { + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType, + id: timelineId, + }) + ); + } if (timelineId === TimelineId.active && tabType === TimelineTabs.query) { activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); } - }, [dispatch, event._id, event._index, refetch, tabType, timelineId]); + }, [dispatch, event._id, event._index, isSecurityFlyoutEnabled, refetch, tabType, timelineId]); const associateNote = useCallback( (noteId: string) => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 0444510776d67..e32dfe7fe70a3 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -13,7 +13,6 @@ import { Subscription } from 'rxjs'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; -import { EntityType } from '@kbn/timelines-plugin/common'; import { useKibana } from '../../../common/lib/kibana'; import type { SearchHit, @@ -32,7 +31,6 @@ export interface EventsArgs { } export interface UseTimelineEventsDetailsProps { - entityType?: EntityType; indexName: string; eventId: string; runtimeMappings: MappingRuntimeFields; @@ -40,7 +38,6 @@ export interface UseTimelineEventsDetailsProps { } export const useTimelineEventsDetails = ({ - entityType = EntityType.EVENTS, indexName, eventId, runtimeMappings, @@ -124,7 +121,6 @@ export const useTimelineEventsDetails = ({ setTimelineDetailsRequest((prevRequest) => { const myRequest = { ...(prevRequest ?? {}), - entityType, indexName, eventId, factoryQueryType: TimelineEventsQueries.details, @@ -135,7 +131,7 @@ export const useTimelineEventsDetails = ({ } return prevRequest; }); - }, [entityType, eventId, indexName, runtimeMappings]); + }, [eventId, indexName, runtimeMappings]); useEffect(() => { timelineDetailsSearch(timelineDetailsRequest); diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 3c756aff43f60..67277508be2a6 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -134,5 +134,6 @@ "@kbn/controls-plugin", "@kbn/shared-ux-utility", "@kbn/user-profile-components", + "@kbn/security-solution-plugin", ] }