diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts index e6c0d4c1bc637..ffc2aa727153b 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { DataTableModel } from '@kbn/securitysolution-data-table'; import { ALERT_FLYOUT, CELL_TEXT, @@ -24,6 +25,8 @@ import { login, visit, visitWithoutDateRange } from '../../tasks/login'; import { getUnmappedRule } from '../../objects/rule'; import { ALERTS_URL } from '../../urls/navigation'; import { tablePageSelector } from '../../screens/table_pagination'; +import { getLocalstorageEntryAsObject } from '../../helpers/common'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; describe('Alert details flyout', () => { describe('With unmapped fields', { testIsolation: false }, () => { @@ -124,11 +127,81 @@ describe('Alert details flyout', () => { }); it('should have the `kibana.alert.url` field set', () => { - const alertUrl = - 'http://localhost:5601/app/security/alerts/redirect/eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1?index=.alerts-security.alerts-default×tamp=2023-04-27T11:03:57.906Z'; openTable(); filterBy('kibana.alert.url'); - cy.get('[data-test-subj="formatted-field-kibana.alert.url"]').should('have.text', alertUrl); + cy.get('[data-test-subj="formatted-field-kibana.alert.url"]').should( + 'have.text', + 'http://localhost:5601/app/security/alerts/redirect/eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1?index=.alerts-security.alerts-default×tamp=2023-04-27T11:03:57.906Z' + ); + }); + }); + + describe('Localstorage management', { testIsolation: false }, () => { + before(() => { + cleanKibana(); + esArchiverLoad('query_alert'); + login(); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + beforeEach(() => { + expandFirstAlert(); + }); + + const alertTableKey = 'alerts-page'; + const getFlyoutConfig = (dataTable: { [alertTableKey]: DataTableModel }) => + dataTable?.[alertTableKey]?.expandedDetail?.query; + + /** + * Localstorage is updated after a delay here x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts + * We create this config to re-check localStorage 3 times, every 500ms to avoid any potential flakyness from that delay + */ + const storageCheckRetryConfig = { + timeout: 1500, + interval: 500, + }; + + it('should store the flyout state in localstorage', () => { + cy.get(OVERVIEW_RULE).should('be.visible'); + const localStorageCheck = () => + cy.getAllLocalStorage().then((storage) => { + const securityDataTable = getLocalstorageEntryAsObject(storage, 'securityDataTable'); + return getFlyoutConfig(securityDataTable)?.panelView === 'eventDetail'; + }); + + cy.waitUntil(localStorageCheck, storageCheckRetryConfig); + }); + + it('should remove the flyout details from local storage when closed', () => { + cy.get(OVERVIEW_RULE).should('be.visible'); + closeAlertFlyout(); + const localStorageCheck = () => + cy.getAllLocalStorage().then((storage) => { + const securityDataTable = getLocalstorageEntryAsObject(storage, 'securityDataTable'); + return getFlyoutConfig(securityDataTable)?.panelView === undefined; + }); + + cy.waitUntil(localStorageCheck, storageCheckRetryConfig); + }); + + it('should remove the flyout state from localstorage when navigating away without closing the flyout', () => { + cy.get(OVERVIEW_RULE).should('be.visible'); + goToRuleDetails(); + const localStorageCheck = () => + cy.getAllLocalStorage().then((storage) => { + const securityDataTable = getLocalstorageEntryAsObject(storage, 'securityDataTable'); + return getFlyoutConfig(securityDataTable)?.panelView === undefined; + }); + + cy.waitUntil(localStorageCheck, storageCheckRetryConfig); + }); + + it('should not reopen the flyout when navigating away from the alerts page and returning to it', () => { + cy.get(OVERVIEW_RULE).should('be.visible'); + goToRuleDetails(); + visit(ALERTS_URL); + cy.get(OVERVIEW_RULE).should('not.exist'); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/helpers/common.ts b/x-pack/plugins/security_solution/cypress/helpers/common.ts index e965684f1ab0e..82a8c711d7cc9 100644 --- a/x-pack/plugins/security_solution/cypress/helpers/common.ts +++ b/x-pack/plugins/security_solution/cypress/helpers/common.ts @@ -24,3 +24,17 @@ export const getDataTestSubjectSelectorStartWith = (dataTestSubjectValue: string * @param className the value passed to class property of the DOM element */ export const getClassSelector = (className: string) => `.${className}`; + +export const getLocalstorageEntryAsObject = (storage: Cypress.StorageByOrigin, field: string) => { + // baseUrl value from x-pack/plugins/security_solution/cypress/cypress.config.ts + const envLocalstorage = storage?.['http://localhost:5620']; + let result; + if (envLocalstorage && envLocalstorage[field]) { + try { + result = JSON.parse(envLocalstorage[field] as string); + } catch { + result = undefined; + } + } + return result; +}; diff --git a/x-pack/plugins/security_solution/cypress/tsconfig.json b/x-pack/plugins/security_solution/cypress/tsconfig.json index 8f818e8663a94..e04bff0591371 100644 --- a/x-pack/plugins/security_solution/cypress/tsconfig.json +++ b/x-pack/plugins/security_solution/cypress/tsconfig.json @@ -30,6 +30,7 @@ "@kbn/rison", "@kbn/datemath", "@kbn/guided-onboarding-plugin", - "@kbn/alerting-plugin" + "@kbn/alerting-plugin", + "@kbn/securitysolution-data-table" ] } diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx index 60ed09ab4d002..379ea0f37929f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx @@ -67,7 +67,7 @@ describe('AlertDetailsRedirect', () => { expect(historyMock.replace).toHaveBeenCalledWith({ hash: '', pathname: ALERTS_PATH, - search: `?query=(language:kuery,query:'_id: ${testAlertId}')&timerange=(global:(linkTo:!(timeline,socTrends),timerange:(from:'${testTimestamp}',kind:absolute,to:'2023-04-20T12:05:00.000Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:${testIndex}))`, + search: `?query=(language:kuery,query:'_id: ${testAlertId}')&timerange=(global:(linkTo:!(timeline,socTrends),timerange:(from:'${testTimestamp}',kind:absolute,to:'2023-04-20T12:05:00.000Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:${testIndex}))`, state: undefined, }); }); @@ -96,7 +96,7 @@ describe('AlertDetailsRedirect', () => { expect(historyMock.replace).toHaveBeenCalledWith({ hash: '', pathname: ALERTS_PATH, - search: `?query=(language:kuery,query:'_id: ${testAlertId}')&timerange=(global:(linkTo:!(timeline,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',kind:absolute,to:'2020-07-08T08:25:18.966Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:${testIndex}))`, + search: `?query=(language:kuery,query:'_id: ${testAlertId}')&timerange=(global:(linkTo:!(timeline,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',kind:absolute,to:'2020-07-08T08:25:18.966Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:${testIndex}))`, state: undefined, }); }); @@ -124,7 +124,7 @@ describe('AlertDetailsRedirect', () => { expect(historyMock.replace).toHaveBeenCalledWith({ hash: '', pathname: ALERTS_PATH, - search: `?query=(language:kuery,query:'_id: ${testAlertId}')&timerange=(global:(linkTo:!(timeline,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',kind:absolute,to:'2020-07-08T08:25:18.966Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:.internal${DEFAULT_ALERTS_INDEX}-default))`, + search: `?query=(language:kuery,query:'_id: ${testAlertId}')&timerange=(global:(linkTo:!(timeline,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',kind:absolute,to:'2020-07-08T08:25:18.966Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:.internal${DEFAULT_ALERTS_INDEX}-default))`, state: undefined, }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx index ec8bf7c1526e3..d2942acd0ca7b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx @@ -11,9 +11,12 @@ import { Redirect, useLocation, useParams } from 'react-router-dom'; import moment from 'moment'; import { encode } from '@kbn/rison'; +import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils'; +import type { FilterItemObj } from '../../../common/components/filter_group/types'; import { ALERTS_PATH, DEFAULT_ALERTS_INDEX } from '../../../../common/constants'; import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state'; import { inputsSelectors } from '../../../common/store'; +import { formatPageFilterSearchParam } from '../../../../common/utils/format_page_filter_search_param'; export const AlertDetailsRedirect = () => { const { alertId } = useParams<{ alertId: string }>(); @@ -61,7 +64,16 @@ export const AlertDetailsRedirect = () => { const kqlAppQuery = encode({ language: 'kuery', query: `_id: ${alertId}` }); - const url = `${ALERTS_PATH}?${URL_PARAM_KEY.appQuery}=${kqlAppQuery}&${URL_PARAM_KEY.timerange}=${timerange}&${URL_PARAM_KEY.eventFlyout}=${flyoutString}`; + const statusPageFilter: FilterItemObj = { + fieldName: ALERT_WORKFLOW_STATUS, + title: 'Status', + selectedOptions: [], + existsSelected: false, + }; + + const pageFiltersQuery = encode(formatPageFilterSearchParam([statusPageFilter])); + + const url = `${ALERTS_PATH}?${URL_PARAM_KEY.appQuery}=${kqlAppQuery}&${URL_PARAM_KEY.timerange}=${timerange}&${URL_PARAM_KEY.pageFilter}=${pageFiltersQuery}&${URL_PARAM_KEY.eventFlyout}=${flyoutString}`; return ; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx index 19aa3fe2874ec..c92c49cda23d6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -5,14 +5,14 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import type { EuiFlyoutProps } from '@elastic/eui'; import { EuiFlyout } from '@elastic/eui'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { EntityType } from '@kbn/timelines-plugin/common'; -import { dataTableSelectors } from '@kbn/securitysolution-data-table'; +import { dataTableActions, dataTableSelectors } from '@kbn/securitysolution-data-table'; import { getScopedActions, isInTableScope, isTimelineScope } from '../../../helpers'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; @@ -66,6 +66,21 @@ export const DetailsPanel = React.memo( (state) => ((getScope && getScope(state, scopeId)) ?? timelineDefaults)?.expandedDetail ); + useEffect(() => { + /** + * Removes the flyout from redux when it is unmounted as it's also stored in localStorage + * This only works when navigating within the app, if navigating via the url bar, + * the localStorage state will be maintained + * */ + return () => { + dispatch( + dataTableActions.toggleDetailPanel({ + id: scopeId, + }) + ); + }; + }, [dispatch, scopeId]); + // To be used primarily in the flyout scenario where we don't want to maintain the tabType const defaultOnPanelClose = useCallback(() => { const scopedActions = getScopedActions(scopeId);