From 5b6887dd3df7e92dce72bf58ff80e999d501a721 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:43:28 -0600 Subject: [PATCH] [Security Solution][Expandable flyout] Introducing Flyout history in document flyout (#184970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR introduced flyout history in expandable flyouts to keep tracked of previously opened flyouts. The history button is available when feature flag `newExpandableFlyoutNavigationEnabled` is enabled. Flag is currently default `False` ### Changes in [kbn-expandable-flyout](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout) package - When `openFlyout` is called, the **right** panel will be appended to the `history` slice in redux. - History can be accessed via `useExpandableFlyoutHistory` API ![image](https://github.com/user-attachments/assets/081d6d6f-3c10-40f0-8882-73bc8c275e68) ### Changes to expandable flyouts in security solution - When feature flag is on, opening more than 1 flyout will show a history icon. Currently max at 10 entries - When user clicks a flyout from the history, it does not add on top on history, instead the position will be moved up. There is no duplicate entries. ![image](https://github.com/user-attachments/assets/3bc68519-5eea-4fb7-9386-f6688b28b525) **To illustrate how ordering works:** -> History: [host1, user1, alert1] -> clicks alert1 -> History: [alert1, host1, user1] Keep in mind this is slightly different in the actual implementation, as we do not display the current entry (i.e. alert1 in this example) ### Other changes in order to support flyout history - Added a preview panel for network. Previously we reused the panel for both network flyout and network preview. A dedicated network preview with out history is now available - Replaced `openRightPanel` with `openFlyout` in applicable places - Added `isPreview` and `isPreviewMode` checks in EA flyouts ## How to test - Enable feature flag `newExpandableFlyoutNavigationEnabled`
✅ Alerts page Available for alert, host, user, rule name and ip's
✅ Explore pages (event table) Available for events, host, user, rule name and ip's
✅ Cases Note: the rule and entity link still go to a page, this will be addressed in a separate PR
✅ Discover in severless - enable `discover.experimental.enabledProfiles: ['security-root-profile']`
❌ Disabled in alert preview
❌ Disabled in preview mode
## WIP - [x] Investigate performance with process history - [ ] Final ui of the entries - pending UIUX team ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- packages/kbn-expandable-flyout/README.md | 2 + packages/kbn-expandable-flyout/index.ts | 1 + .../src/components/container.test.tsx | 5 + .../src/components/preview_section.test.tsx | 1 + .../hooks/use_expandable_flyout_history.ts | 22 ++ .../src/index.stories.tsx | 9 + .../kbn-expandable-flyout/src/index.test.tsx | 1 + .../src/provider.test.tsx | 2 + .../src/store/reducers.test.ts | 64 ++++- .../src/store/reducers.ts | 8 + .../kbn-expandable-flyout/src/store/redux.ts | 2 + .../kbn-expandable-flyout/src/store/state.ts | 4 + .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../common/experimental_features.ts | 5 + .../left/components/host_details.test.tsx | 5 +- .../left/components/user_details.test.tsx | 5 +- .../components/alert_header_title.test.tsx | 5 - .../right/components/alert_header_title.tsx | 26 +- .../right/components/event_header_title.tsx | 34 +-- .../highlighted_fields_cell.test.tsx | 5 +- .../table_field_value_cell.test.tsx | 5 +- .../document_details/right/navigation.tsx | 4 +- .../right/utils/event_utils.tsx | 22 -- .../document_details/shared/utils.test.tsx | 46 ++- .../flyout/document_details/shared/utils.tsx | 73 +++++ .../entity_details/host_right/index.test.tsx | 25 +- .../entity_details/host_right/index.tsx | 13 +- .../entity_details/user_right/index.test.tsx | 25 +- .../entity_details/user_right/index.tsx | 6 +- .../security_solution/public/flyout/index.tsx | 8 +- .../public/flyout/network_details/header.tsx | 2 +- .../public/flyout/network_details/index.tsx | 37 ++- .../flyout/rule_details/right/content.tsx | 23 +- .../flyout/rule_details/right/header.tsx | 6 +- .../flyout/rule_details/right/index.test.tsx | 28 ++ .../flyout/rule_details/right/index.tsx | 5 +- .../shared/components/flyout_history.test.tsx | 52 ++++ .../shared/components/flyout_history.tsx | 97 +++++++ .../components/flyout_history_row.test.tsx | 270 ++++++++++++++++++ .../shared/components/flyout_history_row.tsx | 186 ++++++++++++ .../components/flyout_navigation.test.tsx | 68 ++++- .../shared/components/flyout_navigation.tsx | 59 +++- .../shared/components/preview_link.test.tsx | 5 +- .../flyout/shared/components/preview_link.tsx | 5 +- .../flyout/shared/components/test_ids.ts | 15 + .../flyout/shared/utils/history_utils.test.ts | 56 ++++ .../flyout/shared/utils/history_utils.ts | 31 ++ .../components/formatted_ip/index.test.tsx | 1 + .../components/formatted_ip/index.tsx | 1 + .../renderers/formatted_field_helpers.tsx | 14 +- .../body/renderers/host_name.test.tsx | 42 +-- .../timeline/body/renderers/host_name.tsx | 21 +- .../body/renderers/user_name.test.tsx | 40 +-- .../timeline/body/renderers/user_name.tsx | 20 +- 56 files changed, 1320 insertions(+), 206 deletions(-) create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts diff --git a/packages/kbn-expandable-flyout/README.md b/packages/kbn-expandable-flyout/README.md index 2bdd7ae3dfc48..930bf00334c56 100644 --- a/packages/kbn-expandable-flyout/README.md +++ b/packages/kbn-expandable-flyout/README.md @@ -61,6 +61,8 @@ To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi] > The expandable flyout propagates the `onClose` callback from the EuiFlyout component. As we recommend having a single instance of the flyout in your application, it's up to the application's code to dispatch the event (through Redux, window events, observable, prop drilling...). +When calling `openFlyout`, the right panel state is automatically appended in the `history` slice in the redux context. To access the flyout's history, you can use the [useExpandableFlyoutHistory](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts) hook. + ## Usage To use the expandable flyout in your plugin, first you need wrap your code with the [context provider](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx) at a high enough level as follows: diff --git a/packages/kbn-expandable-flyout/index.ts b/packages/kbn-expandable-flyout/index.ts index 5816b6673dbc1..48478334b6590 100644 --- a/packages/kbn-expandable-flyout/index.ts +++ b/packages/kbn-expandable-flyout/index.ts @@ -11,6 +11,7 @@ export { ExpandableFlyout } from './src'; export { useExpandableFlyoutApi } from './src/hooks/use_expandable_flyout_api'; export { useExpandableFlyoutState } from './src/hooks/use_expandable_flyout_state'; +export { useExpandableFlyoutHistory } from './src/hooks/use_expandable_flyout_history'; export { type FlyoutPanels as ExpandableFlyoutState } from './src/store/state'; diff --git a/packages/kbn-expandable-flyout/src/components/container.test.tsx b/packages/kbn-expandable-flyout/src/components/container.test.tsx index fa27d81fa4437..5482d73893c3a 100644 --- a/packages/kbn-expandable-flyout/src/components/container.test.tsx +++ b/packages/kbn-expandable-flyout/src/components/container.test.tsx @@ -58,6 +58,7 @@ describe('Container', () => { }, left: undefined, preview: undefined, + history: [{ id: 'key' }], }, }, }, @@ -85,6 +86,7 @@ describe('Container', () => { id: 'key', }, preview: undefined, + history: [], }, }, }, @@ -112,6 +114,7 @@ describe('Container', () => { id: 'key', }, ], + history: [], }, }, }, @@ -137,6 +140,7 @@ describe('Container', () => { }, left: undefined, preview: undefined, + history: [], }, }, }, @@ -163,6 +167,7 @@ describe('Container', () => { }, left: undefined, preview: undefined, + history: [], }, }, }, diff --git a/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx b/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx index 6476ac91c0031..a6f927ca4eb0d 100644 --- a/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx +++ b/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx @@ -30,6 +30,7 @@ describe('PreviewSection', () => { id: 'key', }, ], + history: [], }, }, }, diff --git a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts new file mode 100644 index 0000000000000..415703a8811e4 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts @@ -0,0 +1,22 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants'; +import { useExpandableFlyoutContext } from '../context'; +import { selectHistoryById, useSelector } from '../store/redux'; + +/** + * This hook allows you to access the flyout state, read open right, left and preview panels. + */ +export const useExpandableFlyoutHistory = () => { + const { urlKey } = useExpandableFlyoutContext(); + // if no urlKey is provided, we are in memory storage mode and use the reserved word 'memory' + const id = urlKey || REDUX_ID_FOR_MEMORY_STORAGE; + return useSelector(selectHistoryById(id)); +}; diff --git a/packages/kbn-expandable-flyout/src/index.stories.tsx b/packages/kbn-expandable-flyout/src/index.stories.tsx index 1e8e08d96c073..b827d02f1da08 100644 --- a/packages/kbn-expandable-flyout/src/index.stories.tsx +++ b/packages/kbn-expandable-flyout/src/index.stories.tsx @@ -111,6 +111,7 @@ export const Right: Story = () => { }, left: undefined, preview: undefined, + history: [{ id: 'right' }], }, }, }, @@ -139,6 +140,7 @@ export const Left: Story = () => { id: 'left', }, preview: undefined, + history: [{ id: 'right' }], }, }, }, @@ -171,6 +173,7 @@ export const Preview: Story = () => { id: 'preview1', }, ], + history: [{ id: 'right' }], }, }, }, @@ -206,6 +209,7 @@ export const MultiplePreviews: Story = () => { id: 'preview2', }, ], + history: [{ id: 'right' }], }, }, }, @@ -232,6 +236,7 @@ export const CollapsedPushMode: Story = () => { }, left: undefined, preview: undefined, + history: [{ id: 'right' }], }, }, }, @@ -260,6 +265,7 @@ export const ExpandedPushMode: Story = () => { id: 'left', }, preview: undefined, + history: [{ id: 'right' }], }, }, }, @@ -288,6 +294,7 @@ export const DisableTypeSelection: Story = () => { id: 'left', }, preview: undefined, + history: [{ id: 'right' }], }, }, }, @@ -318,6 +325,7 @@ export const ResetWidths: Story = () => { id: 'left', }, preview: undefined, + history: [{ id: 'right' }], }, }, }, @@ -343,6 +351,7 @@ export const DisableResizeWidthSelection: Story = () => { id: 'left', }, preview: undefined, + history: [{ id: 'right' }], }, }, }, diff --git a/packages/kbn-expandable-flyout/src/index.test.tsx b/packages/kbn-expandable-flyout/src/index.test.tsx index 8ee4ff32a9821..5f0bada8653a1 100644 --- a/packages/kbn-expandable-flyout/src/index.test.tsx +++ b/packages/kbn-expandable-flyout/src/index.test.tsx @@ -51,6 +51,7 @@ describe('ExpandableFlyout', () => { }, left: undefined, preview: undefined, + history: [{ id: 'key' }], }, }, }, diff --git a/packages/kbn-expandable-flyout/src/provider.test.tsx b/packages/kbn-expandable-flyout/src/provider.test.tsx index 7d7e6f8ab10c0..0d8e935098a3f 100644 --- a/packages/kbn-expandable-flyout/src/provider.test.tsx +++ b/packages/kbn-expandable-flyout/src/provider.test.tsx @@ -34,6 +34,7 @@ describe('UrlSynchronizer', () => { right: { id: 'key1' }, left: { id: 'key11' }, preview: undefined, + history: [{ id: 'key1' }], }, }, needsSync: true, @@ -93,6 +94,7 @@ describe('UrlSynchronizer', () => { right: { id: 'key1' }, left: { id: 'key2' }, preview: undefined, + history: [{ id: 'key1' }], }, }, needsSync: true, diff --git a/packages/kbn-expandable-flyout/src/store/reducers.test.ts b/packages/kbn-expandable-flyout/src/store/reducers.test.ts index 1a887333daca8..e77a53c911319 100644 --- a/packages/kbn-expandable-flyout/src/store/reducers.test.ts +++ b/packages/kbn-expandable-flyout/src/store/reducers.test.ts @@ -74,19 +74,21 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, }); }); - it('should override all panels in the state', () => { + it('should override all panels in the state and update history', () => { const state: PanelsState = { byId: { [id1]: { left: leftPanel1, right: rightPanel1, preview: [previewPanel1, { id: 'preview' }], + history: [rightPanel1], }, }, }; @@ -104,6 +106,7 @@ describe('panelsReducer', () => { left: leftPanel2, right: rightPanel2, preview: [previewPanel2], + history: [rightPanel1, rightPanel2], }, }, needsSync: true, @@ -117,6 +120,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -132,6 +136,7 @@ describe('panelsReducer', () => { left: undefined, right: rightPanel2, preview: undefined, + history: [rightPanel1, rightPanel2], }, }, needsSync: true, @@ -145,6 +150,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -160,11 +166,13 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, [id2]: { left: undefined, right: rightPanel2, preview: undefined, + history: [rightPanel2], }, }, needsSync: true, @@ -173,7 +181,7 @@ describe('panelsReducer', () => { }); describe('should handle openRightPanel action', () => { - it('should add right panel to empty state', () => { + it('should add right panel to empty state but does not update history', () => { const state: PanelsState = initialPanelsState; const action = openRightPanelAction({ right: rightPanel1, id: id1 }); const newState: PanelsState = panelsReducer(state, action); @@ -184,19 +192,21 @@ describe('panelsReducer', () => { left: undefined, right: rightPanel1, preview: undefined, + history: [], }, }, needsSync: true, }); }); - it('should replace right panel', () => { + it('should replace right panel but does not update history', () => { const state: PanelsState = { byId: { [id1]: { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -209,6 +219,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel2, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, @@ -222,6 +233,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -234,11 +246,13 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, [id2]: { left: undefined, right: rightPanel2, preview: undefined, + history: [], }, }, needsSync: true, @@ -258,6 +272,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: undefined, preview: undefined, + history: [], }, }, needsSync: true, @@ -271,6 +286,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [], }, }, }; @@ -283,6 +299,7 @@ describe('panelsReducer', () => { left: leftPanel2, right: rightPanel1, preview: [previewPanel1], + history: [], }, }, needsSync: true, @@ -296,6 +313,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [], }, }, }; @@ -308,11 +326,13 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [], }, [id2]: { left: leftPanel2, right: undefined, preview: undefined, + history: [], }, }, needsSync: true, @@ -332,6 +352,7 @@ describe('panelsReducer', () => { left: undefined, right: undefined, preview: [previewPanel1], + history: [], }, }, needsSync: true, @@ -345,6 +366,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [], }, }, }; @@ -357,6 +379,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1, previewPanel2], + history: [], }, }, needsSync: true, @@ -370,6 +393,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [], }, }, }; @@ -382,11 +406,13 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [], }, [id2]: { left: undefined, right: undefined, preview: [previewPanel2], + history: [], }, }, needsSync: true, @@ -413,6 +439,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: undefined, preview: [previewPanel1], + history: [], }, }, }; @@ -432,6 +459,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -445,6 +473,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: undefined, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, @@ -458,6 +487,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -471,6 +501,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, @@ -497,6 +528,7 @@ describe('panelsReducer', () => { left: undefined, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -516,6 +548,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -528,6 +561,7 @@ describe('panelsReducer', () => { left: undefined, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, @@ -541,6 +575,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -553,6 +588,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, @@ -579,6 +615,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: undefined, + history: [rightPanel1], }, }, }; @@ -598,6 +635,7 @@ describe('panelsReducer', () => { left: rightPanel1, right: leftPanel1, preview: [previewPanel1, previewPanel2], + history: [rightPanel1], }, }, }; @@ -610,6 +648,7 @@ describe('panelsReducer', () => { left: rightPanel1, right: leftPanel1, preview: undefined, + history: [rightPanel1], }, }, needsSync: true, @@ -623,6 +662,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -635,6 +675,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, @@ -661,6 +702,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: undefined, + history: [rightPanel1], }, }, }; @@ -677,9 +719,10 @@ describe('panelsReducer', () => { const state: PanelsState = { byId: { [id1]: { - left: rightPanel1, - right: leftPanel1, + left: leftPanel1, + right: rightPanel1, preview: [previewPanel1, previewPanel2], + history: [rightPanel1], }, }, }; @@ -689,9 +732,10 @@ describe('panelsReducer', () => { expect(newState).toEqual({ byId: { [id1]: { - left: rightPanel1, - right: leftPanel1, + left: leftPanel1, + right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: false, @@ -705,6 +749,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -717,6 +762,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: false, @@ -743,6 +789,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -755,6 +802,7 @@ describe('panelsReducer', () => { left: undefined, right: undefined, preview: undefined, + history: [rightPanel1], }, }, needsSync: true, @@ -768,6 +816,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, }; @@ -780,6 +829,7 @@ describe('panelsReducer', () => { left: leftPanel1, right: rightPanel1, preview: [previewPanel1], + history: [rightPanel1], }, }, needsSync: true, diff --git a/packages/kbn-expandable-flyout/src/store/reducers.ts b/packages/kbn-expandable-flyout/src/store/reducers.ts index b14aa0b1b703b..be2c47344526e 100644 --- a/packages/kbn-expandable-flyout/src/store/reducers.ts +++ b/packages/kbn-expandable-flyout/src/store/reducers.ts @@ -35,11 +35,15 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => { state.byId[id].right = right; state.byId[id].left = left; state.byId[id].preview = preview ? [preview] : undefined; + if (right) { + state.byId[id].history?.push(right); + } } else { state.byId[id] = { left, right, preview: preview ? [preview] : undefined, + history: right ? [right] : [], }; } @@ -54,6 +58,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => { left, right: undefined, preview: undefined, + history: [], }; } @@ -68,6 +73,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => { right, left: undefined, preview: undefined, + history: [], }; } @@ -90,6 +96,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => { right: undefined, left: undefined, preview: preview ? [preview] : undefined, + history: [], }; } @@ -149,6 +156,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => { right, left, preview: preview ? [preview] : undefined, + history: right ? [right] : [], // update history only when loading flyout on refresh }; } diff --git a/packages/kbn-expandable-flyout/src/store/redux.ts b/packages/kbn-expandable-flyout/src/store/redux.ts index d68b4a0295769..7f37017652bbc 100644 --- a/packages/kbn-expandable-flyout/src/store/redux.ts +++ b/packages/kbn-expandable-flyout/src/store/redux.ts @@ -48,6 +48,8 @@ const panelsSelector = createSelector(stateSelector, (state) => state.panels); export const selectPanelsById = (id: string) => createSelector(panelsSelector, (state) => state.byId[id] || {}); export const selectNeedsSync = () => createSelector(panelsSelector, (state) => state.needsSync); +export const selectHistoryById = (id: string) => + createSelector(stateSelector, (state) => state.panels.byId[id].history || []); const uiSelector = createSelector(stateSelector, (state) => state.ui); export const selectPushVsOverlay = createSelector(uiSelector, (state) => state.pushVsOverlay); diff --git a/packages/kbn-expandable-flyout/src/store/state.ts b/packages/kbn-expandable-flyout/src/store/state.ts index e158f61aaccd5..46326c311fbeb 100644 --- a/packages/kbn-expandable-flyout/src/store/state.ts +++ b/packages/kbn-expandable-flyout/src/store/state.ts @@ -22,6 +22,10 @@ export interface FlyoutPanels { * Panels to render in the preview section */ preview: FlyoutPanelProps[] | undefined; + /* + * History of the right panels that were opened + */ + history: FlyoutPanelProps[]; } export interface PanelsState { diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 414a9a7ee12a9..898aac0bf0860 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -40777,9 +40777,6 @@ "xpack.securitySolution.flyout.right.response.responseButtonLabel": "Réponse", "xpack.securitySolution.flyout.right.response.sectionTitle": "Réponse", "xpack.securitySolution.flyout.right.rule.rulePreviewTitle": "Afficher les détails de la règle", - "xpack.securitySolution.flyout.right.title.alertEventTitle": "Détails d'alerte externe", - "xpack.securitySolution.flyout.right.title.eventTitle": "Détails de l'événement", - "xpack.securitySolution.flyout.right.title.otherEventTitle": "Détails de {eventKind}", "xpack.securitySolution.flyout.right.user.userPreviewTitle": "Aperçu des détails de l'utilisateur", "xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip": "Investiguer dans la chronologie", "xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip": "Ouvrir l'analyseur de graphe", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 6c3164faad045..1e4409f185464 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -40634,9 +40634,6 @@ "xpack.securitySolution.flyout.right.response.responseButtonLabel": "応答", "xpack.securitySolution.flyout.right.response.sectionTitle": "応答", "xpack.securitySolution.flyout.right.rule.rulePreviewTitle": "ルール詳細をプレビュー", - "xpack.securitySolution.flyout.right.title.alertEventTitle": "外部アラート詳細", - "xpack.securitySolution.flyout.right.title.eventTitle": "イベントの詳細", - "xpack.securitySolution.flyout.right.title.otherEventTitle": "{eventKind}詳細", "xpack.securitySolution.flyout.right.user.userPreviewTitle": "ユーザー詳細をプレビュー", "xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip": "タイムラインで調査", "xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip": "アナライザーグラフを開く", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 3fb56f88c24e0..c1508435745b9 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -40032,9 +40032,6 @@ "xpack.securitySolution.flyout.right.response.responseButtonLabel": "响应", "xpack.securitySolution.flyout.right.response.sectionTitle": "响应", "xpack.securitySolution.flyout.right.rule.rulePreviewTitle": "预览规则详情", - "xpack.securitySolution.flyout.right.title.alertEventTitle": "外部告警详情", - "xpack.securitySolution.flyout.right.title.eventTitle": "事件详情", - "xpack.securitySolution.flyout.right.title.otherEventTitle": "{eventKind} 详情", "xpack.securitySolution.flyout.right.user.userPreviewTitle": "预览用户详情", "xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip": "在时间线中调查", "xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip": "打开分析器图表", diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 05b3c2ac9af51..428a48cf4b7be 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -258,6 +258,11 @@ export const allowedExperimentalValues = Object.freeze({ */ defendInsights: false, + /** + * Enables flyout history and new preview navigation + */ + newExpandableFlyoutNavigationEnabled: false, + /** * Enables CrowdStrike's RunScript RTR command */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx index a5b2307dd9ca3..e08a794665222 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx @@ -39,7 +39,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; -import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; jest.mock('@kbn/expandable-flyout'); @@ -313,10 +313,11 @@ describe('', () => { getAllByTestId(HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID)[0].click(); expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ - id: NetworkPanelKey, + id: NetworkPreviewPanelKey, params: { ip: '100.XXX.XXX', flowTarget: 'source', + scopeId: defaultProps.scopeId, banner: NETWORK_PREVIEW_BANNER, }, }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx index 28389919dec87..5bfb8a7df50db 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx @@ -37,7 +37,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; -import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; jest.mock('@kbn/expandable-flyout'); @@ -291,10 +291,11 @@ describe('', () => { getAllByTestId(USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID)[0].click(); expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ - id: NetworkPanelKey, + id: NetworkPreviewPanelKey, params: { ip: '100.XXX.XXX', flowTarget: 'source', + scopeId: defaultProps.scopeId, banner: NETWORK_PREVIEW_BANNER, }, }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx index 8a8293badb6af..b2d8e64c34b45 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx @@ -83,9 +83,4 @@ describe('', () => { const { getByTestId } = renderHeader(mockContextValue); expect(getByTestId(NOTES_TITLE_TEST_ID)).toBeInTheDocument(); }); - - it('should render fall back values if document is not alert', () => { - const { getByTestId } = renderHeader({ ...mockContextValue, dataFormattedForFieldBrowser: [] }); - expect(getByTestId(HEADER_TEXT_TEST_ID)).toHaveTextContent('Document details'); - }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx index cc7ef14585833..529e3d43b6056 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx @@ -8,7 +8,6 @@ import React, { memo, useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink } from '@elastic/eui'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; -import { i18n } from '@kbn/i18n'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { Notes } from './notes'; import { useRuleDetailsLink } from '../../shared/hooks/use_rule_details_link'; @@ -22,6 +21,7 @@ import { PreferenceFormattedDate } from '../../../../common/components/formatted import { FLYOUT_ALERT_HEADER_TITLE_TEST_ID, ALERT_SUMMARY_PANEL_TEST_ID } from './test_ids'; import { Assignees } from './assignees'; import { FlyoutTitle } from '../../../shared/components/flyout_title'; +import { getAlertTitle } from '../../shared/utils'; // minWidth for each block, allows to switch for a 1 row 4 blocks to 2 rows with 2 block each const blockStyles = { @@ -44,17 +44,15 @@ export const AlertHeaderTitle = memo(() => { 'securitySolutionNotesDisabled' ); - const { isAlert, ruleName, timestamp, ruleId } = useBasicDataFromDetailsData( - dataFormattedForFieldBrowser - ); - + const { ruleName, timestamp, ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const title = useMemo(() => getAlertTitle({ ruleName }), [ruleName]); const href = useRuleDetailsLink({ ruleId: !isPreview ? ruleId : null }); const ruleTitle = useMemo( () => href ? ( { ) : ( ), - [ruleName, href] + [title, href] ); const { refetch } = useRefetchByScope({ scopeId }); @@ -86,17 +84,7 @@ export const AlertHeaderTitle = memo(() => { {timestamp && } - {isAlert && ruleName ? ( - ruleTitle - ) : ( - - )} + {ruleTitle} {securitySolutionNotesDisabled ? ( { const eventKind = getField(getFieldsData('event.kind')); const eventCategory = getField(getFieldsData('event.category')); - const title = useMemo(() => { - const defaultTitle = i18n.translate('xpack.securitySolution.flyout.right.title.eventTitle', { - defaultMessage: `Event details`, - }); - - if (eventKind === 'event' && eventCategory) { - const fieldName = EVENT_CATEGORY_TO_FIELD[eventCategory]; - return getField(getFieldsData(fieldName)) ?? defaultTitle; - } - - if (eventKind === 'alert') { - return i18n.translate('xpack.securitySolution.flyout.right.title.alertEventTitle', { - defaultMessage: 'External alert details', - }); - } - - return eventKind - ? i18n.translate('xpack.securitySolution.flyout.right.title.otherEventTitle', { - defaultMessage: '{eventKind} details', - values: { - eventKind: startCase(eventKind), - }, - }) - : defaultTitle; - }, [eventKind, getFieldsData, eventCategory]); + const title = useMemo( + () => getEventTitle({ eventKind, eventCategory, getFieldsData }), + [eventKind, eventCategory, getFieldsData] + ); return ( <> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx index d819365da00b7..ff003813f260d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx @@ -26,7 +26,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from './host_entity_overview'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from './user_entity_overview'; -import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; jest.mock('../../../../management/hooks'); @@ -137,10 +137,11 @@ describe('', () => { getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID).click(); expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ - id: NetworkPanelKey, + id: NetworkPreviewPanelKey, params: { ip: '100:XXX:XXX', flowTarget: 'source', + scopeId: panelContextValue.scopeId, banner: NETWORK_PREVIEW_BANNER, }, }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/table_field_value_cell.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/table_field_value_cell.test.tsx index ee680f1061621..eec53dbe3d262 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/table_field_value_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/table_field_value_cell.test.tsx @@ -14,7 +14,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { EventFieldsData } from '../../../../common/components/event_details/types'; import { TableFieldValueCell } from './table_field_value_cell'; import { TestProviders } from '../../../../common/mock'; -import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { FLYOUT_TABLE_PREVIEW_LINK_FIELD_TEST_ID } from './test_ids'; import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; @@ -217,10 +217,11 @@ describe('TableFieldValueCell', () => { screen.getByTestId(`${FLYOUT_TABLE_PREVIEW_LINK_FIELD_TEST_ID}-0`).click(); expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ - id: NetworkPanelKey, + id: NetworkPreviewPanelKey, params: { ip: '127.0.0.1', flowTarget: 'source', + scopeId: 'scopeId', banner: NETWORK_PREVIEW_BANNER, }, }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/navigation.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/navigation.tsx index c3ee6a7d7a51a..2a17b76c03855 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/navigation.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/navigation.tsx @@ -25,7 +25,7 @@ interface PanelNavigationProps { export const PanelNavigation: FC = memo(({ flyoutIsExpandable }) => { const { telemetry } = useKibana().services; const { openLeftPanel } = useExpandableFlyoutApi(); - const { eventId, indexName, scopeId } = useDocumentDetailsContext(); + const { eventId, indexName, scopeId, isPreview } = useDocumentDetailsContext(); const expandDetails = useCallback(() => { openLeftPanel({ @@ -47,6 +47,8 @@ export const PanelNavigation: FC = memo(({ flyoutIsExpanda flyoutIsExpandable={flyoutIsExpandable} expandDetails={expandDetails} actions={} + isPreviewMode={false} + isPreview={isPreview} /> ); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx index 59c06629e2a4c..5f39d73bd31f9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx @@ -48,25 +48,3 @@ export const getEcsAllowedValueDescription = (fieldName: FieldName, value: strin }) ); }; - -// mapping of event category to the field displayed as title -export const EVENT_CATEGORY_TO_FIELD: Record = { - authentication: 'user.name', - configuration: '', - database: '', - driver: '', - email: '', - file: 'file.name', - host: 'host.name', - iam: '', - intrusion_detection: '', - malware: '', - network: '', - package: '', - process: 'process.name', - registry: '', - session: '', - threat: '', - vulnerability: '', - web: '', -}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx index 531bc1b57df51..6c9e9917679fd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { getField, getFieldArray } from './utils'; +import { getField, getFieldArray, getEventTitle, getAlertTitle } from './utils'; -describe('test getField', () => { +describe('getField', () => { it('should return the string value if field is a string', () => { expect(getField('test string')).toBe('test string'); }); @@ -29,7 +29,7 @@ describe('test getField', () => { }); }); -describe('test getFieldArray', () => { +describe('getFieldArray', () => { it('should return the string value in an array if field is a string', () => { expect(getFieldArray('test string')).toStrictEqual(['test string']); }); @@ -47,3 +47,43 @@ describe('test getFieldArray', () => { expect(getFieldArray(null)).toStrictEqual([]); }); }); + +describe('getEventTitle', () => { + it('should return event title based on category when event kind is event', () => { + expect( + getEventTitle({ + eventKind: 'event', + eventCategory: 'process', + getFieldsData: (field) => (field === 'process.name' ? 'process name' : ''), + }) + ).toBe('process name'); + }); + + it('should return External alert details when event kind is alert', () => { + expect( + getEventTitle({ eventKind: 'alert', eventCategory: null, getFieldsData: jest.fn() }) + ).toBe('External alert details'); + }); + + it('should return generic event details when event kind is not event or alert', () => { + expect( + getEventTitle({ eventKind: 'metric', eventCategory: null, getFieldsData: jest.fn() }) + ).toBe('Metric details'); + }); + + it('should return Event details when event kind is null', () => { + expect(getEventTitle({ eventKind: null, eventCategory: null, getFieldsData: jest.fn() })).toBe( + 'Event details' + ); + }); +}); + +describe('getAlertTitle', () => { + it('should return Document details when ruleName is undefined', () => { + expect(getAlertTitle({ ruleName: undefined })).toBe('Document details'); + }); + + it('should return ruleName when ruleName is defined', () => { + expect(getAlertTitle({ ruleName: 'test rule' })).toBe('test rule'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx index 72d568325676e..9953fa0fbbfb1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx @@ -4,6 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { i18n } from '@kbn/i18n'; +import { startCase } from 'lodash'; +import type { GetFieldsData } from './hooks/use_get_fields_data'; /** * Helper function to retrieve a field's value (used in combination with the custom hook useGetFieldsData (https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts) @@ -33,3 +36,73 @@ export const getFieldArray = (field: unknown | unknown[]) => { } return []; }; + +// mapping of event category to the field displayed as title +export const EVENT_CATEGORY_TO_FIELD: Record = { + authentication: 'user.name', + configuration: '', + database: '', + driver: '', + email: '', + file: 'file.name', + host: 'host.name', + iam: '', + intrusion_detection: '', + malware: '', + network: '', + package: '', + process: 'process.name', + registry: '', + session: '', + threat: '', + vulnerability: '', + web: '', +}; + +/** + * Helper function to retrieve the alert title + */ +export const getAlertTitle = ({ ruleName }: { ruleName?: string | null }) => { + const defaultAlertTitle = i18n.translate( + 'xpack.securitySolution.flyout.right.header.headerTitle', + { defaultMessage: 'Document details' } + ); + return ruleName ?? defaultAlertTitle; +}; + +/** + * Helper function to retrieve the event title + */ +export const getEventTitle = ({ + eventKind, + eventCategory, + getFieldsData, +}: { + eventKind: string | null; + eventCategory: string | null; + getFieldsData: GetFieldsData; +}) => { + const defaultTitle = i18n.translate('xpack.securitySolution.flyout.title.eventTitle', { + defaultMessage: `Event details`, + }); + + if (eventKind === 'event' && eventCategory) { + const fieldName = EVENT_CATEGORY_TO_FIELD[eventCategory]; + return getField(getFieldsData(fieldName)) ?? defaultTitle; + } + + if (eventKind === 'alert') { + return i18n.translate('xpack.securitySolution.flyout.title.alertEventTitle', { + defaultMessage: 'External alert details', + }); + } + + return eventKind + ? i18n.translate('xpack.securitySolution.flyout.title.otherEventTitle', { + defaultMessage: '{eventKind} details', + values: { + eventKind: startCase(eventKind), + }, + }) + : defaultTitle; +}; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx index 14bc3d3bd35db..f66ff0883a4ad 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx @@ -9,7 +9,16 @@ import { render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../common/mock'; import { mockHostRiskScoreState, mockObservedHostData } from '../mocks'; - +import type { + FlyoutPanelProps, + ExpandableFlyoutState, + ExpandableFlyoutApi, +} from '@kbn/expandable-flyout'; +import { + useExpandableFlyoutApi, + useExpandableFlyoutState, + useExpandableFlyoutHistory, +} from '@kbn/expandable-flyout'; import type { HostPanelProps } from '.'; import { HostPanel } from '.'; @@ -34,10 +43,24 @@ jest.mock('./hooks/use_observed_host', () => ({ useObservedHost: () => mockedUseObservedHost(), })); +const flyoutContextValue = { + closeLeftPanel: jest.fn(), +} as unknown as ExpandableFlyoutApi; + +const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[]; +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + useExpandableFlyoutHistory: jest.fn(), + useExpandableFlyoutState: jest.fn(), +})); + describe('HostPanel', () => { beforeEach(() => { mockedHostRiskScore.mockReturnValue(mockHostRiskScoreState); mockedUseObservedHost.mockReturnValue(mockObservedHostData); + jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory); + jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); }); it('renders', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx index 64c8e74d62714..abf7d5cf591dd 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx @@ -11,6 +11,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities'; +import { TableId } from '@kbn/securitysolution-data-table'; import { useNonClosedAlerts } from '../../../cloud_security_posture/hooks/use_non_closed_alerts'; import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../overview/components/detection_response/alerts_by_status/types'; import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id'; @@ -36,7 +37,6 @@ import { HostDetailsPanelKey } from '../host_details_left'; import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; import { HostPreviewPanelFooter } from '../host_preview/footer'; import { EntityEventTypes } from '../../../common/lib/telemetry'; - export interface HostPanelProps extends Record { contextID: string; scopeId: string; @@ -187,13 +187,14 @@ export const HostPanel = ({ <> ({ useIsExperimentalFeatureEnabled: () => mockedUseIsExperimentalFeatureEnabled(), })); +const flyoutContextValue = { + closeLeftPanel: jest.fn(), +} as unknown as ExpandableFlyoutApi; + +const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[]; +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + useExpandableFlyoutHistory: jest.fn(), + useExpandableFlyoutState: jest.fn(), +})); + describe('UserPanel', () => { beforeEach(() => { mockedUseRiskScore.mockReturnValue(mockRiskScoreState); mockedUseManagedUser.mockReturnValue(mockManagedUserData); mockedUseObservedUser.mockReturnValue(mockObservedUser); + jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory); + jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); }); it('renders', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx index 1a97c691f373f..182740a5afa57 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; +import { TableId } from '@kbn/securitysolution-data-table'; import { useNonClosedAlerts } from '../../../cloud_security_posture/hooks/use_non_closed_alerts'; import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id'; import type { Refetch } from '../../../common/types'; @@ -191,10 +192,11 @@ export const UserPanel = ({ <> , }, + { + key: NetworkPreviewPanelKey, + component: (props) => ( + + ), + }, ]; export const SECURITY_SOLUTION_ON_CLOSE_EVENT = `expandable-flyout-on-close-${Flyouts.securitySolution}`; diff --git a/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx b/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx index 8ffceb345b1e0..5ebdf9c9e8660 100644 --- a/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/network_details/header.tsx @@ -28,7 +28,7 @@ export interface PanelHeaderProps extends React.ComponentProps = memo( ({ ip, flowTarget, ...flyoutHeaderProps }: PanelHeaderProps) => { diff --git a/x-pack/plugins/security_solution/public/flyout/network_details/index.tsx b/x-pack/plugins/security_solution/public/flyout/network_details/index.tsx index d7e9d3519e4b2..37727e8074f0b 100644 --- a/x-pack/plugins/security_solution/public/flyout/network_details/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/network_details/index.tsx @@ -5,19 +5,23 @@ * 2.0. */ +import type { FC } from 'react'; import React, { memo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { i18n } from '@kbn/i18n'; +import { TableId } from '@kbn/securitysolution-data-table'; import type { FlowTargetSourceDest } from '../../../common/search_strategy'; import { PanelHeader } from './header'; import { PanelContent } from './content'; +import { FlyoutNavigation } from '../shared/components/flyout_navigation'; export interface NetworkExpandableFlyoutProps extends FlyoutPanelProps { - key: 'network-details'; + key: 'network-details' | 'network-preview'; params: NetworkPanelProps; } export const NetworkPanelKey: NetworkExpandableFlyoutProps['key'] = 'network-details'; +export const NetworkPreviewPanelKey: NetworkExpandableFlyoutProps['key'] = 'network-preview'; export const NETWORK_PREVIEW_BANNER = { title: i18n.translate('xpack.securitySolution.flyout.right.network.networkPreviewTitle', { @@ -36,18 +40,33 @@ export interface NetworkPanelProps extends Record { * Destination or source information */ flowTarget: FlowTargetSourceDest; + /** + * Scope ID + */ + scopeId: string; + /** + * If in preview mode, show preview banner and hide navigation + */ + isPreviewMode?: boolean; } /** * Panel to be displayed in the network details expandable flyout right section */ -export const NetworkPanel = memo(({ ip, flowTarget }: NetworkPanelProps) => { - return ( - <> - - - - ); -}); +export const NetworkPanel: FC = memo( + ({ ip, flowTarget, scopeId, isPreviewMode }) => { + return ( + <> + + + + + ); + } +); NetworkPanel.displayName = 'NetworkPanel'; diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx index 8acc6cfe9b715..54e6824ce5052 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/content.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { EuiText, EuiHorizontalRule, EuiSpacer, EuiPanel } from '@elastic/eui'; import { css } from '@emotion/css'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -50,12 +50,23 @@ export interface RuleDetailsProps { * Rule details content on the right section of expandable flyout */ export const PanelContent = memo(({ rule }: RuleDetailsProps) => { - const { ruleActionsData } = - rule != null ? getStepsData({ rule, detailsView: true }) : { ruleActionsData: null }; + const { ruleActionsData } = useMemo( + () => (rule != null ? getStepsData({ rule, detailsView: true }) : { ruleActionsData: null }), + [rule] + ); - const hasNotificationActions = Boolean(ruleActionsData?.actions?.length); - const hasResponseActions = Boolean(ruleActionsData?.responseActions?.length); - const hasActions = ruleActionsData != null && (hasNotificationActions || hasResponseActions); + const hasNotificationActions = useMemo( + () => Boolean(ruleActionsData?.actions?.length), + [ruleActionsData] + ); + const hasResponseActions = useMemo( + () => Boolean(ruleActionsData?.responseActions?.length), + [ruleActionsData] + ); + const hasActions = useMemo( + () => ruleActionsData != null && (hasNotificationActions || hasResponseActions), + [ruleActionsData, hasNotificationActions, hasResponseActions] + ); return ( diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx index 294870d6eebb7..027fc29479548 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/header.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { memo } from 'react'; import { EuiTitle, EuiText, @@ -43,7 +43,7 @@ export interface PanelHeaderProps { /** * Title component that shows basic information of a rule. This is displayed above rule overview body */ -export const PanelHeader: React.FC = ({ rule, isSuppressed }) => { +export const PanelHeader: React.FC = memo(({ rule, isSuppressed }) => { const href = useRuleDetailsLink({ ruleId: rule.id }); return ( @@ -86,6 +86,6 @@ export const PanelHeader: React.FC = ({ rule, isSuppressed }) ); -}; +}); PanelHeader.displayName = 'PanelHeader'; diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx index 146da2be34346..c1a629f881710 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx @@ -22,6 +22,16 @@ import { import type { RuleResponse } from '../../../../common/api/detection_engine'; import { BODY_TEST_ID, LOADING_TEST_ID } from './test_ids'; import { RULE_PREVIEW_FOOTER_TEST_ID } from '../preview/test_ids'; +import type { + FlyoutPanelProps, + ExpandableFlyoutState, + ExpandableFlyoutApi, +} from '@kbn/expandable-flyout'; +import { + useExpandableFlyoutApi, + useExpandableFlyoutState, + useExpandableFlyoutHistory, +} from '@kbn/expandable-flyout'; jest.mock('../../document_details/shared/hooks/use_rule_details_link'); @@ -31,6 +41,18 @@ jest.mock('../hooks/use_rule_details'); const mockGetStepsData = getStepsData as jest.Mock; jest.mock('../../../detections/pages/detection_engine/rules/helpers'); +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + useExpandableFlyoutState: jest.fn(), + useExpandableFlyoutHistory: jest.fn(), +})); + +const flyoutContextValue = { + closeLeftPanel: jest.fn(), +} as unknown as ExpandableFlyoutApi; + +const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[]; + const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); const rule = { name: 'rule name', description: 'rule description' } as RuleResponse; const ERROR_MESSAGE = 'There was an error displaying data.'; @@ -45,6 +67,12 @@ const renderRulePanel = (isPreviewMode = false) => ); describe('', () => { + beforeEach(() => { + jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory); + jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + }); + it('should render rule details and its sub sections', () => { mockUseRuleDetails.mockReturnValue({ rule, diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx index 10b22e22a575c..dfe5863f6a85b 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { FC } from 'react'; import React, { memo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { i18n } from '@kbn/i18n'; @@ -50,14 +51,14 @@ export interface RulePanelProps extends Record { /** * Displays a rule overview panel */ -export const RulePanel = memo(({ ruleId, isPreviewMode }: RulePanelProps) => { +export const RulePanel: FC = memo(({ ruleId, isPreviewMode }) => { const { rule, loading, isExistingRule } = useRuleDetails({ ruleId }); return loading ? ( ) : rule ? ( <> - + {isPreviewMode && } diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx new file mode 100644 index 0000000000000..f922190da3e73 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx @@ -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 React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { + FLYOUT_HISTORY_TEST_ID, + FLYOUT_HISTORY_BUTTON_TEST_ID, + FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID, + NO_DATA_HISTORY_ROW_TEST_ID, +} from './test_ids'; +import { FlyoutHistory } from './flyout_history'; + +const mockedHistory = [{ id: '1' }, { id: '2' }]; + +describe('FlyoutHistory', () => { + it('renders', () => { + const { getByTestId, queryByTestId } = render( + + + + ); + expect(getByTestId(FLYOUT_HISTORY_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID)).not.toBeInTheDocument(); + }); + + it('renders context menu when clicking the popover', () => { + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)); + expect(getByTestId(FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID)).toBeInTheDocument(); + }); + + it('render empty history message if history is empty', () => { + const { getByTestId } = render( + + + + ); + fireEvent.click(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)); + expect(getByTestId(NO_DATA_HISTORY_ROW_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx new file mode 100644 index 0000000000000..933106e28ed10 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React, { memo, useMemo, useState } from 'react'; +import { + EuiFlexItem, + EuiButtonEmpty, + EuiPopover, + EuiContextMenuPanel, + EuiText, + EuiContextMenuItem, + EuiTextColor, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { FlyoutHistoryRow } from './flyout_history_row'; +import { + FLYOUT_HISTORY_TEST_ID, + FLYOUT_HISTORY_BUTTON_TEST_ID, + FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID, + NO_DATA_HISTORY_ROW_TEST_ID, +} from './test_ids'; + +export interface HistoryProps { + /** + * A list of flyouts that have been opened + */ + history: FlyoutPanelProps[]; +} + +/** + * History of flyouts shown in top navigation + * Shows the title of previously opened flyout, and count of history of more than 1 flyout was opened + */ +export const FlyoutHistory: FC = memo(({ history }) => { + const [isPopoverOpen, setPopover] = useState(false); + const togglePopover = () => setPopover(!isPopoverOpen); + + const emptyHistoryMessage = useMemo(() => { + return ( + + + + + + + + + + ); + }, []); + + const historyDropdownPanels = useMemo( + () => + history.length > 0 + ? history.map((item, index) => { + return ; + }) + : [emptyHistoryMessage], + [history, emptyHistoryMessage] + ); + + return ( + + + } + isOpen={isPopoverOpen} + closePopover={togglePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + ); +}); + +FlyoutHistory.displayName = 'FlyoutHistory'; diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx new file mode 100644 index 0000000000000..f3dcaefd536e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx @@ -0,0 +1,270 @@ +/* + * 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 { render, fireEvent } from '@testing-library/react'; +import { + FlyoutHistoryRow, + RuleHistoryRow, + DocumentDetailsHistoryRow, + GenericHistoryRow, +} from './flyout_history_row'; +import { TestProviders } from '../../../common/mock'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; +import { useExpandableFlyoutApi, type ExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useRuleDetails } from '../../rule_details/hooks/use_rule_details'; +import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; +import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys'; +import { RulePanelKey } from '../../rule_details/right'; +import { UserPanelKey } from '../../entity_details/user_right'; +import { HostPanelKey } from '../../entity_details/host_right'; +import { NetworkPanelKey } from '../../network_details'; +import { + DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID, + RULE_HISTORY_ROW_TEST_ID, + HOST_HISTORY_ROW_TEST_ID, + USER_HISTORY_ROW_TEST_ID, + NETWORK_HISTORY_ROW_TEST_ID, + GENERIC_HISTORY_ROW_TEST_ID, +} from './test_ids'; + +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), + useExpandableFlyoutState: jest.fn(), + useExpandableFlyoutHistory: jest.fn(), + ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}, +})); + +jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback'); +jest.mock('../../document_details/shared/hooks/use_basic_data_from_details_data'); +jest.mock('../../rule_details/hooks/use_rule_details'); + +const flyoutContextValue = { + openFlyout: jest.fn(), +} as unknown as ExpandableFlyoutApi; + +const rowItems = { + alert: { + id: DocumentDetailsRightPanelKey, + params: { + id: 'eventId', + indexName: 'indexName', + scopeId: 'scopeId', + }, + }, + rule: { + id: RulePanelKey, + params: { ruleId: 'ruleId' }, + }, + host: { + id: HostPanelKey, + params: { hostName: 'host name' }, + }, + user: { + id: UserPanelKey, + params: { userName: 'user name' }, + }, + network: { + id: NetworkPanelKey, + params: { ip: 'ip' }, + }, +}; + +const mockedRuleResponse = { + rule: null, + loading: false, + isExistingRule: false, + error: null, + refresh: jest.fn(), +}; + +describe('FlyoutHistoryRow', () => { + beforeEach(() => { + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + jest.mocked(useRuleDetails).mockReturnValue({ + ...mockedRuleResponse, + rule: { name: 'rule name' } as RuleResponse, + }); + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: false }); + }); + + it('renders document details history row when key is alert', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ + isAlert: true, + ruleName: 'rule name', + }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + }); + + it('renders rule history row when key is rule', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${1}-${RULE_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + }); + + it('renders generic host history row when key is host', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${2}-${HOST_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + expect(getByTestId(`${2}-${HOST_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Host: host name'); + }); + + it('renders generic user history row when key is user', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${3}-${USER_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + expect(getByTestId(`${3}-${USER_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('User: user name'); + }); + + it('renders generic network history row when key is network', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${4}-${NETWORK_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + expect(getByTestId(`${4}-${NETWORK_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Network: ip'); + }); + + it('renders null when key is not supported', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('DocumentDetailsHistoryRow', () => { + beforeEach(() => { + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + }); + + it('renders alert title when isAlert is true and rule name is defined', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ + isAlert: true, + ruleName: 'rule name', + }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent( + 'Alert: rule name' + ); + }); + + it('renders default alert title when isAlert is true and rule name is undefined', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: true }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent( + 'Alert: Document details' + ); + }); + + it('renders event title when isAlert is false', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: false }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent( + 'Event details' + ); + }); + + it('opens document details flyout when clicked', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: true }); + + const { getByTestId } = render( + + + + ); + fireEvent.click(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)); + expect(flyoutContextValue.openFlyout).toHaveBeenCalledWith({ right: rowItems.alert }); + }); +}); + +describe('RuleHistoryRow', () => { + beforeEach(() => { + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + jest.mocked(useRuleDetails).mockReturnValue({ + rule: { name: 'rule name' } as RuleResponse, + loading: false, + isExistingRule: false, + }); + }); + + it('renders', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${0}-${RULE_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Rule: rule name'); + expect(useRuleDetails).toHaveBeenCalledWith({ ruleId: rowItems.rule.params.ruleId }); + }); + + it('opens rule details flyout when clicked', () => { + const { getByTestId } = render( + + + + ); + fireEvent.click(getByTestId(`${0}-${RULE_HISTORY_ROW_TEST_ID}`)); + expect(flyoutContextValue.openFlyout).toHaveBeenCalledWith({ right: rowItems.rule }); + }); +}); + +describe('GenericHistoryRow', () => { + beforeEach(() => { + jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + }); + + it('renders', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(`${0}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Row name: title'); + fireEvent.click(getByTestId(`${0}-${GENERIC_HISTORY_ROW_TEST_ID}`)); + expect(flyoutContextValue.openFlyout).toHaveBeenCalledWith({ right: rowItems.host }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx new file mode 100644 index 0000000000000..1081cae88e31d --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React, { memo, useMemo, useCallback } from 'react'; +import { EuiContextMenuItem, type EuiIconProps } from '@elastic/eui'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys'; +import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; +import { useEventDetails } from '../../document_details/shared/hooks/use_event_details'; +import { getField, getAlertTitle, getEventTitle } from '../../document_details/shared/utils'; +import { RulePanelKey } from '../../rule_details/right'; +import { UserPanelKey } from '../../entity_details/user_right'; +import { HostPanelKey } from '../../entity_details/host_right'; +import { NetworkPanelKey } from '../../network_details'; +import { useRuleDetails } from '../../rule_details/hooks/use_rule_details'; +import { + DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID, + RULE_HISTORY_ROW_TEST_ID, + GENERIC_HISTORY_ROW_TEST_ID, + HOST_HISTORY_ROW_TEST_ID, + USER_HISTORY_ROW_TEST_ID, + NETWORK_HISTORY_ROW_TEST_ID, +} from './test_ids'; + +export interface FlyoutHistoryRowProps { + /** + * Flyout item to display + */ + item: FlyoutPanelProps; + /** + * Index of the flyout in the list + */ + index: number; +} + +/** + * Row item for a flyout history row + */ +export const FlyoutHistoryRow: FC = memo(({ item, index }) => { + switch (item.id) { + case DocumentDetailsRightPanelKey: + return ; + case RulePanelKey: + return ; + case HostPanelKey: + return ( + + ); + case UserPanelKey: + return ( + + ); + case NetworkPanelKey: + return ( + + ); + default: + return null; + } +}); + +/** + * Row item for a document details + */ +export const DocumentDetailsHistoryRow: FC = memo(({ item, index }) => { + const { dataFormattedForFieldBrowser, getFieldsData } = useEventDetails({ + eventId: String(item?.params?.id), + indexName: String(item?.params?.indexName), + }); + const { ruleName, isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const eventKind = useMemo(() => getField(getFieldsData('event.kind')), [getFieldsData]); + const eventCategory = useMemo(() => getField(getFieldsData('event.category')), [getFieldsData]); + + const title = useMemo( + () => + isAlert + ? getAlertTitle({ ruleName }) + : getEventTitle({ eventKind, eventCategory, getFieldsData }), + [isAlert, ruleName, eventKind, eventCategory, getFieldsData] + ); + + return ( + + ); +}); + +/** + * Row item for a rule details flyout + */ +export const RuleHistoryRow: FC = memo(({ item, index }) => { + const ruleId = String(item?.params?.ruleId); + const { rule } = useRuleDetails({ ruleId }); + + return ( + + ); +}); + +interface GenericHistoryRowProps extends FlyoutHistoryRowProps { + /** + * Icon to display + */ + icon: EuiIconProps['type']; + /** + * Title to display + */ + title: string; + /** + * Name to display + */ + name: string; + /** + * Data test subject + */ + dataTestSubj?: string; +} + +/** + * Row item for a generic history row where the title is accessible in flyout params + */ +export const GenericHistoryRow: FC = memo( + ({ item, index, title, icon, name, dataTestSubj }) => { + const { openFlyout } = useExpandableFlyoutApi(); + const onClick = useCallback(() => { + openFlyout({ right: item }); + }, [openFlyout, item]); + + return ( + + {`${name}: `} + {title} + + ); + } +); + +FlyoutHistoryRow.displayName = 'FlyoutHistoryRow'; +DocumentDetailsHistoryRow.displayName = 'DocumentDetailsHistoryRow'; +RuleHistoryRow.displayName = 'RuleHistoryRow'; +GenericHistoryRow.displayName = 'GenericHistoryRow'; diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx index 321245ccde86e..372b11bcc9ef4 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx @@ -13,13 +13,16 @@ import { FlyoutNavigation } from './flyout_navigation'; import { COLLAPSE_DETAILS_BUTTON_TEST_ID, EXPAND_DETAILS_BUTTON_TEST_ID, + FLYOUT_HISTORY_BUTTON_TEST_ID, HEADER_ACTIONS_TEST_ID, } from './test_ids'; -import type { ExpandableFlyoutState } from '@kbn/expandable-flyout'; +import type { ExpandableFlyoutState, FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useExpandableFlyoutApi, type ExpandableFlyoutApi, useExpandableFlyoutState, + useExpandableFlyoutHistory, } from '@kbn/expandable-flyout'; const expandDetails = jest.fn(); @@ -31,9 +34,12 @@ const ExpandableFlyoutTestProviders: FC> = ({ children }) jest.mock('@kbn/expandable-flyout', () => ({ useExpandableFlyoutApi: jest.fn(), useExpandableFlyoutState: jest.fn(), + useExpandableFlyoutHistory: jest.fn(), ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}, })); +jest.mock('../../../common/hooks/use_experimental_features'); + const flyoutContextValue = { closeLeftPanel: jest.fn(), } as unknown as ExpandableFlyoutApi; @@ -42,6 +48,8 @@ describe('', () => { beforeEach(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState); + jest.mocked(useExpandableFlyoutHistory).mockReturnValue([]); + jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(false); }); describe('when flyout is expandable', () => { @@ -114,4 +122,62 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); }); + + it('should render empty component if isPreviewMode is true', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + const flyoutHistory = [ + { id: 'id1', params: {} }, + { id: 'id2', params: {} }, + ] as unknown as FlyoutPanelProps[]; + + describe('when flyout history is enabled', () => { + beforeEach(() => { + jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true); + jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory); + }); + + it('should render history button when there is no item in history', () => { + jest.mocked(useExpandableFlyoutHistory).mockReturnValue([]); + const { getByTestId } = render( + + + + ); + expect(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render history button when there are more than 1 unqie item in history', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render history button if in rule preview', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('should render empty component if isPreviewMode is true', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx index 1915c5a4484a4..89798687c3e62 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx @@ -15,9 +15,16 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { useExpandableFlyoutApi, useExpandableFlyoutState } from '@kbn/expandable-flyout'; +import { + useExpandableFlyoutApi, + useExpandableFlyoutState, + useExpandableFlyoutHistory, +} from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { FlyoutHistory } from './flyout_history'; +import { getProcessedHistory } from '../utils/history_utils'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { HEADER_ACTIONS_TEST_ID, COLLAPSE_DETAILS_BUTTON_TEST_ID, @@ -37,6 +44,14 @@ export interface FlyoutNavigationProps { * Optional actions to be placed on the right hand side of navigation */ actions?: React.ReactElement; + /** + * Boolean indicating the panel is shown in preview panel + */ + isPreviewMode?: boolean; + /** + * Boolean indicating the panel is shown in rule preview + */ + isPreview?: boolean; } /** @@ -44,9 +59,17 @@ export interface FlyoutNavigationProps { * pass in a list of actions to be displayed on top. */ export const FlyoutNavigation: FC = memo( - ({ flyoutIsExpandable = false, expandDetails, actions }) => { + ({ flyoutIsExpandable = false, expandDetails, actions, isPreviewMode, isPreview }) => { const { euiTheme } = useEuiTheme(); + const history = useExpandableFlyoutHistory(); + const isFlyoutHistoryEnabled = useIsExperimentalFeatureEnabled( + 'newExpandableFlyoutNavigationEnabled' + ); + const historyArray = useMemo(() => getProcessedHistory({ history, maxCount: 10 }), [history]); + // Don't show history in rule preview + const hasHistory = !isPreview && isFlyoutHistoryEnabled; + const panels = useExpandableFlyoutState(); const isExpanded: boolean = !!panels.left; @@ -101,7 +124,12 @@ export const FlyoutNavigation: FC = memo( [expandDetails] ); - return flyoutIsExpandable || actions ? ( + // do not show navigation in preview mode + if (isPreviewMode) { + return null; + } + + return flyoutIsExpandable || actions || hasHistory ? ( = memo( `} > - {flyoutIsExpandable && expandDetails && (isExpanded ? collapseButton : expandButton)} + + {flyoutIsExpandable && expandDetails && ( + + {isExpanded ? collapseButton : expandButton} + + )} + {hasHistory && ( + + + + )} + {actions && ( diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx index f1dedc18d3b1c..0dcda8d523392 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.test.tsx @@ -16,7 +16,7 @@ import { HostPreviewPanelKey } from '../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../document_details/right/components/host_entity_overview'; import { UserPreviewPanelKey } from '../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../document_details/right/components/user_entity_overview'; -import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details'; +import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details'; import { RulePreviewPanelKey, RULE_PREVIEW_BANNER } from '../../rule_details/right'; import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock'; @@ -105,10 +105,11 @@ describe('', () => { getByTestId('ip-link').click(); expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ - id: NetworkPanelKey, + id: NetworkPreviewPanelKey, params: { ip: '100:XXX:XXX', flowTarget: 'source', + scopeId: 'scopeId', banner: NETWORK_PREVIEW_BANNER, }, }); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.tsx index b6a4ea33ba4bc..712156243d4cb 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/preview_link.tsx @@ -22,7 +22,7 @@ import { HostPreviewPanelKey } from '../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../document_details/right/components/host_entity_overview'; import { UserPreviewPanelKey } from '../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../document_details/right/components/user_entity_overview'; -import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details'; +import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details'; import { RulePreviewPanelKey, RULE_PREVIEW_BANNER } from '../../rule_details/right'; import { DocumentEventTypes } from '../../../common/lib/telemetry'; @@ -46,9 +46,10 @@ const getPreviewParams = ( ): PreviewParams | null => { if (getEcsField(field)?.type === IP_FIELD_TYPE) { return { - id: NetworkPanelKey, + id: NetworkPreviewPanelKey, params: { ip: value, + scopeId, flowTarget: field.includes(FlowTargetSourceDest.destination) ? FlowTargetSourceDest.destination : FlowTargetSourceDest.source, diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts index f8a589f31561e..7f6be4ef2fa1d 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts @@ -36,3 +36,18 @@ export const HEADER_ACTIONS_TEST_ID = `${FLYOUT_NAVIGATION_TEST_ID}Actions` as c export const TITLE_HEADER_ICON_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Icon`; export const TITLE_HEADER_TEXT_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Text`; export const TITLE_LINK_ICON_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}LinkIcon`; + +/* History */ +export const FLYOUT_HISTORY_TEST_ID = `${PREFIX}History` as const; +export const FLYOUT_HISTORY_BUTTON_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}Button` as const; +export const FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID = + `${FLYOUT_HISTORY_TEST_ID}ContextPanel` as const; + +export const DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID = + `${FLYOUT_HISTORY_TEST_ID}DocumentDetailsRow` as const; +export const RULE_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}RuleRow` as const; +export const HOST_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}HostRow` as const; +export const USER_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}UserRow` as const; +export const NETWORK_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}NetworkRow` as const; +export const GENERIC_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}GenericRow` as const; +export const NO_DATA_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}NoDataRow` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts new file mode 100644 index 0000000000000..97257fa84dd8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts @@ -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 { getProcessedHistory } from './history_utils'; + +describe('getProcessedHistory', () => { + const simpleHistory = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]; + const complexHistory = [ + { id: '1' }, + { id: '2' }, + { id: '1' }, + { id: '3' }, + { id: '4' }, + { id: '2' }, + ]; + + it('returns a reversed history array and removes latest entry', () => { + // input: 1, 2, 3, 4 + // reverse: 4, 3, 2, 1 + // remove latest: 4, 3, 2 + const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 5 }); + expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }, { id: '1' }]); + }); + + it('returns processed history with the maxCount', () => { + // input: 1, 2, 3, 4 + // reverse: 4, 3, 2, 1 + // remove latest: 3, 2, 1 + // keep maxCount: 3, 2 + const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 2 }); + expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }]); + }); + + it('removes duplicates and reverses', () => { + // input: 1, 2, 1, 3, 4, 2 + // reverse: 2, 4, 3, 1, 2, 1 + // remove duplicates: 2, 4, 3, 1 + // remove latest: 4, 3, 1 + const processedHistory = getProcessedHistory({ history: complexHistory, maxCount: 5 }); + expect(processedHistory).toEqual([{ id: '4' }, { id: '3' }, { id: '1' }]); + }); + + it('returns empty array if history only has one entry', () => { + const processedHistory = getProcessedHistory({ history: [{ id: '1' }], maxCount: 5 }); + expect(processedHistory).toEqual([]); + }); + + it('returns empty array if history is empty', () => { + const processedHistory = getProcessedHistory({ history: [], maxCount: 5 }); + expect(processedHistory).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts new file mode 100644 index 0000000000000..ef31daa7f83f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts @@ -0,0 +1,31 @@ +/* + * 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 { FlyoutPanelProps } from '@kbn/expandable-flyout'; + +/** + * Helper function that reverses the history array, + * removes duplicates and the most recent item + * @returns a history array of maxCount length + */ +export const getProcessedHistory = ({ + history, + maxCount, +}: { + history: FlyoutPanelProps[]; + maxCount: number; +}): FlyoutPanelProps[] => { + // Step 1: reverse history so the most recent is first + const reversedHistory = history.slice().reverse(); + + // Step 2: remove duplicates + const historyArray = Array.from(new Set(reversedHistory.map((i) => JSON.stringify(i)))).map((i) => + JSON.parse(i) + ); + + // Omit the first (current) entry and return array of maxCount length + return historyArray.slice(1, maxCount + 1); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx index 339a55a8b6d56..a132a1745af63 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.test.tsx @@ -113,6 +113,7 @@ describe('FormattedIp', () => { params: { ip: props.value, flowTarget: 'source', + scopeId: TimelineId.active, }, }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index fc03759e17b14..3871a20f6b695 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -195,6 +195,7 @@ const AddressLinksItemComponent: React.FC = ({ id: NetworkPanelKey, params: { ip: address, + scopeId: eventContext.timelineID, flowTarget: fieldName.includes(FlowTargetSourceDest.destination) ? FlowTargetSourceDest.destination : FlowTargetSourceDest.source, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx index d41c0238ce592..057f108834ee6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx @@ -67,7 +67,7 @@ export const RenderRuleName: React.FC = ({ title, value, }) => { - const { openRightPanel } = useExpandableFlyoutApi(); + const { openFlyout } = useExpandableFlyoutApi(); const eventContext = useContext(StatefulEventContext); const ruleName = `${value}`; @@ -91,14 +91,16 @@ export const RenderRuleName: React.FC = ({ return; } - openRightPanel({ - id: RulePanelKey, - params: { - ruleId, + openFlyout({ + right: { + id: RulePanelKey, + params: { + ruleId, + }, }, }); }, - [navigateToApp, ruleId, search, openInNewTab, openRightPanel, eventContext, isInTimelineContext] + [navigateToApp, ruleId, search, openInNewTab, openFlyout, eventContext, isInTimelineContext] ); const href = useMemo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx index a7769069ff197..24f704e5f846d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx @@ -16,7 +16,7 @@ import { TableId } from '@kbn/securitysolution-data-table'; import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -const mockOpenRightPanel = jest.fn(); +const mockOpenFlyout = jest.fn(); jest.mock('@kbn/expandable-flyout'); @@ -28,7 +28,7 @@ describe('HostName', () => { beforeEach(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue({ ...createExpandableFlyoutApiMock(), - openRightPanel: mockOpenRightPanel, + openFlyout: mockOpenFlyout, }); }); @@ -81,7 +81,7 @@ describe('HostName', () => { wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); }); }); @@ -103,7 +103,7 @@ describe('HostName', () => { wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); }); }); @@ -125,7 +125,7 @@ describe('HostName', () => { wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); }); }); @@ -146,13 +146,15 @@ describe('HostName', () => { wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).toHaveBeenCalledWith({ - id: 'host-panel', - params: { - hostName: props.value, - contextID: props.contextId, - scopeId: TableId.alertsOnAlertsPage, - isDraggable: false, + expect(mockOpenFlyout).toHaveBeenCalledWith({ + right: { + id: 'host-panel', + params: { + hostName: props.value, + contextID: props.contextId, + scopeId: TableId.alertsOnAlertsPage, + isDraggable: false, + }, }, }); }); @@ -175,13 +177,15 @@ describe('HostName', () => { wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).toHaveBeenCalledWith({ - id: 'host-panel', - params: { - hostName: props.value, - contextID: props.contextId, - scopeId: 'timeline-1', - isDraggable: false, + expect(mockOpenFlyout).toHaveBeenCalledWith({ + right: { + id: 'host-panel', + params: { + hostName: props.value, + contextID: props.contextId, + scopeId: 'timeline-1', + isDraggable: false, + }, }, }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx index 41d403b3f2c5b..0597b3e99d207 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -44,7 +44,7 @@ const HostNameComponent: React.FC = ({ title, value, }) => { - const { openRightPanel } = useExpandableFlyoutApi(); + const { openFlyout } = useExpandableFlyoutApi(); const isInSecurityApp = useIsInSecurityApp(); @@ -70,18 +70,19 @@ const HostNameComponent: React.FC = ({ } const { timelineID } = eventContext; - - openRightPanel({ - id: HostPanelKey, - params: { - hostName, - contextID: contextId, - scopeId: timelineID, - isDraggable, + openFlyout({ + right: { + id: HostPanelKey, + params: { + hostName, + contextID: contextId, + scopeId: timelineID, + isDraggable, + }, }, }); }, - [contextId, eventContext, hostName, isDraggable, isInTimelineContext, onClick, openRightPanel] + [contextId, eventContext, hostName, isDraggable, isInTimelineContext, onClick, openFlyout] ); // The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx index bdb53f5850ec3..c3211d5fd776e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx @@ -16,7 +16,7 @@ import { TableId } from '@kbn/securitysolution-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout'; -const mockOpenRightPanel = jest.fn(); +const mockOpenFlyout = jest.fn(); jest.mock('@kbn/expandable-flyout'); @@ -28,7 +28,7 @@ describe('UserName', () => { beforeEach(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue({ ...createExpandableFlyoutApiMock(), - openRightPanel: mockOpenRightPanel, + openFlyout: mockOpenFlyout, }); }); afterEach(() => { @@ -78,7 +78,7 @@ describe('UserName', () => { wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); }); }); @@ -100,7 +100,7 @@ describe('UserName', () => { wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); }); }); @@ -121,13 +121,15 @@ describe('UserName', () => { wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).toHaveBeenCalledWith({ - id: 'user-panel', - params: { - userName: props.value, - contextID: props.contextId, - scopeId: TableId.alertsOnAlertsPage, - isDraggable: false, + expect(mockOpenFlyout).toHaveBeenCalledWith({ + right: { + id: 'user-panel', + params: { + userName: props.value, + contextID: props.contextId, + scopeId: TableId.alertsOnAlertsPage, + isDraggable: false, + }, }, }); }); @@ -150,13 +152,15 @@ describe('UserName', () => { wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click'); await waitFor(() => { - expect(mockOpenRightPanel).toHaveBeenCalledWith({ - id: 'user-panel', - params: { - userName: props.value, - contextID: props.contextId, - scopeId: 'timeline-1', - isDraggable: false, + expect(mockOpenFlyout).toHaveBeenCalledWith({ + right: { + id: 'user-panel', + params: { + userName: props.value, + contextID: props.contextId, + scopeId: 'timeline-1', + isDraggable: false, + }, }, }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx index 31a8424e5ea0c..88e8889353a03 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.tsx @@ -47,7 +47,7 @@ const UserNameComponent: React.FC = ({ const eventContext = useContext(StatefulEventContext); const userName = `${value}`; const isInTimelineContext = userName && eventContext?.timelineID; - const { openRightPanel } = useExpandableFlyoutApi(); + const { openFlyout } = useExpandableFlyoutApi(); const isInSecurityApp = useIsInSecurityApp(); @@ -65,17 +65,19 @@ const UserNameComponent: React.FC = ({ const { timelineID } = eventContext; - openRightPanel({ - id: UserPanelKey, - params: { - userName, - contextID: contextId, - scopeId: timelineID, - isDraggable, + openFlyout({ + right: { + id: UserPanelKey, + params: { + userName, + contextID: contextId, + scopeId: timelineID, + isDraggable, + }, }, }); }, - [contextId, eventContext, isDraggable, isInTimelineContext, onClick, openRightPanel, userName] + [contextId, eventContext, isDraggable, isInTimelineContext, onClick, openFlyout, userName] ); // The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined