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 e218207e84db..947f92d3ec4a 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 @@ -8,82 +8,137 @@ import { ALERT_FLYOUT, CELL_TEXT, + COPY_ALERT_FLYOUT_LINK, JSON_TEXT, + OVERVIEW_RULE, TABLE_CONTAINER, TABLE_ROWS, } from '../../screens/alerts_details'; - -import { expandFirstAlert } from '../../tasks/alerts'; -import { openJsonView, openTable } from '../../tasks/alerts_details'; +import { closeAlertFlyout, expandFirstAlert } from '../../tasks/alerts'; +import { filterBy, openJsonView, openTable } from '../../tasks/alerts_details'; import { createRule } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../tasks/login'; - -import { getUnmappedRule } from '../../objects/rule'; - +import { login, visit, visitWithoutDateRange } from '../../tasks/login'; +import { getUnmappedRule, getNewRule } from '../../objects/rule'; import { ALERTS_URL } from '../../urls/navigation'; import { tablePageSelector } from '../../screens/table_pagination'; +import { ALERTS_COUNT } from '../../screens/alerts'; -describe('Alert details with unmapped fields', { testIsolation: false }, () => { - before(() => { - cleanKibana(); - esArchiverLoad('unmapped_fields'); - login(); - createRule(getUnmappedRule()); - visitWithoutDateRange(ALERTS_URL); - waitForAlertsToPopulate(); - expandFirstAlert(); - }); +describe('Alert details flyout', () => { + describe('With unmapped fields', { testIsolation: false }, () => { + before(() => { + cleanKibana(); + esArchiverLoad('unmapped_fields'); + login(); + createRule(getUnmappedRule()); + visitWithoutDateRange(ALERTS_URL); + waitForAlertsToPopulate(); + expandFirstAlert(); + }); - after(() => { - esArchiverUnload('unmapped_fields'); - }); + after(() => { + esArchiverUnload('unmapped_fields'); + }); - it('should display the unmapped field on the JSON view', () => { - const expectedUnmappedValue = 'This is the unmapped field'; + it('should display the unmapped field on the JSON view', () => { + const expectedUnmappedValue = 'This is the unmapped field'; - openJsonView(); + openJsonView(); - cy.get(JSON_TEXT).then((x) => { - const parsed = JSON.parse(x.text()); - expect(parsed.fields.unmapped[0]).to.equal(expectedUnmappedValue); + cy.get(JSON_TEXT).then((x) => { + const parsed = JSON.parse(x.text()); + expect(parsed.fields.unmapped[0]).to.equal(expectedUnmappedValue); + }); + }); + + it('should displays the unmapped field on the table', () => { + const expectedUnmappedField = { + field: 'unmapped', + text: 'This is the unmapped field', + }; + + openTable(); + cy.get(ALERT_FLYOUT).find(tablePageSelector(6)).click({ force: true }); + cy.get(ALERT_FLYOUT) + .find(TABLE_ROWS) + .last() + .within(() => { + cy.get(CELL_TEXT).should('contain', expectedUnmappedField.field); + cy.get(CELL_TEXT).should('contain', expectedUnmappedField.text); + }); + }); + + // This test makes sure that the table does not overflow horizontally + it('table should not scroll horizontally', () => { + openTable(); + + cy.get(ALERT_FLYOUT) + .find(TABLE_CONTAINER) + .within(($tableContainer) => { + expect($tableContainer[0].scrollLeft).to.equal(0); + + // Due to the introduction of pagination on the table, a slight horizontal overflow has been introduced. + // scroll ignores the `overflow-x:hidden` attribute and will still scroll the element if there is a hidden overflow + // Updated the below to < 5 to account for this and keep a test to make sure it doesn't grow + $tableContainer[0].scroll({ left: 1000 }); + + expect($tableContainer[0].scrollLeft).to.be.lessThan(5); + }); }); }); - it('should displays the unmapped field on the table', () => { - const expectedUnmappedField = { - field: 'unmapped', - text: 'This is the unmapped field', - }; + describe('Url state management', { testIsolation: false }, () => { + const testRule = getNewRule(); + before(() => { + cleanKibana(); + login(); + createRule(testRule); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + }); - openTable(); - cy.get(ALERT_FLYOUT).find(tablePageSelector(6)).click({ force: true }); - cy.get(ALERT_FLYOUT) - .find(TABLE_ROWS) - .last() - .within(() => { - cy.get(CELL_TEXT).should('contain', expectedUnmappedField.field); - cy.get(CELL_TEXT).should('contain', expectedUnmappedField.text); + it('should store the flyout state in the url when it is opened', () => { + expandFirstAlert(); + cy.get(OVERVIEW_RULE).should('be.visible'); + cy.url().should('include', 'eventFlyout='); + }); + + it('should remove the flyout state from the url when it is closed', () => { + expandFirstAlert(); + cy.get(OVERVIEW_RULE).should('be.visible'); + closeAlertFlyout(); + cy.url().should('not.include', 'eventFlyout='); + }); + + it('should open the alert flyout when the page is refreshed', () => { + expandFirstAlert(); + cy.get(OVERVIEW_RULE).should('be.visible'); + cy.reload(); + cy.get(OVERVIEW_RULE).should('be.visible'); + cy.get(OVERVIEW_RULE).then((field) => { + expect(field).to.contain(testRule.name); }); - }); + }); - // This test makes sure that the table does not overflow horizontally - it('table should not scroll horizontally', () => { - openTable(); + it('should show the copy link button for the flyout', () => { + expandFirstAlert(); + cy.get(COPY_ALERT_FLYOUT_LINK).should('be.visible'); + }); - cy.get(ALERT_FLYOUT) - .find(TABLE_CONTAINER) - .within(($tableContainer) => { - expect($tableContainer[0].scrollLeft).to.equal(0); - - // Due to the introduction of pagination on the table, a slight horizontal overflow has been introduced. - // scroll ignores the `overflow-x:hidden` attribute and will still scroll the element if there is a hidden overflow - // Updated the below to < 5 to account for this and keep a test to make sure it doesn't grow - $tableContainer[0].scroll({ left: 1000 }); - - expect($tableContainer[0].scrollLeft).to.be.lessThan(5); - }); + it('should open the flyout given the custom url', () => { + expandFirstAlert(); + openTable(); + filterBy('_id'); + cy.get('[data-test-subj="formatted-field-_id"]') + .invoke('text') + .then((alertId) => { + cy.visit(`http://localhost:5620/app/security/alerts/${alertId}`); + cy.get('[data-test-subj="unifiedQueryInput"]').should('have.text', `_id: ${alertId}`); + cy.get(ALERTS_COUNT).should('have.text', '1 alert'); + cy.get(OVERVIEW_RULE).should('be.visible'); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/navigation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/navigation.cy.ts index 60cf6c4f1195..79267cb65a19 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/navigation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/navigation.cy.ts @@ -21,7 +21,9 @@ import { import { PAGE_TITLE } from '../../screens/common/page'; import { OPEN_ALERT_DETAILS_PAGE } from '../../screens/alerts_details'; -describe('Alert Details Page Navigation', () => { +// This is skipped as the details page POC will be removed in favor of the expanded alert flyout +// https://github.com/elastic/kibana/issues/154477 +describe.skip('Alert Details Page Navigation', () => { describe('navigating to alert details page', () => { const rule = getNewRule(); before(() => { diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index 23b15524305e..74df1f199937 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -83,3 +83,5 @@ export const INSIGHTS_INVESTIGATE_ANCESTRY_ALERTS_IN_TIMELINE_BUTTON = `[data-te export const ENRICHED_DATA_ROW = `[data-test-subj='EnrichedDataRow']`; export const OPEN_ALERT_DETAILS_PAGE = `[data-test-subj="open-alert-details-page"]`; + +export const COPY_ALERT_FLYOUT_LINK = `[data-test-subj="copy-alert-flyout-link"]`; diff --git a/x-pack/plugins/security_solution/public/common/hooks/flyout/types.ts b/x-pack/plugins/security_solution/public/common/hooks/flyout/types.ts new file mode 100644 index 000000000000..09fd6a25cc53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/flyout/types.ts @@ -0,0 +1,10 @@ +/* + * 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 { ExpandedDetailType } from '../../../../common/types'; + +export type FlyoutUrlState = ExpandedDetailType; diff --git a/x-pack/plugins/security_solution/public/common/hooks/flyout/use_init_flyout_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/flyout/use_init_flyout_url_param.ts new file mode 100644 index 000000000000..c07ff0569fb0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/flyout/use_init_flyout_url_param.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { useDispatch } from 'react-redux'; + +import { TableId } from '../../../../common/types'; +import { useInitializeUrlParam } from '../../utils/global_query_string'; +import { URL_PARAM_KEY } from '../use_url_state'; +import type { FlyoutUrlState } from './types'; +import { dataTableActions, dataTableSelectors } from '../../store/data_table'; +import { useShallowEqualSelector } from '../use_selector'; +import { tableDefaults } from '../../store/data_table/defaults'; + +export const useInitFlyoutFromUrlParam = () => { + const [urlDetails, setUrlDetails] = useState(null); + const [hasLoadedUrlDetails, updateHasLoadedUrlDetails] = useState(false); + const dispatch = useDispatch(); + const getDataTable = dataTableSelectors.getTableByIdSelector(); + + // Only allow the alerts page for now to be saved in the url state. + // Allowing only one makes the transition to the expanded flyout much easier as well + const dataTableCurrent = useShallowEqualSelector( + (state) => getDataTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults + ); + + const onInitialize = useCallback((initialState: FlyoutUrlState | null) => { + if (initialState != null && initialState.panelView) { + setUrlDetails(initialState); + } + }, []); + + const loadExpandedDetailFromUrl = useCallback(() => { + const { initialized, isLoading, totalCount } = dataTableCurrent; + const isTableLoaded = initialized && !isLoading && totalCount > 0; + if (urlDetails && isTableLoaded) { + updateHasLoadedUrlDetails(true); + dispatch( + dataTableActions.toggleDetailPanel({ + id: TableId.alertsOnAlertsPage, + ...urlDetails, + }) + ); + } + }, [dataTableCurrent, dispatch, urlDetails]); + + // The alert page creates a default dataTable slice in redux initially that is later overriden when data is retrieved + // We use the below to store the urlDetails on app load, and then set it when the table is done loading and has data + useEffect(() => { + if (!hasLoadedUrlDetails) { + loadExpandedDetailFromUrl(); + } + }, [hasLoadedUrlDetails, loadExpandedDetailFromUrl]); + + useInitializeUrlParam(URL_PARAM_KEY.eventFlyout, onInitialize); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/flyout/use_sync_flyout_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/flyout/use_sync_flyout_url_param.ts new file mode 100644 index 000000000000..95218a7f91b5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/flyout/use_sync_flyout_url_param.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { ALERTS_PATH } from '../../../../common/constants'; +import { useUpdateUrlParam } from '../../utils/global_query_string'; +import { TableId } from '../../../../common/types'; +import { useShallowEqualSelector } from '../use_selector'; +import { URL_PARAM_KEY } from '../use_url_state'; +import { dataTableActions, dataTableSelectors } from '../../store/data_table'; +import { tableDefaults } from '../../store/data_table/defaults'; +import type { FlyoutUrlState } from './types'; + +export const useSyncFlyoutUrlParam = () => { + const updateUrlParam = useUpdateUrlParam(URL_PARAM_KEY.eventFlyout); + const { pathname } = useLocation(); + const dispatch = useDispatch(); + const getDataTable = dataTableSelectors.getTableByIdSelector(); + + // Only allow the alerts page for now to be saved in the url state + const { expandedDetail } = useShallowEqualSelector( + (state) => getDataTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults + ); + + useEffect(() => { + const isOnAlertsPage = pathname === ALERTS_PATH; + if (isOnAlertsPage && expandedDetail != null && expandedDetail?.query) { + updateUrlParam(expandedDetail.query.panelView ? expandedDetail.query : null); + } else if (!isOnAlertsPage && expandedDetail?.query?.panelView) { + // Close the detail panel as it's stored in a top level redux store maintained across views + dispatch( + dataTableActions.toggleDetailPanel({ + id: TableId.alertsOnAlertsPage, + }) + ); + // Clear the reference from the url + updateUrlParam(null); + } + }, [dispatch, expandedDetail, pathname, updateUrlParam]); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts index ff491d55c314..a5230c9ac599 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts @@ -12,6 +12,8 @@ import { useUpdateTimerangeOnPageChange } from './search_bar/use_update_timerang import { useInitTimelineFromUrlParam } from './timeline/use_init_timeline_url_param'; import { useSyncTimelineUrlParam } from './timeline/use_sync_timeline_url_param'; import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_by_id_on_url_change'; +import { useInitFlyoutFromUrlParam } from './flyout/use_init_flyout_url_param'; +import { useSyncFlyoutUrlParam } from './flyout/use_sync_flyout_url_param'; export const useUrlState = () => { useSyncGlobalQueryString(); @@ -21,10 +23,13 @@ export const useUrlState = () => { useInitTimelineFromUrlParam(); useSyncTimelineUrlParam(); useQueryTimelineByIdOnUrlChange(); + useInitFlyoutFromUrlParam(); + useSyncFlyoutUrlParam(); }; export enum URL_PARAM_KEY { appQuery = 'query', + eventFlyout = 'eventFlyout', filters = 'filters', savedQuery = 'savedQuery', sourcerer = 'sourcerer', 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 new file mode 100644 index 000000000000..c44fcfdd7e50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Redirect, useLocation, useParams } from 'react-router-dom'; + +import moment from 'moment'; +import { encode } from '@kbn/rison'; +import { ALERTS_PATH, DEFAULT_ALERTS_INDEX } from '../../../../common/constants'; +import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state'; +import { inputsSelectors } from '../../../common/store'; + +export const AlertDetailsRedirect = () => { + const { alertId } = useParams<{ alertId: string }>(); + const { search } = useLocation(); + const searchParams = new URLSearchParams(search); + const timestamp = searchParams.get('timestamp'); + // Although we use the 'default' space here when an index isn't provided or accidentally deleted + // It's a safe catch all as we reset the '.internal.alerts-*` indices with the correct space in the flyout + // Here: x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/helpers.tsx + const index = searchParams.get('index') ?? `.internal${DEFAULT_ALERTS_INDEX}-default`; + + const getInputSelector = useMemo(() => inputsSelectors.inputsSelector(), []); + const inputState = useSelector(getInputSelector); + const { linkTo: globalLinkTo, timerange: globalTimerange } = inputState.global; + const { linkTo: timelineLinkTo, timerange: timelineTimerange } = inputState.timeline; + + // Default to the existing global timerange if we don't get this query param for whatever reason + const fromTime = timestamp ?? globalTimerange.from; + // Add 1 millisecond to the alert timestamp as the alert table is non-inclusive of the end time + // So we have to extend slightly beyond the range of the timestamp of the given alert + const toTime = moment(timestamp ?? globalTimerange.to).add('1', 'millisecond'); + + const timerange = encode({ + global: { + [URL_PARAM_KEY.timerange]: { + kind: 'absolute', + from: fromTime, + to: toTime, + }, + linkTo: globalLinkTo, + }, + timeline: { + [URL_PARAM_KEY.timerange]: timelineTimerange, + linkTo: timelineLinkTo, + }, + }); + + const flyoutString = encode({ + panelView: 'eventDetail', + params: { + eventId: alertId, + indexName: index, + }, + }); + + 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}`; + + return ; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index 1cb4ee66e4f3..2d3a55c6b0cd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -6,20 +6,17 @@ */ import React from 'react'; -import { Redirect, Switch } from 'react-router-dom'; +import { Switch } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { DetectionEnginePage } from '../detection_engine/detection_engine'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { useReadonlyHeader } from '../../../use_readonly_header'; -import { AlertDetailsPage } from '../alert_details'; -import { AlertDetailRouteType } from '../alert_details/types'; -import { getAlertDetailsTabUrl } from '../alert_details/utils/navigation'; +import { AlertDetailsRedirect } from './alert_details_redirect'; const AlertsRoute = () => ( @@ -28,40 +25,13 @@ const AlertsRoute = () => ( ); -const AlertDetailsRoute = () => ( - - - -); - const AlertsContainerComponent: React.FC = () => { useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP); - const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); return ( - {isAlertDetailsPageEnabled && ( - <> - {/* Redirect to the summary page if only the detail name is provided */} - ( - - )} - /> - - - )} + {/* Redirect to the alerts page filtered for the given alert id */} + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index f210710f7508..6a371c46a640 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -8,12 +8,14 @@ import { isEmpty } from 'lodash/fp'; import { EuiButtonIcon, + EuiButtonEmpty, EuiTextColor, EuiLoadingContent, EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiCopy, } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; @@ -32,6 +34,7 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str import * as i18n from './translations'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { SecurityPageName } from '../../../../../common/constants'; +import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link'; export type HandleOnEventClosed = () => void; interface Props { @@ -52,10 +55,11 @@ interface Props { interface ExpandableEventTitleProps { eventId: string; + eventIndex: string; isAlert: boolean; loading: boolean; ruleName?: string; - timestamp?: string; + timestamp: string; handleOnEventClosed?: HandleOnEventClosed; } @@ -76,12 +80,19 @@ const StyledEuiFlexItem = styled(EuiFlexItem)` `; export const ExpandableEventTitle = React.memo( - ({ eventId, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => { + ({ eventId, eventIndex, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => { const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); const { onClick } = useGetSecuritySolutionLinkProps()({ deepLinkId: SecurityPageName.alerts, path: eventId && isAlert ? getAlertDetailsUrl(eventId) : '', }); + + const alertDetailsLink = useGetAlertDetailsFlyoutLink({ + _id: eventId, + _index: eventIndex, + timestamp, + }); + return ( @@ -117,6 +128,19 @@ export const ExpandableEventTitle = React.memo( )} + {isAlert && ( + + {(copy) => ( + + {i18n.SHARE_ALERT} + + )} + + )} ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx index bc9af3fa8546..eac84c185369 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -13,6 +13,7 @@ import { BackToAlertDetailsLink } from './back_to_alert_details_link'; interface FlyoutHeaderComponentProps { eventId: string; + eventIndex: string; isAlert: boolean; isHostIsolationPanelOpen: boolean; isolateAction: 'isolateHost' | 'unisolateHost'; @@ -24,6 +25,7 @@ interface FlyoutHeaderComponentProps { const FlyoutHeaderContentComponent = ({ eventId, + eventIndex, isAlert, isHostIsolationPanelOpen, isolateAction, @@ -39,6 +41,7 @@ const FlyoutHeaderContentComponent = ({ ) : ( { { ); }, [ + alert.indexName, isAlert, alertId, isHostIsolationPanelOpen, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx index eee8d15ff19a..7c70328c8e5a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx @@ -37,6 +37,15 @@ const ecsData: Ecs = { }, }; +const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' }); +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useLocation: () => mockUseLocation(), + }; +}); + jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => { return { isIsolationSupported: jest.fn().mockReturnValue(true), diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 29a1940413da..059f4322d897 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -81,6 +81,7 @@ const EventDetailsPanelComponent: React.FC = ({ isFlyoutView || isHostIsolationPanelOpen ? ( = ({ ) : ( ), [ expandedEvent.eventId, + eventIndex, handleOnEventClosed, isAlert, isFlyoutView, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts index 39292052cf8d..a40a9095ea11 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts @@ -48,3 +48,10 @@ export const ALERT_DETAILS = i18n.translate( defaultMessage: 'Alert details', } ); + +export const SHARE_ALERT = i18n.translate( + 'xpack.securitySolution.timeline.expandableEvent.shareAlert', + { + defaultMessage: 'Share alert', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts new file mode 100644 index 000000000000..1d2d1b5ea621 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import { useAppUrl } from '../../../../common/lib/kibana/hooks'; +import { ALERTS_PATH } from '../../../../../common/constants'; + +export const useGetAlertDetailsFlyoutLink = ({ + _id, + _index, + timestamp, +}: { + _id: string; + _index: string; + timestamp: string; +}) => { + const { getAppUrl } = useAppUrl(); + // getAppUrl accounts for the users selected space + const alertDetailsLink = useMemo(() => { + const url = getAppUrl({ + path: `${ALERTS_PATH}/${_id}?index=${_index}×tamp=${timestamp}`, + }); + return `${window.location.origin}${url}`; + }, [_id, _index, getAppUrl, timestamp]); + + return alertDetailsLink; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx index 685362d8f1de..fd2da0c570f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -29,6 +29,15 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({ useSearchStrategy: jest.fn(), })); +const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' }); +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useLocation: () => mockUseLocation(), + }; +}); + describe('Details Panel Component', () => { const state: State = { ...mockGlobalState,