mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
[Security Solution][Timeline] - Store flyout information in the url (#148800)
## Summary The purpose of this PR is to give users the ability to share a given alert on the alert's page. This is possible via two changes. First, the simple state of the details flyout is now stored in a url query param `eventFlyout=(...flyoutState)`, when opened. Secondly, the addition of a `Share Alert` button which allows users to share a link directly to the alert page filtered for the given alert and the flyout opened. ### Caveats 1. **The share button is much more reliable than copying the url from the browser url bar.** Ideally storing the url state in the url should have been enough, but because of the potential for relative time ranges in the global kql query bar (which are also stored in the url), it it is possible to share a url by copying the browser url that doesn't actually open the given alert. As an example: A user with a relative time range of `last 24 hours` opens an alert that was created this morning with a colleague. The colleague doesn't actually visit the link till the following afternoon. When the user visits the link, they _may_ see the flyout open, but may not actually see the associated alert in the alert table. This is because the relative time range of `last 24 hours` doesn't contain the alert that was opened the previous morning. The flyout _may_ open because it is not constrained by the relative time range, but the primary alert table may easily be out of sync. Given this, the `Share Alert` button creates a custom url `alerts/alertId?index='blah'×tamp='...'` which redirects the user to the specific alert and time range of their given alert. 2. **Storing of the alert flyout url state only works on the alerts page. The url state is not stored on the cases or rules pages.** Although this flyout is used in multiple locations, we only want to preserve it on this singular page to keep the user flow simple, and also allow us to more smoothly transition to the future flyout experience. ## Demo ### Sharing via the browser url https://user-images.githubusercontent.com/17211684/227567405-37589def-b1be-406e-802e-764b49bee5f6.mov ### Sharing via the `Share alert` button https://user-images.githubusercontent.com/17211684/230382767-0e6bf3d0-6ed1-442f-921a-db5eeec7592f.mov ## Follow up work Revert/Delete the changes of the old Alerts Page POC: https://github.com/elastic/kibana/issues/154477
This commit is contained in:
parent
c4908c09fc
commit
56566ffc89
17 changed files with 402 additions and 92 deletions
|
@ -8,25 +8,26 @@
|
||||||
import {
|
import {
|
||||||
ALERT_FLYOUT,
|
ALERT_FLYOUT,
|
||||||
CELL_TEXT,
|
CELL_TEXT,
|
||||||
|
COPY_ALERT_FLYOUT_LINK,
|
||||||
JSON_TEXT,
|
JSON_TEXT,
|
||||||
|
OVERVIEW_RULE,
|
||||||
TABLE_CONTAINER,
|
TABLE_CONTAINER,
|
||||||
TABLE_ROWS,
|
TABLE_ROWS,
|
||||||
} from '../../screens/alerts_details';
|
} from '../../screens/alerts_details';
|
||||||
|
import { closeAlertFlyout, expandFirstAlert } from '../../tasks/alerts';
|
||||||
import { expandFirstAlert } from '../../tasks/alerts';
|
import { filterBy, openJsonView, openTable } from '../../tasks/alerts_details';
|
||||||
import { openJsonView, openTable } from '../../tasks/alerts_details';
|
|
||||||
import { createRule } from '../../tasks/api_calls/rules';
|
import { createRule } from '../../tasks/api_calls/rules';
|
||||||
import { cleanKibana } from '../../tasks/common';
|
import { cleanKibana } from '../../tasks/common';
|
||||||
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
|
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
|
||||||
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
|
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
|
||||||
import { login, visitWithoutDateRange } from '../../tasks/login';
|
import { login, visit, visitWithoutDateRange } from '../../tasks/login';
|
||||||
|
import { getUnmappedRule, getNewRule } from '../../objects/rule';
|
||||||
import { getUnmappedRule } from '../../objects/rule';
|
|
||||||
|
|
||||||
import { ALERTS_URL } from '../../urls/navigation';
|
import { ALERTS_URL } from '../../urls/navigation';
|
||||||
import { tablePageSelector } from '../../screens/table_pagination';
|
import { tablePageSelector } from '../../screens/table_pagination';
|
||||||
|
import { ALERTS_COUNT } from '../../screens/alerts';
|
||||||
|
|
||||||
describe('Alert details with unmapped fields', { testIsolation: false }, () => {
|
describe('Alert details flyout', () => {
|
||||||
|
describe('With unmapped fields', { testIsolation: false }, () => {
|
||||||
before(() => {
|
before(() => {
|
||||||
cleanKibana();
|
cleanKibana();
|
||||||
esArchiverLoad('unmapped_fields');
|
esArchiverLoad('unmapped_fields');
|
||||||
|
@ -87,3 +88,57 @@ describe('Alert details with unmapped fields', { testIsolation: false }, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Url state management', { testIsolation: false }, () => {
|
||||||
|
const testRule = getNewRule();
|
||||||
|
before(() => {
|
||||||
|
cleanKibana();
|
||||||
|
login();
|
||||||
|
createRule(testRule);
|
||||||
|
visit(ALERTS_URL);
|
||||||
|
waitForAlertsToPopulate();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the copy link button for the flyout', () => {
|
||||||
|
expandFirstAlert();
|
||||||
|
cy.get(COPY_ALERT_FLYOUT_LINK).should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -21,7 +21,9 @@ import {
|
||||||
import { PAGE_TITLE } from '../../screens/common/page';
|
import { PAGE_TITLE } from '../../screens/common/page';
|
||||||
import { OPEN_ALERT_DETAILS_PAGE } from '../../screens/alerts_details';
|
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', () => {
|
describe('navigating to alert details page', () => {
|
||||||
const rule = getNewRule();
|
const rule = getNewRule();
|
||||||
before(() => {
|
before(() => {
|
||||||
|
|
|
@ -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 ENRICHED_DATA_ROW = `[data-test-subj='EnrichedDataRow']`;
|
||||||
|
|
||||||
export const OPEN_ALERT_DETAILS_PAGE = `[data-test-subj="open-alert-details-page"]`;
|
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"]`;
|
||||||
|
|
|
@ -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;
|
|
@ -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<FlyoutUrlState | null>(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);
|
||||||
|
};
|
|
@ -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<FlyoutUrlState>(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]);
|
||||||
|
};
|
|
@ -12,6 +12,8 @@ import { useUpdateTimerangeOnPageChange } from './search_bar/use_update_timerang
|
||||||
import { useInitTimelineFromUrlParam } from './timeline/use_init_timeline_url_param';
|
import { useInitTimelineFromUrlParam } from './timeline/use_init_timeline_url_param';
|
||||||
import { useSyncTimelineUrlParam } from './timeline/use_sync_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 { 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 = () => {
|
export const useUrlState = () => {
|
||||||
useSyncGlobalQueryString();
|
useSyncGlobalQueryString();
|
||||||
|
@ -21,10 +23,13 @@ export const useUrlState = () => {
|
||||||
useInitTimelineFromUrlParam();
|
useInitTimelineFromUrlParam();
|
||||||
useSyncTimelineUrlParam();
|
useSyncTimelineUrlParam();
|
||||||
useQueryTimelineByIdOnUrlChange();
|
useQueryTimelineByIdOnUrlChange();
|
||||||
|
useInitFlyoutFromUrlParam();
|
||||||
|
useSyncFlyoutUrlParam();
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum URL_PARAM_KEY {
|
export enum URL_PARAM_KEY {
|
||||||
appQuery = 'query',
|
appQuery = 'query',
|
||||||
|
eventFlyout = 'eventFlyout',
|
||||||
filters = 'filters',
|
filters = 'filters',
|
||||||
savedQuery = 'savedQuery',
|
savedQuery = 'savedQuery',
|
||||||
sourcerer = 'sourcerer',
|
sourcerer = 'sourcerer',
|
||||||
|
|
|
@ -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 <Redirect to={url} />;
|
||||||
|
};
|
|
@ -6,20 +6,17 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 { Route } from '@kbn/shared-ux-router';
|
||||||
|
|
||||||
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
|
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
|
||||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
|
||||||
import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants';
|
import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants';
|
||||||
import { NotFoundPage } from '../../../app/404';
|
import { NotFoundPage } from '../../../app/404';
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import { DetectionEnginePage } from '../detection_engine/detection_engine';
|
import { DetectionEnginePage } from '../detection_engine/detection_engine';
|
||||||
import { SpyRoute } from '../../../common/utils/route/spy_routes';
|
import { SpyRoute } from '../../../common/utils/route/spy_routes';
|
||||||
import { useReadonlyHeader } from '../../../use_readonly_header';
|
import { useReadonlyHeader } from '../../../use_readonly_header';
|
||||||
import { AlertDetailsPage } from '../alert_details';
|
import { AlertDetailsRedirect } from './alert_details_redirect';
|
||||||
import { AlertDetailRouteType } from '../alert_details/types';
|
|
||||||
import { getAlertDetailsTabUrl } from '../alert_details/utils/navigation';
|
|
||||||
|
|
||||||
const AlertsRoute = () => (
|
const AlertsRoute = () => (
|
||||||
<TrackApplicationView viewId={SecurityPageName.alerts}>
|
<TrackApplicationView viewId={SecurityPageName.alerts}>
|
||||||
|
@ -28,40 +25,13 @@ const AlertsRoute = () => (
|
||||||
</TrackApplicationView>
|
</TrackApplicationView>
|
||||||
);
|
);
|
||||||
|
|
||||||
const AlertDetailsRoute = () => (
|
|
||||||
<TrackApplicationView viewId={SecurityPageName.alerts}>
|
|
||||||
<AlertDetailsPage />
|
|
||||||
</TrackApplicationView>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AlertsContainerComponent: React.FC = () => {
|
const AlertsContainerComponent: React.FC = () => {
|
||||||
useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP);
|
useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP);
|
||||||
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={ALERTS_PATH} exact component={AlertsRoute} />
|
<Route path={ALERTS_PATH} exact component={AlertsRoute} />
|
||||||
{isAlertDetailsPageEnabled && (
|
{/* Redirect to the alerts page filtered for the given alert id */}
|
||||||
<>
|
<Route path={`${ALERTS_PATH}/:alertId`} component={AlertDetailsRedirect} />
|
||||||
{/* Redirect to the summary page if only the detail name is provided */}
|
|
||||||
<Route
|
|
||||||
path={`${ALERTS_PATH}/:detailName`}
|
|
||||||
render={({
|
|
||||||
match: {
|
|
||||||
params: { detailName },
|
|
||||||
},
|
|
||||||
location: { search = '' },
|
|
||||||
}) => (
|
|
||||||
<Redirect
|
|
||||||
to={{
|
|
||||||
pathname: getAlertDetailsTabUrl(detailName, AlertDetailRouteType.summary),
|
|
||||||
search,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path={`${ALERTS_PATH}/:detailName/:tabName`} component={AlertDetailsRoute} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Route component={NotFoundPage} />
|
<Route component={NotFoundPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,12 +8,14 @@
|
||||||
import { isEmpty } from 'lodash/fp';
|
import { isEmpty } from 'lodash/fp';
|
||||||
import {
|
import {
|
||||||
EuiButtonIcon,
|
EuiButtonIcon,
|
||||||
|
EuiButtonEmpty,
|
||||||
EuiTextColor,
|
EuiTextColor,
|
||||||
EuiLoadingContent,
|
EuiLoadingContent,
|
||||||
EuiTitle,
|
EuiTitle,
|
||||||
EuiFlexGroup,
|
EuiFlexGroup,
|
||||||
EuiFlexItem,
|
EuiFlexItem,
|
||||||
EuiSpacer,
|
EuiSpacer,
|
||||||
|
EuiCopy,
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
@ -32,6 +34,7 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
|
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
|
||||||
import { SecurityPageName } from '../../../../../common/constants';
|
import { SecurityPageName } from '../../../../../common/constants';
|
||||||
|
import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link';
|
||||||
|
|
||||||
export type HandleOnEventClosed = () => void;
|
export type HandleOnEventClosed = () => void;
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -52,10 +55,11 @@ interface Props {
|
||||||
|
|
||||||
interface ExpandableEventTitleProps {
|
interface ExpandableEventTitleProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
eventIndex: string;
|
||||||
isAlert: boolean;
|
isAlert: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
ruleName?: string;
|
ruleName?: string;
|
||||||
timestamp?: string;
|
timestamp: string;
|
||||||
handleOnEventClosed?: HandleOnEventClosed;
|
handleOnEventClosed?: HandleOnEventClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,12 +80,19 @@ const StyledEuiFlexItem = styled(EuiFlexItem)`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
||||||
({ eventId, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => {
|
({ eventId, eventIndex, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => {
|
||||||
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
|
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
|
||||||
const { onClick } = useGetSecuritySolutionLinkProps()({
|
const { onClick } = useGetSecuritySolutionLinkProps()({
|
||||||
deepLinkId: SecurityPageName.alerts,
|
deepLinkId: SecurityPageName.alerts,
|
||||||
path: eventId && isAlert ? getAlertDetailsUrl(eventId) : '',
|
path: eventId && isAlert ? getAlertDetailsUrl(eventId) : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const alertDetailsLink = useGetAlertDetailsFlyoutLink({
|
||||||
|
_id: eventId,
|
||||||
|
_index: eventIndex,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
|
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
|
@ -117,6 +128,19 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
||||||
<EuiButtonIcon iconType="cross" aria-label={i18n.CLOSE} onClick={handleOnEventClosed} />
|
<EuiButtonIcon iconType="cross" aria-label={i18n.CLOSE} onClick={handleOnEventClosed} />
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
)}
|
)}
|
||||||
|
{isAlert && (
|
||||||
|
<EuiCopy textToCopy={alertDetailsLink}>
|
||||||
|
{(copy) => (
|
||||||
|
<EuiButtonEmpty
|
||||||
|
onClick={copy}
|
||||||
|
iconType="share"
|
||||||
|
data-test-subj="copy-alert-flyout-link"
|
||||||
|
>
|
||||||
|
{i18n.SHARE_ALERT}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
)}
|
||||||
|
</EuiCopy>
|
||||||
|
)}
|
||||||
</StyledEuiFlexGroup>
|
</StyledEuiFlexGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { BackToAlertDetailsLink } from './back_to_alert_details_link';
|
||||||
|
|
||||||
interface FlyoutHeaderComponentProps {
|
interface FlyoutHeaderComponentProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
eventIndex: string;
|
||||||
isAlert: boolean;
|
isAlert: boolean;
|
||||||
isHostIsolationPanelOpen: boolean;
|
isHostIsolationPanelOpen: boolean;
|
||||||
isolateAction: 'isolateHost' | 'unisolateHost';
|
isolateAction: 'isolateHost' | 'unisolateHost';
|
||||||
|
@ -24,6 +25,7 @@ interface FlyoutHeaderComponentProps {
|
||||||
|
|
||||||
const FlyoutHeaderContentComponent = ({
|
const FlyoutHeaderContentComponent = ({
|
||||||
eventId,
|
eventId,
|
||||||
|
eventIndex,
|
||||||
isAlert,
|
isAlert,
|
||||||
isHostIsolationPanelOpen,
|
isHostIsolationPanelOpen,
|
||||||
isolateAction,
|
isolateAction,
|
||||||
|
@ -39,6 +41,7 @@ const FlyoutHeaderContentComponent = ({
|
||||||
) : (
|
) : (
|
||||||
<ExpandableEventTitle
|
<ExpandableEventTitle
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
eventIndex={eventIndex}
|
||||||
isAlert={isAlert}
|
isAlert={isAlert}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
ruleName={ruleName}
|
ruleName={ruleName}
|
||||||
|
@ -52,6 +55,7 @@ const FlyoutHeaderContent = React.memo(FlyoutHeaderContentComponent);
|
||||||
|
|
||||||
const FlyoutHeaderComponent = ({
|
const FlyoutHeaderComponent = ({
|
||||||
eventId,
|
eventId,
|
||||||
|
eventIndex,
|
||||||
isAlert,
|
isAlert,
|
||||||
isHostIsolationPanelOpen,
|
isHostIsolationPanelOpen,
|
||||||
isolateAction,
|
isolateAction,
|
||||||
|
@ -64,6 +68,7 @@ const FlyoutHeaderComponent = ({
|
||||||
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
|
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
|
||||||
<FlyoutHeaderContentComponent
|
<FlyoutHeaderContentComponent
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
eventIndex={eventIndex}
|
||||||
isAlert={isAlert}
|
isAlert={isAlert}
|
||||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||||
isolateAction={isolateAction}
|
isolateAction={isolateAction}
|
||||||
|
|
|
@ -107,6 +107,7 @@ export const useToGetInternalFlyout = () => {
|
||||||
<FlyoutHeaderContent
|
<FlyoutHeaderContent
|
||||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||||
isAlert={isAlert}
|
isAlert={isAlert}
|
||||||
|
eventIndex={alert.indexName ?? ''}
|
||||||
eventId={alertId}
|
eventId={alertId}
|
||||||
isolateAction={isolateAction}
|
isolateAction={isolateAction}
|
||||||
loading={isLoading || loading}
|
loading={isLoading || loading}
|
||||||
|
@ -117,6 +118,7 @@ export const useToGetInternalFlyout = () => {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
alert.indexName,
|
||||||
isAlert,
|
isAlert,
|
||||||
alertId,
|
alertId,
|
||||||
isHostIsolationPanelOpen,
|
isHostIsolationPanelOpen,
|
||||||
|
|
|
@ -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', () => {
|
jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => {
|
||||||
return {
|
return {
|
||||||
isIsolationSupported: jest.fn().mockReturnValue(true),
|
isIsolationSupported: jest.fn().mockReturnValue(true),
|
||||||
|
|
|
@ -81,6 +81,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
||||||
isFlyoutView || isHostIsolationPanelOpen ? (
|
isFlyoutView || isHostIsolationPanelOpen ? (
|
||||||
<FlyoutHeader
|
<FlyoutHeader
|
||||||
eventId={expandedEvent.eventId}
|
eventId={expandedEvent.eventId}
|
||||||
|
eventIndex={eventIndex}
|
||||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||||
isAlert={isAlert}
|
isAlert={isAlert}
|
||||||
isolateAction={isolateAction}
|
isolateAction={isolateAction}
|
||||||
|
@ -92,14 +93,17 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<ExpandableEventTitle
|
<ExpandableEventTitle
|
||||||
eventId={expandedEvent.eventId}
|
eventId={expandedEvent.eventId}
|
||||||
|
eventIndex={eventIndex}
|
||||||
isAlert={isAlert}
|
isAlert={isAlert}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
ruleName={ruleName}
|
ruleName={ruleName}
|
||||||
|
timestamp={timestamp}
|
||||||
handleOnEventClosed={handleOnEventClosed}
|
handleOnEventClosed={handleOnEventClosed}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
expandedEvent.eventId,
|
expandedEvent.eventId,
|
||||||
|
eventIndex,
|
||||||
handleOnEventClosed,
|
handleOnEventClosed,
|
||||||
isAlert,
|
isAlert,
|
||||||
isFlyoutView,
|
isFlyoutView,
|
||||||
|
|
|
@ -48,3 +48,10 @@ export const ALERT_DETAILS = i18n.translate(
|
||||||
defaultMessage: 'Alert details',
|
defaultMessage: 'Alert details',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SHARE_ALERT = i18n.translate(
|
||||||
|
'xpack.securitySolution.timeline.expandableEvent.shareAlert',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Share alert',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -29,6 +29,15 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({
|
||||||
useSearchStrategy: jest.fn(),
|
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', () => {
|
describe('Details Panel Component', () => {
|
||||||
const state: State = {
|
const state: State = {
|
||||||
...mockGlobalState,
|
...mockGlobalState,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue