Alert redirects w/ new flyout and share button (#157956)

## Summary

This PR ensures that alert redirection mechanism works the same way it
does for the legacy flyout (eg. that redirects done with
`kibana.alert.url` work),
and it introduces the share button, whenever `kibana.alert.url` is
present:


![image](dbb15607-f4b7-4dd2-abf7-9a76324576ac)

### Testing
The usual approach with switching the flag applies, it should work with
any alert created with detection rules.

### Checklist

Delete any items that are not applicable to this PR.

- [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] [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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
Luke 2023-05-29 11:15:17 +02:00 committed by GitHub
parent 6abee5af88
commit 96e4a83dcb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 328 additions and 36 deletions

View file

@ -16,12 +16,11 @@ import {
} from '../../../common/mock';
import { createStore } from '../../../common/store';
import { kibanaObservable } from '@kbn/timelines-plugin/public/mock';
import {
ALERTS_PATH,
ALERT_DETAILS_REDIRECT_PATH,
DEFAULT_ALERTS_INDEX,
} from '../../../../common/constants';
import { ALERTS_PATH, ALERT_DETAILS_REDIRECT_PATH } from '../../../../common/constants';
import { mockHistory } from '../../../common/utils/route/mocks';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
jest.mock('../../../common/hooks/use_experimental_features');
jest.mock('../../../common/lib/kibana');
@ -67,7 +66,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)))&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:${testIndex}))`,
search: `?query=%28language%3Akuery%2Cquery%3A%27_id%3A+test-alert-id%27%29&timerange=%28global%3A%28linkTo%3A%21%28timeline%2CsocTrends%29%2Ctimerange%3A%28from%3A%272023-04-20T12%3A00%3A00.000Z%27%2Ckind%3Aabsolute%2Cto%3A%272023-04-20T12%3A05%3A00.000Z%27%29%29%2Ctimeline%3A%28linkTo%3A%21%28global%2CsocTrends%29%2Ctimerange%3A%28from%3A%272020-07-07T08%3A20%3A18.966Z%27%2CfromStr%3Anow%2Fd%2Ckind%3Arelative%2Cto%3A%272020-07-08T08%3A20%3A18.966Z%27%2CtoStr%3Anow%2Fd%29%29%29&pageFilters=%21%28%28exclude%3A%21f%2CexistsSelected%3A%21f%2CfieldName%3Akibana.alert.workflow_status%2CselectedOptions%3A%21%28%29%2Ctitle%3AStatus%29%29&eventFlyout=%28panelView%3AeventDetail%2Cparams%3A%28eventId%3Atest-alert-id%2CindexName%3A.someTestIndex%29%29`,
state: undefined,
});
});
@ -96,7 +95,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)))&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))&eventFlyout=(panelView:eventDetail,params:(eventId:${testAlertId},indexName:${testIndex}))`,
search: `?query=%28language%3Akuery%2Cquery%3A%27_id%3A+test-alert-id%27%29&timerange=%28global%3A%28linkTo%3A%21%28timeline%2CsocTrends%29%2Ctimerange%3A%28from%3A%272020-07-07T08%3A20%3A18.966Z%27%2Ckind%3Aabsolute%2Cto%3A%272020-07-08T08%3A25%3A18.966Z%27%29%29%2Ctimeline%3A%28linkTo%3A%21%28global%2CsocTrends%29%2Ctimerange%3A%28from%3A%272020-07-07T08%3A20%3A18.966Z%27%2CfromStr%3Anow%2Fd%2Ckind%3Arelative%2Cto%3A%272020-07-08T08%3A20%3A18.966Z%27%2CtoStr%3Anow%2Fd%29%29%29&pageFilters=%21%28%28exclude%3A%21f%2CexistsSelected%3A%21f%2CfieldName%3Akibana.alert.workflow_status%2CselectedOptions%3A%21%28%29%2Ctitle%3AStatus%29%29&eventFlyout=%28panelView%3AeventDetail%2Cparams%3A%28eventId%3Atest-alert-id%2CindexName%3A.someTestIndex%29%29`,
state: undefined,
});
});
@ -124,9 +123,42 @@ 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)))&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))`,
search: `?query=%28language%3Akuery%2Cquery%3A%27_id%3A+test-alert-id%27%29&timerange=%28global%3A%28linkTo%3A%21%28timeline%2CsocTrends%29%2Ctimerange%3A%28from%3A%272020-07-07T08%3A20%3A18.966Z%27%2Ckind%3Aabsolute%2Cto%3A%272020-07-08T08%3A25%3A18.966Z%27%29%29%2Ctimeline%3A%28linkTo%3A%21%28global%2CsocTrends%29%2Ctimerange%3A%28from%3A%272020-07-07T08%3A20%3A18.966Z%27%2CfromStr%3Anow%2Fd%2Ckind%3Arelative%2Cto%3A%272020-07-08T08%3A20%3A18.966Z%27%2CtoStr%3Anow%2Fd%29%29%29&pageFilters=%21%28%28exclude%3A%21f%2CexistsSelected%3A%21f%2CfieldName%3Akibana.alert.workflow_status%2CselectedOptions%3A%21%28%29%2Ctitle%3AStatus%29%29&eventFlyout=%28panelView%3AeventDetail%2Cparams%3A%28eventId%3Atest-alert-id%2CindexName%3A.internal.alerts-security.alerts-default%29%29`,
state: undefined,
});
});
});
describe('When expandable flyout is enabled', () => {
beforeEach(() => {
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true);
});
describe('when eventFlyout is not in the query', () => {
it('redirects to the expected path with the correct query parameters', () => {
const testSearch = `?index=${testIndex}&timestamp=${testTimestamp}`;
const historyMock = {
...mockHistory,
location: {
hash: '',
pathname: mockPathname,
search: testSearch,
state: '',
},
};
render(
<TestProviders store={store}>
<Router history={historyMock}>
<AlertDetailsRedirect />
</Router>
</TestProviders>
);
const [{ search, pathname }] = historyMock.replace.mock.lastCall;
expect(search as string).toMatch(/eventFlyout.*right/);
expect(pathname).toEqual(ALERTS_PATH);
});
});
});
});

View file

@ -17,6 +17,9 @@ 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';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { resolveFlyoutParams } from './utils';
import { FLYOUT_URL_PARAM } from '../../../flyout/url/use_sync_flyout_state_with_url';
export const AlertDetailsRedirect = () => {
const { alertId } = useParams<{ alertId: string }>();
@ -54,14 +57,6 @@ export const AlertDetailsRedirect = () => {
},
});
const flyoutString = encode({
panelView: 'eventDetail',
params: {
eventId: alertId,
indexName: index,
},
});
const kqlAppQuery = encode({ language: 'kuery', query: `_id: ${alertId}` });
const statusPageFilter: FilterItemObj = {
@ -73,7 +68,21 @@ export const AlertDetailsRedirect = () => {
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}`;
const currentFlyoutParams = searchParams.get(FLYOUT_URL_PARAM);
const isSecurityFlyoutEnabled = useIsExperimentalFeatureEnabled('securityFlyoutEnabled');
const urlParams = new URLSearchParams({
[URL_PARAM_KEY.appQuery]: kqlAppQuery,
[URL_PARAM_KEY.timerange]: timerange,
[URL_PARAM_KEY.pageFilter]: pageFiltersQuery,
[URL_PARAM_KEY.eventFlyout]: resolveFlyoutParams(
{ index, alertId, isSecurityFlyoutEnabled },
currentFlyoutParams
),
});
const url = `${ALERTS_PATH}?${urlParams.toString()}`;
return <Redirect to={url} />;
};

View file

@ -0,0 +1,47 @@
/*
* 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 { encode } from '@kbn/rison';
import { expandableFlyoutStateFromEventMeta } from '../../../flyout/url/expandable_flyout_state_from_event_meta';
export interface ResolveFlyoutParamsConfig {
index: string;
alertId: string;
isSecurityFlyoutEnabled: boolean;
}
/**
* Resolves url parameters for the flyout, serialized as
* rison string. NOTE: if user is already redirected to this route with flyout parameters set,
* we simply use them. It will be the case when users are coming here using a link obtained
* with Share Button on the Expandable Flyout
*/
export const resolveFlyoutParams = (
{ index, alertId, isSecurityFlyoutEnabled }: ResolveFlyoutParamsConfig,
currentParamsString: string | null
) => {
if (!isSecurityFlyoutEnabled) {
const legacyFlyoutString = encode({
panelView: 'eventDetail',
params: {
eventId: alertId,
indexName: index,
},
});
return legacyFlyoutString;
}
if (currentParamsString) {
return currentParamsString;
}
const modernFlyoutString = encode(
expandableFlyoutStateFromEventMeta({ index, eventId: alertId, scopeId: 'alerts-page' })
);
return modernFlyoutString;
};

View file

@ -11,6 +11,7 @@ import { RightPanelContext } from '../context';
import {
FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID,
FLYOUT_HEADER_SEVERITY_TITLE_TEST_ID,
FLYOUT_HEADER_SHARE_BUTTON_TEST_ID,
FLYOUT_HEADER_TITLE_TEST_ID,
} from './test_ids';
import { HeaderTitle } from './header_title';
@ -80,6 +81,36 @@ describe('<HeaderTitle />', () => {
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('test');
});
it('should render share button in the title if document is an alert', () => {
const contextValue = {
dataFormattedForFieldBrowser: [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
values: ['123'],
originalValue: ['123'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.url',
values: ['http://kibana.url/alert/id'],
originalValue: ['http://kibana.url/alert/id'],
isObjectArray: false,
},
],
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { getByTestId } = render(
<RightPanelContext.Provider value={contextValue}>
<HeaderTitle />
</RightPanelContext.Provider>
);
expect(getByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render default document detail title if document is not an alert', () => {
const contextValue = {
dataFormattedForFieldBrowser: [

View file

@ -16,20 +16,28 @@ import { useBasicDataFromDetailsData } from '../../../timelines/components/side_
import { useRightPanelContext } from '../context';
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
import { ShareButton } from './share_button';
/**
* Document details flyout right section header
*/
export const HeaderTitle: FC = memo(() => {
const { dataFormattedForFieldBrowser } = useRightPanelContext();
const { isAlert, ruleName, timestamp } = useBasicDataFromDetailsData(
const { isAlert, ruleName, timestamp, alertUrl } = useBasicDataFromDetailsData(
dataFormattedForFieldBrowser
);
return (
<>
<EuiTitle size="s" data-test-subj={FLYOUT_HEADER_TITLE_TEST_ID}>
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : DOCUMENT_DETAILS}</h4>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : DOCUMENT_DETAILS}</h4>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isAlert && alertUrl && <ShareButton alertUrl={alertUrl} />}
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
<EuiSpacer size="m" />
{timestamp && <PreferenceFormattedDate value={new Date(timestamp)} />}

View file

@ -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 { render, screen, fireEvent } from '@testing-library/react';
import { copyToClipboard } from '@elastic/eui';
import { ShareButton } from './share_button';
import React from 'react';
import { FLYOUT_URL_PARAM } from '../../url/use_sync_flyout_state_with_url';
import { FLYOUT_HEADER_SHARE_BUTTON_TEST_ID } from './test_ids';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
copyToClipboard: jest.fn(),
EuiCopy: jest.fn(({ children: functionAsChild }) => functionAsChild(jest.fn())),
}));
describe('ShareButton', () => {
const alertUrl = 'https://example.com/alert';
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the share button', () => {
render(<ShareButton alertUrl={alertUrl} />);
expect(screen.getByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('copies the alert URL to clipboard', () => {
const syncedFlyoutState = 'flyoutState';
const query = `?${FLYOUT_URL_PARAM}=${syncedFlyoutState}`;
Object.defineProperty(window, 'location', {
value: {
search: query,
},
});
render(<ShareButton alertUrl={alertUrl} />);
fireEvent.click(screen.getByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID));
expect(copyToClipboard).toHaveBeenCalledWith(
`${alertUrl}&${FLYOUT_URL_PARAM}=${syncedFlyoutState}`
);
});
});

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { copyToClipboard, EuiButtonEmpty, EuiCopy } from '@elastic/eui';
import type { FC } from 'react';
import React from 'react';
import { FLYOUT_URL_PARAM } from '../../url/use_sync_flyout_state_with_url';
import { FLYOUT_HEADER_SHARE_BUTTON_TEST_ID } from './test_ids';
import { SHARE } from './translations';
interface ShareButtonProps {
/**
* Url retrieved from the kibana.alert.url field of the document
*/
alertUrl: string;
}
/**
* Puts alertUrl to user's clipboard. If current query string contains synced flyout state,
* it will be appended to the base alertUrl
*/
export const ShareButton: FC<ShareButtonProps> = ({ alertUrl }) => {
return (
<EuiCopy textToCopy={alertUrl}>
{(copy) => (
<EuiButtonEmpty
onClick={() => {
// NOTE: currently, it is not possible to have textToCopy computed dynamically.
// so, we are calling copy() here to trigger the ui tooltip, and then override the link manually
copy();
const query = new URLSearchParams(window.location.search);
const alertDetailsLink = `${alertUrl}&${FLYOUT_URL_PARAM}=${query.get(
FLYOUT_URL_PARAM
)}`;
copyToClipboard(alertDetailsLink);
}}
iconType="share"
data-test-subj={FLYOUT_HEADER_SHARE_BUTTON_TEST_ID}
>
{SHARE}
</EuiButtonEmpty>
)}
</EuiCopy>
);
};
ShareButton.displayName = 'ShareButton';

View file

@ -21,6 +21,8 @@ export const FLYOUT_HEADER_RISK_SCORE_TITLE_TEST_ID =
'securitySolutionAlertDetailsFlyoutHeaderRiskScoreTitle';
export const FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID =
'securitySolutionAlertDetailsFlyoutHeaderRiskScoreValue';
export const FLYOUT_HEADER_SHARE_BUTTON_TEST_ID =
'securitySolutionAlertDetailsFlyoutHeaderShareButton';
/* Description section */

View file

@ -279,6 +279,10 @@ export const ANALYZER_PREVIEW_TEXT = i18n.translate(
}
);
export const SHARE = i18n.translate('xpack.securitySolution.flyout.documentDetails.share', {
defaultMessage: 'Share Alert',
});
export const INVESTIGATION_GUIDE_TITLE = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.investigationGuideText',
{

View file

@ -0,0 +1,39 @@
/*
* 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 { ExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { RightPanelKey } from '../right';
interface RedirectParams {
index: string;
eventId: string;
scopeId: string;
}
/**
* Builds flyout state from basic event-related data, such as index name, event id and scope id.
* This value can be used to open the flyout either by passing it directly to the flyout api (exposed via ref) or
* by serializing it to the url & performing a redirect
*/
export const expandableFlyoutStateFromEventMeta = ({
index,
eventId,
scopeId,
}: RedirectParams): ExpandableFlyoutContext['panels'] => {
return {
right: {
id: RightPanelKey,
params: {
id: eventId,
indexName: index,
scopeId,
},
},
left: undefined,
preview: [],
};
};

View file

@ -9,8 +9,9 @@ import { useCallback, useRef } from 'react';
import type { ExpandableFlyoutApi, ExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useSyncToUrl } from '@kbn/url-state';
import last from 'lodash/last';
import { URL_PARAM_KEY } from '../../common/hooks/use_url_state';
const URL_KEY = 'eventFlyout' as const;
export const FLYOUT_URL_PARAM = URL_PARAM_KEY.eventFlyout;
type FlyoutState = Parameters<ExpandableFlyoutApi['openFlyout']>[0];
@ -21,7 +22,7 @@ type FlyoutState = Parameters<ExpandableFlyoutApi['openFlyout']>[0];
export const useSyncFlyoutStateWithUrl = () => {
const flyoutApi = useRef<ExpandableFlyoutApi>(null);
const syncStateToUrl = useSyncToUrl<FlyoutState>(URL_KEY, (data) => {
const syncStateToUrl = useSyncToUrl<FlyoutState>(FLYOUT_URL_PARAM, (data) => {
flyoutApi.current?.openFlyout(data);
});

View file

@ -12,16 +12,18 @@ import { getFieldValue } from '../../../../detections/components/host_isolation/
import { DEFAULT_ALERTS_INDEX, DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants';
export interface GetBasicDataFromDetailsData {
alertId: string;
agentId?: string;
isAlert: boolean;
hostName: string;
userName: string;
ruleName: string;
timestamp: string;
alertId: string;
alertUrl?: string;
data: TimelineEventsDetailsItem[] | null;
hostName: string;
indexName?: string;
isAlert: boolean;
ruleDescription: string;
ruleId: string;
ruleName: string;
timestamp: string;
userName: string;
}
export const useBasicDataFromDetailsData = (
@ -49,6 +51,16 @@ export const useBasicDataFromDetailsData = (
const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, data), [data]);
const indexName = useMemo(
() => getFieldValue({ category: '_index', field: '_index' }, data),
[data]
);
const alertUrl = useMemo(
() => getFieldValue({ category: 'kibana', field: 'kibana.alert.url' }, data),
[data]
);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, data),
[data]
@ -71,28 +83,32 @@ export const useBasicDataFromDetailsData = (
return useMemo(
() => ({
alertId,
agentId,
isAlert,
hostName,
userName,
ruleName,
timestamp,
alertId,
alertUrl,
data,
hostName,
indexName,
isAlert,
ruleDescription,
ruleId,
ruleName,
timestamp,
userName,
}),
[
agentId,
alertId,
alertUrl,
data,
hostName,
indexName,
isAlert,
ruleDescription,
ruleId,
ruleName,
timestamp,
userName,
data,
ruleDescription,
ruleId,
]
);
};