mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security Solution][Investigations] - Alert Details Summary Page (#141709)
* initialize alert details page * fix checks * fix types * remove unused import * update details page * fix cases tests * update based on PR feedback: * disable filter in and filter out in alerts details page * fix types * PR feedback * sync with main
This commit is contained in:
parent
e92b38415d
commit
01d76ebd13
69 changed files with 5689 additions and 260 deletions
|
@ -12,6 +12,7 @@ const KIBANA_NAMESPACE = 'kibana' as const;
|
|||
|
||||
const ALERT_NAMESPACE = `${KIBANA_NAMESPACE}.alert` as const;
|
||||
const ALERT_RULE_NAMESPACE = `${ALERT_NAMESPACE}.rule` as const;
|
||||
const ALERT_RULE_THREAT_NAMESPACE = `${ALERT_RULE_NAMESPACE}.threat` as const;
|
||||
|
||||
const ECS_VERSION = 'ecs.version' as const;
|
||||
const EVENT_ACTION = 'event.action' as const;
|
||||
|
@ -68,6 +69,23 @@ const ALERT_RULE_TYPE_ID = `${ALERT_RULE_NAMESPACE}.rule_type_id` as const;
|
|||
const ALERT_RULE_UPDATED_AT = `${ALERT_RULE_NAMESPACE}.updated_at` as const;
|
||||
const ALERT_RULE_UPDATED_BY = `${ALERT_RULE_NAMESPACE}.updated_by` as const;
|
||||
const ALERT_RULE_VERSION = `${ALERT_RULE_NAMESPACE}.version` as const;
|
||||
|
||||
// Fields pertaining to the threat tactic associated with the rule
|
||||
const ALERT_THREAT_FRAMEWORK = `${ALERT_RULE_THREAT_NAMESPACE}.framework` as const;
|
||||
const ALERT_THREAT_TACTIC_ID = `${ALERT_RULE_THREAT_NAMESPACE}.tactic.id` as const;
|
||||
const ALERT_THREAT_TACTIC_NAME = `${ALERT_RULE_THREAT_NAMESPACE}.tactic.name` as const;
|
||||
const ALERT_THREAT_TACTIC_REFERENCE = `${ALERT_RULE_THREAT_NAMESPACE}.tactic.reference` as const;
|
||||
const ALERT_THREAT_TECHNIQUE_ID = `${ALERT_RULE_THREAT_NAMESPACE}.technique.id` as const;
|
||||
const ALERT_THREAT_TECHNIQUE_NAME = `${ALERT_RULE_THREAT_NAMESPACE}.technique.name` as const;
|
||||
const ALERT_THREAT_TECHNIQUE_REFERENCE =
|
||||
`${ALERT_RULE_THREAT_NAMESPACE}.technique.reference` as const;
|
||||
const ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID =
|
||||
`${ALERT_RULE_THREAT_NAMESPACE}.technique.subtechnique.id` as const;
|
||||
const ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME =
|
||||
`${ALERT_RULE_THREAT_NAMESPACE}.technique.subtechnique.name` as const;
|
||||
const ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE =
|
||||
`${ALERT_RULE_THREAT_NAMESPACE}.technique.subtechnique.reference` as const;
|
||||
|
||||
// the feature instantiating a rule type.
|
||||
// Rule created in stack --> alerts
|
||||
// Rule created in siem --> siem
|
||||
|
@ -137,6 +155,16 @@ const fields = {
|
|||
ALERT_WORKFLOW_USER,
|
||||
ALERT_RULE_UUID,
|
||||
ALERT_RULE_CATEGORY,
|
||||
ALERT_THREAT_FRAMEWORK,
|
||||
ALERT_THREAT_TACTIC_ID,
|
||||
ALERT_THREAT_TACTIC_NAME,
|
||||
ALERT_THREAT_TACTIC_REFERENCE,
|
||||
ALERT_THREAT_TECHNIQUE_ID,
|
||||
ALERT_THREAT_TECHNIQUE_NAME,
|
||||
ALERT_THREAT_TECHNIQUE_REFERENCE,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE,
|
||||
SPACE_IDS,
|
||||
VERSION,
|
||||
};
|
||||
|
@ -195,6 +223,16 @@ export {
|
|||
KIBANA_NAMESPACE,
|
||||
ALERT_RULE_UUID,
|
||||
ALERT_RULE_CATEGORY,
|
||||
ALERT_THREAT_FRAMEWORK,
|
||||
ALERT_THREAT_TACTIC_ID,
|
||||
ALERT_THREAT_TACTIC_NAME,
|
||||
ALERT_THREAT_TACTIC_REFERENCE,
|
||||
ALERT_THREAT_TECHNIQUE_ID,
|
||||
ALERT_THREAT_TECHNIQUE_NAME,
|
||||
ALERT_THREAT_TECHNIQUE_REFERENCE,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE,
|
||||
TAGS,
|
||||
TIMESTAMP,
|
||||
SPACE_IDS,
|
||||
|
|
|
@ -75,6 +75,11 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
* Enables the Guided Onboarding tour in security
|
||||
*/
|
||||
guidedOnboarding: false,
|
||||
|
||||
/**
|
||||
* Enables the alert details page currently only accessible via the alert details flyout and alert table context menu
|
||||
*/
|
||||
alertDetailsPageEnabled: false,
|
||||
});
|
||||
|
||||
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
|
||||
|
|
|
@ -319,6 +319,7 @@ export enum TimelineId {
|
|||
active = 'timeline-1',
|
||||
casePage = 'timeline-case',
|
||||
test = 'timeline-test', // Reserved for testing purposes
|
||||
detectionsAlertDetailsPage = 'detections-alert-details-page',
|
||||
}
|
||||
|
||||
export enum TableId {
|
||||
|
|
|
@ -15,7 +15,7 @@ import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
|
|||
import { login, visit, waitForPageWithoutDateRange } from '../../tasks/login';
|
||||
|
||||
import { ALERTS_URL } from '../../urls/navigation';
|
||||
import { ATTACH_ALERT_TO_CASE_BUTTON, TIMELINE_CONTEXT_MENU_BTN } from '../../screens/alerts';
|
||||
import { ATTACH_ALERT_TO_CASE_BUTTON, ATTACH_TO_NEW_CASE_BUTTON } from '../../screens/alerts';
|
||||
import { LOADING_INDICATOR } from '../../screens/security_header';
|
||||
|
||||
const loadDetectionsPage = (role: ROLES) => {
|
||||
|
@ -40,9 +40,16 @@ describe('Alerts timeline', () => {
|
|||
waitForPageToBeLoaded();
|
||||
});
|
||||
|
||||
it('should not allow user with read only privileges to attach alerts to cases', () => {
|
||||
// Disabled actions for read only users are hidden, so actions button should not show
|
||||
cy.get(TIMELINE_CONTEXT_MENU_BTN).should('not.exist');
|
||||
it('should not allow user with read only privileges to attach alerts to existing cases', () => {
|
||||
// Disabled actions for read only users are hidden, so only open alert details button should show
|
||||
expandFirstAlertActions();
|
||||
cy.get(ATTACH_ALERT_TO_CASE_BUTTON).should('not.exist');
|
||||
});
|
||||
|
||||
it('should not allow user with read only privileges to attach alerts to a new case', () => {
|
||||
// Disabled actions for read only users are hidden, so only open alert details button should show
|
||||
expandFirstAlertActions();
|
||||
cy.get(ATTACH_TO_NEW_CASE_BUTTON).should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { expandFirstAlert, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts';
|
||||
import { createCustomRuleEnabled } from '../../tasks/api_calls/rules';
|
||||
import { cleanKibana } from '../../tasks/common';
|
||||
import { login, visit } from '../../tasks/login';
|
||||
|
||||
import { getNewRule } from '../../objects/rule';
|
||||
import type { CustomRule } from '../../objects/rule';
|
||||
|
||||
import { ALERTS_URL } from '../../urls/navigation';
|
||||
import {
|
||||
OPEN_ALERT_DETAILS_PAGE_CONTEXT_MENU_BTN,
|
||||
TIMELINE_CONTEXT_MENU_BTN,
|
||||
} from '../../screens/alerts';
|
||||
import { PAGE_TITLE } from '../../screens/common/page';
|
||||
import { OPEN_ALERT_DETAILS_PAGE } from '../../screens/alerts_details';
|
||||
|
||||
describe('Alert Details Page Navigation', () => {
|
||||
describe('navigating to alert details page', () => {
|
||||
let rule: CustomRule;
|
||||
before(() => {
|
||||
rule = getNewRule();
|
||||
cleanKibana();
|
||||
login();
|
||||
createCustomRuleEnabled(rule, 'rule1');
|
||||
visit(ALERTS_URL);
|
||||
waitForAlertsPanelToBeLoaded();
|
||||
});
|
||||
|
||||
describe('context menu', () => {
|
||||
it('should navigate to the details page from the alert context menu', () => {
|
||||
cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true });
|
||||
cy.get(OPEN_ALERT_DETAILS_PAGE_CONTEXT_MENU_BTN).click({ force: true });
|
||||
cy.get(PAGE_TITLE).should('contain.text', rule.name);
|
||||
cy.url().should('include', '/summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flyout', () => {
|
||||
beforeEach(() => {
|
||||
visit(ALERTS_URL);
|
||||
waitForAlertsPanelToBeLoaded();
|
||||
});
|
||||
|
||||
it('should navigate to the details page from the alert flyout', () => {
|
||||
expandFirstAlert();
|
||||
cy.get(OPEN_ALERT_DETAILS_PAGE).click({ force: true });
|
||||
cy.get(PAGE_TITLE).should('contain.text', rule.name);
|
||||
cy.url().should('include', '/summary');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -30,7 +30,8 @@ describe('Display not found page', () => {
|
|||
visit(TIMELINES_URL);
|
||||
});
|
||||
|
||||
it('navigates to the alerts page with incorrect link', () => {
|
||||
// TODO: We need to determine what we want the behavior to be here
|
||||
it.skip('navigates to the alerts page with incorrect link', () => {
|
||||
visit(`${ALERTS_URL}/randomUrl`);
|
||||
cy.get(NOT_FOUND).should('exist');
|
||||
});
|
||||
|
|
|
@ -71,6 +71,9 @@ export const OPEN_ALERT_BTN = '[data-test-subj="open-alert-status"]';
|
|||
|
||||
export const OPENED_ALERTS_FILTER_BTN = '[data-test-subj="openAlerts"]';
|
||||
|
||||
export const OPEN_ALERT_DETAILS_PAGE_CONTEXT_MENU_BTN =
|
||||
'[data-test-subj="open-alert-details-page-menu-item"]';
|
||||
|
||||
export const PROCESS_NAME_COLUMN = '[data-test-subj="dataGridHeaderCell-process.name"]';
|
||||
export const PROCESS_NAME = '[data-test-subj="formatted-field-process.name"]';
|
||||
|
||||
|
@ -103,6 +106,8 @@ export const USER_NAME = '[data-test-subj^=formatted-field][data-test-subj$=user
|
|||
|
||||
export const ATTACH_ALERT_TO_CASE_BUTTON = '[data-test-subj="add-to-existing-case-action"]';
|
||||
|
||||
export const ATTACH_TO_NEW_CASE_BUTTON = '[data-test-subj="add-to-new-case-action"]';
|
||||
|
||||
export const USER_COLUMN = '[data-gridcell-column-id="user.name"]';
|
||||
|
||||
export const HOST_RISK_HEADER_COLIMN =
|
||||
|
|
|
@ -81,3 +81,5 @@ export const INSIGHTS_RELATED_ALERTS_BY_ANCESTRY = `[data-test-subj='related-ale
|
|||
export const INSIGHTS_INVESTIGATE_ANCESTRY_ALERTS_IN_TIMELINE_BUTTON = `[data-test-subj='investigate-ancestry-in-timeline']`;
|
||||
|
||||
export const ENRICHED_DATA_ROW = `[data-test-subj='EnrichedDataRow']`;
|
||||
|
||||
export const OPEN_ALERT_DETAILS_PAGE = `[data-test-subj="open-alert-details-page"]`;
|
||||
|
|
|
@ -65,11 +65,11 @@ export interface HeaderPageProps extends HeaderProps {
|
|||
badgeOptions?: BadgeOptions;
|
||||
children?: React.ReactNode;
|
||||
draggableArguments?: DraggableArguments;
|
||||
rightSideItems?: React.ReactNode[];
|
||||
subtitle?: SubtitleProps['items'];
|
||||
subtitle2?: SubtitleProps['items'];
|
||||
title: TitleProp;
|
||||
titleNode?: React.ReactElement;
|
||||
rightSideItems?: React.ReactNode[];
|
||||
}
|
||||
|
||||
export const HeaderLinkBack: React.FC<{ backOptions: BackOptions }> = React.memo(
|
||||
|
@ -105,11 +105,11 @@ const HeaderPageComponent: React.FC<HeaderPageProps> = ({
|
|||
children,
|
||||
draggableArguments,
|
||||
isLoading,
|
||||
rightSideItems,
|
||||
subtitle,
|
||||
subtitle2,
|
||||
title,
|
||||
titleNode,
|
||||
rightSideItems,
|
||||
}) => (
|
||||
<>
|
||||
<EuiPageHeader alignItems="center" bottomBorder={border} rightSideItems={rightSideItems}>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { EuiFocusTrap, EuiScreenReaderOnly } from '@elastic/eui';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DraggableId } from 'react-beautiful-dnd';
|
||||
import styled from 'styled-components';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
@ -35,7 +35,7 @@ AdditionalContent.displayName = 'AdditionalContent';
|
|||
const StyledHoverActionsContainer = styled.div<{
|
||||
$showTopN: boolean;
|
||||
$showOwnFocus: boolean;
|
||||
$hideTopN: boolean;
|
||||
$hiddenActionsCount: number;
|
||||
$isActive: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
|
@ -82,7 +82,7 @@ const StyledHoverActionsContainer = styled.div<{
|
|||
`;
|
||||
|
||||
const StyledHoverActionsContainerWithPaddingsAndMinWidth = styled(StyledHoverActionsContainer)`
|
||||
min-width: ${({ $hideTopN }) => `${$hideTopN ? '112px' : '138px'}`};
|
||||
min-width: ${({ $hiddenActionsCount }) => `${138 - $hiddenActionsCount * 26}px`};
|
||||
padding: ${(props) => `0 ${props.theme.eui.euiSizeS}`};
|
||||
position: relative;
|
||||
`;
|
||||
|
@ -161,7 +161,6 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
setIsActive((prev) => !prev);
|
||||
setIsOverflowPopoverOpen(!isOverflowPopoverOpen);
|
||||
}, [isOverflowPopoverOpen, setIsOverflowPopoverOpen]);
|
||||
|
||||
const handleHoverActionClicked = useCallback(() => {
|
||||
if (closeTopN) {
|
||||
closeTopN();
|
||||
|
@ -216,6 +215,20 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
);
|
||||
|
||||
const isCaseView = scopeId === TimelineId.casePage;
|
||||
const isTimelineView = scopeId === TimelineId.active;
|
||||
const isAlertDetailsView = scopeId === TimelineId.detectionsAlertDetailsPage;
|
||||
|
||||
const hideFilters = useMemo(
|
||||
() => isAlertDetailsView && !isTimelineView,
|
||||
[isTimelineView, isAlertDetailsView]
|
||||
);
|
||||
|
||||
const hiddenActionsCount = useMemo(() => {
|
||||
const hiddenTopNActions = hideTopN ? 1 : 0; // hides the `Top N` button
|
||||
const hiddenFilterActions = hideFilters ? 2 : 0; // hides both the `Filter In` and `Filter out` buttons
|
||||
|
||||
return hiddenTopNActions + hiddenFilterActions;
|
||||
}, [hideFilters, hideTopN]);
|
||||
|
||||
const { overflowActionItems, allActionItems } = useHoverActionItems({
|
||||
dataProvider,
|
||||
|
@ -225,6 +238,7 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
enableOverflowButton: enableOverflowButton && !isCaseView,
|
||||
field,
|
||||
fieldType,
|
||||
hideFilters,
|
||||
isAggregatable,
|
||||
handleHoverActionClicked,
|
||||
hideAddToTimeline,
|
||||
|
@ -258,7 +272,7 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
onKeyDown={onKeyDown}
|
||||
$showTopN={showTopN}
|
||||
$showOwnFocus={showOwnFocus}
|
||||
$hideTopN={hideTopN}
|
||||
$hiddenActionsCount={hiddenActionsCount}
|
||||
$isActive={isActive}
|
||||
className={isActive ? 'hoverActions-active' : ''}
|
||||
>
|
||||
|
|
|
@ -33,6 +33,7 @@ export interface UseHoverActionItemsProps {
|
|||
isAggregatable: boolean;
|
||||
handleHoverActionClicked: () => void;
|
||||
hideAddToTimeline: boolean;
|
||||
hideFilters?: boolean;
|
||||
hideTopN: boolean;
|
||||
isCaseView: boolean;
|
||||
isObjectArray: boolean;
|
||||
|
@ -64,6 +65,7 @@ export const useHoverActionItems = ({
|
|||
fieldType,
|
||||
isAggregatable,
|
||||
handleHoverActionClicked,
|
||||
hideFilters,
|
||||
hideTopN,
|
||||
hideAddToTimeline,
|
||||
isCaseView,
|
||||
|
@ -132,12 +134,18 @@ export const useHoverActionItems = ({
|
|||
OnAddToTimeline();
|
||||
}, [handleHoverActionClicked, OnAddToTimeline]);
|
||||
|
||||
/*
|
||||
* In the case of `DisableOverflowButton`, we show filters only when topN is NOT opened. As after topN button is clicked, the chart panel replace current hover actions in the hover actions' popover, so we have to hide all the actions.
|
||||
* in the case of `EnableOverflowButton`, we only need to hide all the items in the overflow popover as the chart's panel opens in the overflow popover, so non-overflowed actions are not affected.
|
||||
*/
|
||||
const showFilters =
|
||||
values != null && (enableOverflowButton || (!showTopN && !enableOverflowButton)) && !isCaseView;
|
||||
const showFilters = useMemo(() => {
|
||||
if (hideFilters) return false;
|
||||
/*
|
||||
* In the case of `DisableOverflowButton`, we show filters only when topN is NOT opened. As after topN button is clicked, the chart panel replace current hover actions in the hover actions' popover, so we have to hide all the actions.
|
||||
* in the case of `EnableOverflowButton`, we only need to hide all the items in the overflow popover as the chart's panel opens in the overflow popover, so non-overflowed actions are not affected.
|
||||
*/
|
||||
return (
|
||||
values != null &&
|
||||
(enableOverflowButton || (!showTopN && !enableOverflowButton)) &&
|
||||
!isCaseView
|
||||
);
|
||||
}, [enableOverflowButton, hideFilters, isCaseView, showTopN, values]);
|
||||
const shouldDisableColumnToggle = (isObjectArray && field !== 'geo_point') || isCaseView;
|
||||
|
||||
const showTopNBtn = useMemo(
|
||||
|
|
|
@ -12,6 +12,7 @@ export { getAppLandingUrl } from '../redirect_to_landing';
|
|||
export { getHostDetailsUrl, getHostsUrl } from '../redirect_to_hosts';
|
||||
export { getNetworkUrl, getNetworkDetailsUrl } from '../redirect_to_network';
|
||||
export { getTimelineTabsUrl, getTimelineUrl } from '../redirect_to_timelines';
|
||||
export { getAlertDetailsUrl, getAlertDetailsTabUrl } from '../redirect_to_alerts';
|
||||
export {
|
||||
getCaseDetailsUrl,
|
||||
getCaseUrl,
|
||||
|
|
|
@ -12,6 +12,7 @@ import { useAppUrl } from '../../lib/kibana/hooks';
|
|||
import type { SecurityPageName } from '../../../app/types';
|
||||
import { needsUrlState } from '../../links';
|
||||
|
||||
export { getAlertDetailsUrl, getAlertDetailsTabUrl } from './redirect_to_alerts';
|
||||
export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine';
|
||||
export { getHostDetailsUrl, getTabsOnHostDetailsUrl, getHostsUrl } from './redirect_to_hosts';
|
||||
export { getKubernetesUrl, getKubernetesDetailsUrl } from './redirect_to_kubernetes';
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { ALERTS_PATH } from '../../../../common/constants';
|
||||
import type { AlertDetailRouteType } from '../../../detections/pages/alert_details/types';
|
||||
import { appendSearch } from './helpers';
|
||||
|
||||
export const getAlertDetailsUrl = (alertId: string, search?: string) =>
|
||||
`/${alertId}/summary${appendSearch(search)}`;
|
||||
|
||||
export const getAlertDetailsTabUrl = (
|
||||
detailName: string,
|
||||
tabName: AlertDetailRouteType,
|
||||
search?: string
|
||||
) => `${ALERTS_PATH}/${detailName}/${tabName}${appendSearch(search)}`;
|
|
@ -15,6 +15,7 @@ import { getTrailingBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../n
|
|||
import { getTrailingBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils';
|
||||
import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../users/pages/details/utils';
|
||||
import { getTrailingBreadcrumbs as getKubernetesBreadcrumbs } from '../../../../kubernetes/pages/utils/breadcrumbs';
|
||||
import { getTrailingBreadcrumbs as getAlertDetailBreadcrumbs } from '../../../../detections/pages/alert_details/utils/breadcrumbs';
|
||||
import { SecurityPageName } from '../../../../app/types';
|
||||
import type {
|
||||
RouteSpyState,
|
||||
|
@ -22,6 +23,7 @@ import type {
|
|||
NetworkRouteSpyState,
|
||||
AdministrationRouteSpyState,
|
||||
UsersRouteSpyState,
|
||||
AlertDetailRouteSpyState,
|
||||
} from '../../../utils/route/types';
|
||||
import { timelineActions } from '../../../../timelines/store/timeline';
|
||||
import { TimelineId } from '../../../../../common/types/timeline';
|
||||
|
@ -132,6 +134,9 @@ const getTrailingBreadcrumbsForRoutes = (
|
|||
if (isKubernetesRoutes(spyState)) {
|
||||
return getKubernetesBreadcrumbs(spyState, getSecuritySolutionUrl);
|
||||
}
|
||||
if (isAlertRoutes(spyState)) {
|
||||
return getAlertDetailBreadcrumbs(spyState, getSecuritySolutionUrl);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
@ -150,6 +155,9 @@ const isCaseRoutes = (spyState: RouteSpyState) => spyState.pageName === Security
|
|||
const isKubernetesRoutes = (spyState: RouteSpyState) =>
|
||||
spyState.pageName === SecurityPageName.kubernetes;
|
||||
|
||||
const isAlertRoutes = (spyState: RouteSpyState): spyState is AlertDetailRouteSpyState =>
|
||||
spyState.pageName === SecurityPageName.alerts;
|
||||
|
||||
const isRulesRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState =>
|
||||
spyState.pageName === SecurityPageName.rules ||
|
||||
spyState.pageName === SecurityPageName.rulesCreate;
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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, useState, useEffect } from 'react';
|
||||
import { useKibana, useToasts } from '../../lib/kibana';
|
||||
import { CASES_ERROR_TOAST } from '../../components/event_details/insights/translations';
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
|
||||
type RelatedCases = Array<{ id: string; title: string }>;
|
||||
|
||||
export const useGetRelatedCasesByEvent = (eventId: string) => {
|
||||
const {
|
||||
services: { cases },
|
||||
} = useKibana();
|
||||
const toasts = useToasts();
|
||||
|
||||
const [relatedCases, setRelatedCases] = useState<RelatedCases | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<null | unknown>(null);
|
||||
|
||||
const getRelatedCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
let relatedCasesResponse: RelatedCases = [];
|
||||
try {
|
||||
if (eventId) {
|
||||
relatedCasesResponse =
|
||||
(await cases.api.getRelatedCases(eventId, {
|
||||
owner: APP_ID,
|
||||
})) ?? [];
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
toasts.addWarning(CASES_ERROR_TOAST(err));
|
||||
} finally {
|
||||
setRelatedCases(relatedCasesResponse);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId, cases.api, toasts]);
|
||||
|
||||
useEffect(() => {
|
||||
getRelatedCases();
|
||||
}, [eventId, getRelatedCases]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
relatedCases,
|
||||
refetchRelatedCases: getRelatedCases,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* 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, useMemo } from 'react';
|
||||
import { getOr } from 'lodash/fp';
|
||||
import type { SearchHit } from '../../../common/search_strategy';
|
||||
|
||||
/**
|
||||
* Since the fields api may return a string array as well as an object array
|
||||
* Getting the nestedPath of an object array would require first getting the top level `fields` key
|
||||
* The field api keys do not provide an index value for the original order of each object
|
||||
* for example, we might expect fields to reference kibana.alert.parameters.0.index, but the index information is represented by the array position.
|
||||
* This should be generally fine, but given the flattened nature of the top level key, utilities like `get` or `getOr` won't work since the path isn't actually nested
|
||||
* This utility allows users to not only get simple fields, but if they provide a path like `kibana.alert.parameters.index`, it will return an array of all index values
|
||||
* for each object in the parameters array. As an added note, this work stemmed from a hope to be able to purely use the fields api in place of the data produced by
|
||||
* `getDataFromFieldsHits` found in `x-pack/plugins/timelines/common/utils/field_formatters.ts`
|
||||
*/
|
||||
const getAllDotIndicesInReverse = (dotField: string): number[] => {
|
||||
const dotRegx = RegExp('[.]', 'g');
|
||||
const indicesOfAllDotsInString = [];
|
||||
let result = dotRegx.exec(dotField);
|
||||
while (result) {
|
||||
indicesOfAllDotsInString.push(result.index);
|
||||
result = dotRegx.exec(dotField);
|
||||
}
|
||||
/**
|
||||
* Put in reverse so we start look up from the most likely to be found;
|
||||
* [[kibana.alert.parameters, index], ['kibana.alert', 'parameters.index'], ['kibana', 'alert.parameters.index']]
|
||||
*/
|
||||
return indicesOfAllDotsInString.reverse();
|
||||
};
|
||||
|
||||
/**
|
||||
* We get the dot paths so we can look up each path to see if any of the nested fields exist
|
||||
* */
|
||||
|
||||
const getAllPotentialDotPaths = (dotField: string): string[][] => {
|
||||
const reverseDotIndices = getAllDotIndicesInReverse(dotField);
|
||||
|
||||
// The nested array paths seem to be at most a tuple (i.e.: `kibana.alert.parameters`, `some.nested.parameters.field`)
|
||||
const pathTuples = reverseDotIndices.map((dotIndex: number) => {
|
||||
return [dotField.slice(0, dotIndex), dotField.slice(dotIndex + 1)];
|
||||
});
|
||||
|
||||
return pathTuples;
|
||||
};
|
||||
|
||||
const getNestedValue = (startPath: string, endPath: string, data: Record<string, unknown>) => {
|
||||
const foundPrimaryPath = data[startPath];
|
||||
if (Array.isArray(foundPrimaryPath)) {
|
||||
// If the nested path points to an array of objects return the nested value of every object in the array
|
||||
return foundPrimaryPath
|
||||
.map((nestedObj) => getOr(null, endPath, nestedObj)) // TODO:QUESTION: does it make sense to leave undefined or null values as array position could be important?
|
||||
.filter((val) => val !== null);
|
||||
} else {
|
||||
// The nested path is just a nested object, so use getOr
|
||||
return getOr(undefined, endPath, foundPrimaryPath);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* we get the field value from a fields response and by breaking down to look at each individual path,
|
||||
* we're able to get both top level fields as well as nested fields that don't provide index information.
|
||||
* In the case where a user enters kibana.alert.parameters.someField, a mapped array of the subfield value will be returned
|
||||
*/
|
||||
const getFieldsValue = (
|
||||
dotField: string,
|
||||
data: SearchHit['fields'] | undefined,
|
||||
cacheNestedField: (fullPath: string, value: unknown) => void
|
||||
) => {
|
||||
if (!dotField || !data) return undefined;
|
||||
|
||||
// If the dotField exists and is not a nested object return it
|
||||
if (Object.hasOwn(data, dotField)) return data[dotField];
|
||||
else {
|
||||
const pathTuples = getAllPotentialDotPaths(dotField);
|
||||
for (const [startPath, endPath] of pathTuples) {
|
||||
const foundPrimaryPath = Object.hasOwn(data, startPath) ? data[startPath] : null;
|
||||
if (foundPrimaryPath) {
|
||||
const nestedValue = getNestedValue(startPath, endPath, data);
|
||||
// We cache only the values that need extra work to find. This can be an array of values or a single value
|
||||
cacheNestedField(dotField, nestedValue);
|
||||
return nestedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return undefined if nothing is found
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export type GetFieldsDataValue = string | string[] | null | undefined;
|
||||
export type GetFieldsData = (field: string) => GetFieldsDataValue;
|
||||
|
||||
export const useGetFieldsData = (fieldsData: SearchHit['fields'] | undefined): GetFieldsData => {
|
||||
// TODO: Move cache to top level container such as redux or context. Make it store type agnostic if possible
|
||||
// TODO: Handle updates where data is re-requested and the cache is reset.
|
||||
const cachedOriginalData = useMemo(() => fieldsData, [fieldsData]);
|
||||
const cachedExpensiveNestedValues: Record<string, unknown> = useMemo(() => ({}), []);
|
||||
|
||||
// Speed up any lookups elsewhere by caching the field.
|
||||
const cacheNestedValues = useCallback(
|
||||
(fullPath: string, value: unknown) => {
|
||||
cachedExpensiveNestedValues[fullPath] = value;
|
||||
},
|
||||
[cachedExpensiveNestedValues]
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(field: string) => {
|
||||
let fieldsValue;
|
||||
// Get an expensive value from the cache if it exists, otherwise search for the value
|
||||
if (Object.hasOwn(cachedExpensiveNestedValues, field)) {
|
||||
fieldsValue = cachedExpensiveNestedValues[field];
|
||||
} else {
|
||||
fieldsValue = cachedOriginalData
|
||||
? getFieldsValue(field, cachedOriginalData, cacheNestedValues)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(fieldsValue)) {
|
||||
// Return the value if it's singular, otherwise return an expected array of values
|
||||
if (fieldsValue.length === 0) return undefined;
|
||||
else return fieldsValue;
|
||||
}
|
||||
// Otherwise return the given fieldsValue if it isn't an array
|
||||
return fieldsValue;
|
||||
},
|
||||
[cacheNestedValues, cachedExpensiveNestedValues, cachedOriginalData]
|
||||
);
|
||||
};
|
|
@ -13,6 +13,7 @@ import type { TimelineType } from '../../../../common/types/timeline';
|
|||
|
||||
import type { HostsTableType } from '../../../hosts/store/model';
|
||||
import type { NetworkRouteType } from '../../../network/pages/navigation/types';
|
||||
import type { AlertDetailRouteType } from '../../../detections/pages/alert_details/types';
|
||||
import type { AdministrationSubTab as AdministrationType } from '../../../management/types';
|
||||
import type { FlowTarget } from '../../../../common/search_strategy';
|
||||
import type { UsersTableType } from '../../../users/store/model';
|
||||
|
@ -21,6 +22,7 @@ import type { SecurityPageName } from '../../../app/types';
|
|||
export type SiemRouteType =
|
||||
| HostsTableType
|
||||
| NetworkRouteType
|
||||
| AlertDetailRouteType
|
||||
| TimelineType
|
||||
| AdministrationType
|
||||
| UsersTableType;
|
||||
|
@ -47,6 +49,10 @@ export interface NetworkRouteSpyState extends RouteSpyState {
|
|||
tabName: NetworkRouteType | undefined;
|
||||
}
|
||||
|
||||
export interface AlertDetailRouteSpyState extends RouteSpyState {
|
||||
tabName: AlertDetailRouteType | undefined;
|
||||
}
|
||||
|
||||
export interface AdministrationRouteSpyState extends RouteSpyState {
|
||||
tabName: AdministrationType | undefined;
|
||||
}
|
||||
|
|
|
@ -18,6 +18,15 @@ import { useUserPrivileges } from '../../../../common/components/user_privileges
|
|||
|
||||
jest.mock('../../../../common/components/user_privileges');
|
||||
|
||||
const testSecuritySolutionLinkHref = 'test-url';
|
||||
jest.mock('../../../../common/components/links', () => ({
|
||||
useGetSecuritySolutionLinkProps: () => () => ({ href: testSecuritySolutionLinkHref }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const ecsRowData: Ecs = {
|
||||
_id: '1',
|
||||
agent: { type: ['blah'] },
|
||||
|
@ -83,182 +92,232 @@ const markAsOpenButton = '[data-test-subj="open-alert-status"]';
|
|||
const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]';
|
||||
const markAsClosedButton = '[data-test-subj="close-alert-status"]';
|
||||
const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]';
|
||||
const openAlertDetailsPageButton = '[data-test-subj="open-alert-details-page-menu-item"]';
|
||||
|
||||
describe('InvestigateInResolverAction', () => {
|
||||
test('it render AddToCase context menu item if timelineId === TableId.alertsOnAlertsPage', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TableId.alertsOnAlertsPage} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it render AddToCase context menu item if timelineId === TableId.alertsOnRuleDetailsPage', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...props} scopeId={TableId.alertsOnRuleDetailsPage} />,
|
||||
{
|
||||
describe('Alert table context menu', () => {
|
||||
describe('Case actions', () => {
|
||||
test('it render AddToCase context menu item if timelineId === TimelineId.detectionsPage', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TableId.alertsOnAlertsPage} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it render AddToCase context menu item if timelineId === TimelineId.active', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true);
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true);
|
||||
});
|
||||
test('it render AddToCase context menu item if timelineId === TimelineId.detectionsRulesDetailsPage', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...props} scopeId={TableId.alertsOnRuleDetailsPage} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
test('it does NOT render AddToCase context menu item when timelineId is not in the allowed list', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId="timeline-test" />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(false);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('it renders the correct status action buttons', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true);
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
test('it render AddToCase context menu item if timelineId === TimelineId.active', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
expect(wrapper.find(markAsOpenButton).first().exists()).toEqual(false);
|
||||
expect(wrapper.find(markAsAcknowledgedButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(markAsClosedButton).first().exists()).toEqual(true);
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it does NOT render AddToCase context menu item when timelineId is not in the allowed list', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId="timeline-test" />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(false);
|
||||
expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddEndpointEventFilter', () => {
|
||||
const endpointEventProps = {
|
||||
...props,
|
||||
ecsRowData: { ...ecsRowData, agent: { type: ['endpoint'] }, event: { kind: ['event'] } },
|
||||
};
|
||||
describe('Alert status actions', () => {
|
||||
test('it renders the correct status action buttons', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
describe('when users can access endpoint management', () => {
|
||||
beforeEach(() => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: true },
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
|
||||
expect(wrapper.find(markAsOpenButton).first().exists()).toEqual(false);
|
||||
expect(wrapper.find(markAsAcknowledgedButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(markAsClosedButton).first().exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Endpoint event filter actions', () => {
|
||||
describe('AddEndpointEventFilter', () => {
|
||||
const endpointEventProps = {
|
||||
...props,
|
||||
ecsRowData: { ...ecsRowData, agent: { type: ['endpoint'] }, event: { kind: ['event'] } },
|
||||
};
|
||||
|
||||
describe('when users can access endpoint management', () => {
|
||||
beforeEach(() => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is not host events page', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TimelineId.active} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
|
||||
test('it enables AddEndpointEventFilter when timeline id is host events page', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.hostsPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is host events page but is not from endpoint', () => {
|
||||
const customProps = {
|
||||
...props,
|
||||
ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } },
|
||||
};
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...customProps} scopeId={TableId.hostsPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
|
||||
test('it enables AddEndpointEventFilter when timeline id is user events page', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.usersPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is user events page but is not from endpoint', () => {
|
||||
const customProps = {
|
||||
...props,
|
||||
ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } },
|
||||
};
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...customProps} scopeId={TableId.usersPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is not host events page', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TimelineId.active} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
describe('when users can NOT access endpoint management', () => {
|
||||
beforeEach(() => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
test('it disables AddEndpointEventFilter when timeline id is host events page but cannot acces endpoint management', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.hostsPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
test('it enables AddEndpointEventFilter when timeline id is host events page', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.hostsPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(false);
|
||||
});
|
||||
test('it disables AddEndpointEventFilter when timeline id is user events page but cannot acces endpoint management', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.usersPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is host events page but is not from endpoint', () => {
|
||||
const customProps = {
|
||||
...props,
|
||||
ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } },
|
||||
};
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...customProps} scopeId={TableId.hostsPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
|
||||
test('it enables AddEndpointEventFilter when timeline id is user events page', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.usersPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(false);
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is user events page but is not from endpoint', () => {
|
||||
const customProps = {
|
||||
...props,
|
||||
ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } },
|
||||
};
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...customProps} scopeId={TableId.usersPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('when users can NOT access endpoint management', () => {
|
||||
beforeEach(() => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is host events page but cannot acces endpoint management', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.hostsPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
describe('Open alert details action', () => {
|
||||
test('it does not render the open alert details page action if kibana.alert.rule.uuid is not set', () => {
|
||||
const nonAlertProps = {
|
||||
...props,
|
||||
ecsRowData: {
|
||||
...ecsRowData,
|
||||
kibana: {
|
||||
alert: {
|
||||
workflow_status: ['open'],
|
||||
rule: {
|
||||
parameters: {},
|
||||
uuid: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
const wrapper = mount(<AlertContextMenu {...nonAlertProps} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
test('it disables AddEndpointEventFilter when timeline id is user events page but cannot acces endpoint management', () => {
|
||||
const wrapper = mount(
|
||||
<AlertContextMenu {...endpointEventProps} scopeId={TableId.usersPageEvents} />,
|
||||
{
|
||||
wrappingComponent: TestProviders,
|
||||
}
|
||||
);
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true);
|
||||
expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true);
|
||||
expect(wrapper.find(openAlertDetailsPageButton).first().exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('it renders the open alert details action button', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
|
||||
expect(wrapper.find(openAlertDetailsPageButton).first().exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -43,6 +43,7 @@ import { useEventFilterAction } from './use_event_filter_action';
|
|||
import { useAddToCaseActions } from './use_add_to_case_actions';
|
||||
import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check';
|
||||
import type { Rule } from '../../../../detection_engine/rule_management/logic/types';
|
||||
import { useOpenAlertDetailsAction } from './use_open_alert_details';
|
||||
|
||||
interface AlertContextMenuProps {
|
||||
ariaLabel?: string;
|
||||
|
@ -50,7 +51,6 @@ interface AlertContextMenuProps {
|
|||
columnValues: string;
|
||||
disabled: boolean;
|
||||
ecsRowData: Ecs;
|
||||
refetch: inputsModel.Refetch;
|
||||
onRuleChange?: () => void;
|
||||
scopeId: string;
|
||||
}
|
||||
|
@ -61,7 +61,6 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
|
|||
columnValues,
|
||||
disabled,
|
||||
ecsRowData,
|
||||
refetch,
|
||||
onRuleChange,
|
||||
scopeId,
|
||||
globalQuery,
|
||||
|
@ -75,7 +74,8 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
|
|||
setPopover(false);
|
||||
}, []);
|
||||
|
||||
const alertId = ecsRowData?.kibana?.alert ? ecsRowData?._id : null;
|
||||
const getAlertId = () => (ecsRowData?.kibana?.alert ? ecsRowData?._id : null);
|
||||
const alertId = getAlertId();
|
||||
const ruleId = get(0, ecsRowData?.kibana?.alert?.rule?.uuid);
|
||||
const ruleName = get(0, ecsRowData?.kibana?.alert?.rule?.name);
|
||||
const isInDetections = [TableId.alertsOnAlertsPage, TableId.alertsOnRuleDetailsPage].includes(
|
||||
|
@ -209,6 +209,12 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
|
|||
|
||||
const { osqueryActionItems } = useOsqueryContextActionItem({ handleClick: handleOnOsqueryClick });
|
||||
|
||||
const { alertDetailsActionItems } = useOpenAlertDetailsAction({
|
||||
alertId,
|
||||
closePopover,
|
||||
ruleId,
|
||||
});
|
||||
|
||||
const items: React.ReactElement[] = useMemo(
|
||||
() =>
|
||||
!isEvent && ruleId
|
||||
|
@ -217,6 +223,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
|
|||
...statusActionItems,
|
||||
...exceptionActionItems,
|
||||
...(agentId ? osqueryActionItems : []),
|
||||
...alertDetailsActionItems,
|
||||
]
|
||||
: [
|
||||
...addToCaseActionItems,
|
||||
|
@ -231,6 +238,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
|
|||
exceptionActionItems,
|
||||
agentId,
|
||||
osqueryActionItems,
|
||||
alertDetailsActionItems,
|
||||
eventFilterActionItems,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiContextMenuItem } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useGetSecuritySolutionLinkProps } from '../../../../common/components/links';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { getAlertDetailsUrl } from '../../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../../../common/constants';
|
||||
|
||||
interface Props {
|
||||
ruleId?: string;
|
||||
closePopover: () => void;
|
||||
alertId: string | null;
|
||||
}
|
||||
|
||||
export const ACTION_OPEN_ALERT_DETAILS_PAGE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.actions.openAlertDetails',
|
||||
{
|
||||
defaultMessage: 'Open alert details page',
|
||||
}
|
||||
);
|
||||
|
||||
export const useOpenAlertDetailsAction = ({ ruleId, closePopover, alertId }: Props) => {
|
||||
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
|
||||
const alertDetailsActionItems = [];
|
||||
const { onClick } = useGetSecuritySolutionLinkProps()({
|
||||
deepLinkId: SecurityPageName.alerts,
|
||||
path: alertId ? getAlertDetailsUrl(alertId) : '',
|
||||
});
|
||||
|
||||
// We check ruleId to confirm this is an alert, as this page does not support events as of 8.6
|
||||
if (ruleId && alertId && isAlertDetailsPageEnabled) {
|
||||
alertDetailsActionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="open-alert-details-item"
|
||||
icon="popout"
|
||||
data-test-subj="open-alert-details-page-menu-item"
|
||||
onClick={onClick}
|
||||
>
|
||||
{ACTION_OPEN_ALERT_DETAILS_PAGE}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
alertDetailsActionItems,
|
||||
};
|
||||
};
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './alert_details_response';
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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, { memo } from 'react';
|
||||
import { EuiCode, EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { ERROR_PAGE_TITLE, ERROR_PAGE_BODY } from '../translations';
|
||||
|
||||
export const AlertDetailsErrorPage = memo(({ eventId }: { eventId: string }) => {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
color="danger"
|
||||
data-test-subj="alert-details-page-error"
|
||||
iconType="alert"
|
||||
title={<h2>{ERROR_PAGE_TITLE}</h2>}
|
||||
body={
|
||||
<div>
|
||||
<p>{ERROR_PAGE_BODY}</p>
|
||||
<p>
|
||||
<EuiCode
|
||||
style={{ wordWrap: 'break-word', overflowWrap: 'break-word' }}
|
||||
>{`_id: ${eventId}`}</EuiCode>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
AlertDetailsErrorPage.displayName = 'AlertDetailsErrorPage';
|
|
@ -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 React from 'react';
|
||||
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
|
||||
import { HeaderPage } from '../../../../common/components/header_page';
|
||||
|
||||
interface AlertDetailsHeaderProps {
|
||||
loading: boolean;
|
||||
ruleName?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export const AlertDetailsHeader = React.memo(
|
||||
({ loading, ruleName, timestamp }: AlertDetailsHeaderProps) => {
|
||||
return (
|
||||
<HeaderPage
|
||||
badgeOptions={{ beta: true, text: 'Beta' }}
|
||||
border
|
||||
isLoading={loading}
|
||||
subtitle={timestamp ? <PreferenceFormattedDate value={new Date(timestamp)} /> : ''}
|
||||
title={ruleName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AlertDetailsHeader.displayName = 'AlertDetailsHeader';
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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, { memo } from 'react';
|
||||
import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import { LOADING_PAGE_MESSAGE } from '../translations';
|
||||
|
||||
export const AlertDetailsLoadingPage = memo(({ eventId }: { eventId: string }) => (
|
||||
<EuiEmptyPrompt
|
||||
data-test-subj="alert-details-page-loading"
|
||||
color="subdued"
|
||||
icon={<EuiLoadingSpinner data-test-subj="loading-spinner" size="l" />}
|
||||
body={<p>{LOADING_PAGE_MESSAGE}</p>}
|
||||
/>
|
||||
));
|
||||
|
||||
AlertDetailsLoadingPage.displayName = 'AlertDetailsLoadingPage';
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Router, useParams } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AlertDetailsPage } from '.';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import {
|
||||
mockAlertDetailsFieldsResponse,
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
} from './__mocks__';
|
||||
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
|
||||
import { useTimelineEventsDetails } from '../../../timelines/containers/details';
|
||||
|
||||
// Node modules mocks
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
(useParams as jest.Mock).mockReturnValue(mockAlertDetailsFieldsResponse._id);
|
||||
|
||||
// Internal Mocks
|
||||
jest.mock('../../../timelines/containers/details');
|
||||
jest.mock('../../../timelines/store/timeline', () => ({
|
||||
...jest.requireActual('../../../timelines/store/timeline'),
|
||||
timelineActions: {
|
||||
createTimeline: jest.fn().mockReturnValue('new-timeline'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../common/containers/sourcerer', () => {
|
||||
const mockSourcererReturn = {
|
||||
browserFields: {},
|
||||
loading: true,
|
||||
indexPattern: {},
|
||||
selectedPatterns: [],
|
||||
missingPatterns: [],
|
||||
};
|
||||
return {
|
||||
useSourcererDataView: jest.fn().mockReturnValue(mockSourcererReturn),
|
||||
};
|
||||
});
|
||||
|
||||
type Action = 'PUSH' | 'POP' | 'REPLACE';
|
||||
const pop: Action = 'POP';
|
||||
const getMockHistory = () => ({
|
||||
length: 1,
|
||||
location: {
|
||||
pathname: `/alerts/${mockAlertDetailsFieldsResponse._id}/summary`,
|
||||
search: '',
|
||||
state: '',
|
||||
hash: '',
|
||||
},
|
||||
action: pop,
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
go: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
goForward: jest.fn(),
|
||||
block: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
listen: jest.fn(),
|
||||
});
|
||||
|
||||
describe('Alert Details Page', () => {
|
||||
it('should render the loading page', () => {
|
||||
(useTimelineEventsDetails as jest.Mock).mockReturnValue([true, null, null, null, jest.fn()]);
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<Router history={getMockHistory()}>
|
||||
<AlertDetailsPage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('alert-details-page-loading')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the error page', () => {
|
||||
(useTimelineEventsDetails as jest.Mock).mockReturnValue([false, null, null, null, jest.fn()]);
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<Router history={getMockHistory()}>
|
||||
<AlertDetailsPage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('alert-details-page-error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the header', () => {
|
||||
(useTimelineEventsDetails as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertDetailsFieldsResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
jest.fn(),
|
||||
]);
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<Router history={getMockHistory()}>
|
||||
<AlertDetailsPage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('header-page-title')).toHaveTextContent(
|
||||
mockAlertDetailsFieldsResponse.fields[ALERT_RULE_NAME][0]
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a timeline', () => {
|
||||
(useTimelineEventsDetails as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertDetailsFieldsResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
jest.fn(),
|
||||
]);
|
||||
render(
|
||||
<TestProviders>
|
||||
<Router history={getMockHistory()}>
|
||||
<AlertDetailsPage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith('new-timeline');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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, { memo, useEffect, useMemo } from 'react';
|
||||
import { Switch, useParams } from 'react-router-dom';
|
||||
import { Route } from '@kbn/kibana-react-plugin/public';
|
||||
import { ALERT_RULE_NAME, TIMESTAMP } from '@kbn/rule-data-utils';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { timelineActions } from '../../../timelines/store/timeline';
|
||||
import { TimelineId } from '../../../../common/types';
|
||||
import { useGetFieldsData } from '../../../common/hooks/use_get_fields_data';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { SpyRoute } from '../../../common/utils/route/spy_routes';
|
||||
import { getAlertDetailsTabUrl } from '../../../common/components/link_to';
|
||||
import { AlertDetailRouteType } from './types';
|
||||
import { SecuritySolutionTabNavigation } from '../../../common/components/navigation';
|
||||
import { getAlertDetailsNavTabs } from './utils/navigation';
|
||||
import { SecurityPageName } from '../../../../common/constants';
|
||||
import { eventID } from '../../../../common/endpoint/models/event';
|
||||
import { useTimelineEventsDetails } from '../../../timelines/containers/details';
|
||||
import { AlertDetailsLoadingPage } from './components/loading_page';
|
||||
import { AlertDetailsErrorPage } from './components/error_page';
|
||||
import { AlertDetailsHeader } from './components/header';
|
||||
import { DetailsSummaryTab } from './tabs/summary';
|
||||
|
||||
export const AlertDetailsPage = memo(() => {
|
||||
const { detailName: eventId } = useParams<{ detailName: string }>();
|
||||
const dispatch = useDispatch();
|
||||
const sourcererDataView = useSourcererDataView(SourcererScopeName.detections);
|
||||
const indexName = useMemo(
|
||||
() => sourcererDataView.selectedPatterns.join(','),
|
||||
[sourcererDataView.selectedPatterns]
|
||||
);
|
||||
|
||||
const [loading, detailsData, searchHit, dataAsNestedObject] = useTimelineEventsDetails({
|
||||
indexName,
|
||||
eventId,
|
||||
runtimeMappings: sourcererDataView.runtimeMappings,
|
||||
skip: !eventID,
|
||||
});
|
||||
const dataNotFound = !loading && !detailsData;
|
||||
const hasData = !loading && detailsData;
|
||||
|
||||
// Example of using useGetFieldsData. Only place it is used currently
|
||||
const getFieldsData = useGetFieldsData(searchHit?.fields);
|
||||
const timestamp = getFieldsData(TIMESTAMP) as string | undefined;
|
||||
const ruleName = getFieldsData(ALERT_RULE_NAME) as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: move detail panel to it's own redux state
|
||||
dispatch(
|
||||
timelineActions.createTimeline({
|
||||
id: TimelineId.detectionsAlertDetailsPage,
|
||||
columns: [],
|
||||
dataViewId: null,
|
||||
indexNames: [],
|
||||
expandedDetail: {},
|
||||
show: false,
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <AlertDetailsLoadingPage eventId={eventId} />}
|
||||
{dataNotFound && <AlertDetailsErrorPage eventId={eventId} />}
|
||||
{hasData && (
|
||||
<>
|
||||
<AlertDetailsHeader loading={loading} ruleName={ruleName} timestamp={timestamp} />
|
||||
<SecuritySolutionTabNavigation navTabs={getAlertDetailsNavTabs(eventId)} />
|
||||
<EuiSpacer size="l" />
|
||||
<Switch>
|
||||
<Route exact path={getAlertDetailsTabUrl(eventId, AlertDetailRouteType.summary)}>
|
||||
<DetailsSummaryTab
|
||||
eventId={eventId}
|
||||
dataAsNestedObject={dataAsNestedObject}
|
||||
detailsData={detailsData}
|
||||
sourcererDataView={sourcererDataView}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
<SpyRoute pageName={SecurityPageName.alerts} state={{ ruleName }} />
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { get } from 'lodash/fp';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AlertRendererPanel } from '.';
|
||||
import { TestProviders } from '../../../../../../common/mock';
|
||||
import { mockAlertNestedDetailsTimelineResponse } from '../../../__mocks__';
|
||||
import { ALERT_RENDERER_FIELDS } from '../../../../../../timelines/components/timeline/body/renderers/alert_renderer';
|
||||
|
||||
describe('AlertDetailsPage - SummaryTab - AlertRendererPanel', () => {
|
||||
it('should render the reason renderer', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<AlertRendererPanel dataAsNestedObject={mockAlertNestedDetailsTimelineResponse} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('alert-renderer-panel')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the render the expected values', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<AlertRendererPanel dataAsNestedObject={mockAlertNestedDetailsTimelineResponse} />
|
||||
</TestProviders>
|
||||
);
|
||||
const alertRendererPanelPanel = getByTestId('alert-renderer-panel');
|
||||
|
||||
ALERT_RENDERER_FIELDS.forEach((rendererField) => {
|
||||
const fieldValues: string[] | null = get(
|
||||
rendererField,
|
||||
mockAlertNestedDetailsTimelineResponse
|
||||
);
|
||||
if (fieldValues && fieldValues.length > 0) {
|
||||
fieldValues.forEach((value) => {
|
||||
expect(alertRendererPanelPanel).toHaveTextContent(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render the reason renderer if data is not provided', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AlertRendererPanel dataAsNestedObject={null} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(queryByTestId('alert-renderer-panel')).toBeNull();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 styled from 'styled-components';
|
||||
import { defaultRowRenderers } from '../../../../../../timelines/components/timeline/body/renderers';
|
||||
import { getRowRenderer } from '../../../../../../timelines/components/timeline/body/renderers/get_row_renderer';
|
||||
import { TimelineId } from '../../../../../../../common/types';
|
||||
import { SummaryPanel } from '../wrappers';
|
||||
import { ALERT_REASON_PANEL_TITLE } from '../translation';
|
||||
import type { Ecs } from '../../../../../../../common/ecs';
|
||||
|
||||
export interface AlertRendererPanelProps {
|
||||
dataAsNestedObject: Ecs | null;
|
||||
}
|
||||
|
||||
const RendererContainer = styled.div`
|
||||
overflow-x: auto;
|
||||
|
||||
& .euiFlexGroup {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AlertRendererPanel = React.memo(({ dataAsNestedObject }: AlertRendererPanelProps) => {
|
||||
const renderer = useMemo(
|
||||
() =>
|
||||
dataAsNestedObject != null
|
||||
? getRowRenderer({ data: dataAsNestedObject, rowRenderers: defaultRowRenderers })
|
||||
: null,
|
||||
[dataAsNestedObject]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryPanel title={ALERT_REASON_PANEL_TITLE}>
|
||||
{renderer != null && dataAsNestedObject != null && (
|
||||
<div>
|
||||
<RendererContainer data-test-subj="alert-renderer-panel">
|
||||
{renderer.renderRow({
|
||||
data: dataAsNestedObject,
|
||||
isDraggable: false,
|
||||
scopeId: TimelineId.detectionsAlertDetailsPage,
|
||||
})}
|
||||
</RendererContainer>
|
||||
</div>
|
||||
)}
|
||||
</SummaryPanel>
|
||||
);
|
||||
});
|
||||
|
||||
AlertRendererPanel.displayName = 'AlertRendererPanel';
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { CasesPanel } from '.';
|
||||
import { TestProviders } from '../../../../../../common/mock';
|
||||
import {
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
} from '../../../__mocks__';
|
||||
import { ERROR_LOADING_CASES, LOADING_CASES } from '../translation';
|
||||
import { useGetRelatedCasesByEvent } from '../../../../../../common/containers/cases/use_get_related_cases_by_event';
|
||||
import { useGetUserCasesPermissions } from '../../../../../../common/lib/kibana';
|
||||
|
||||
jest.mock('../../../../../../common/containers/cases/use_get_related_cases_by_event');
|
||||
jest.mock('../../../../../../common/lib/kibana');
|
||||
|
||||
const defaultPanelProps = {
|
||||
eventId: mockAlertNestedDetailsTimelineResponse._id,
|
||||
dataAsNestedObject: mockAlertNestedDetailsTimelineResponse,
|
||||
detailsData: mockAlertDetailsTimelineResponse,
|
||||
};
|
||||
|
||||
describe('AlertDetailsPage - SummaryTab - CasesPanel', () => {
|
||||
describe('No data', () => {
|
||||
beforeEach(() => {
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
|
||||
create: true,
|
||||
update: true,
|
||||
});
|
||||
});
|
||||
it('should render the loading panel', () => {
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: true,
|
||||
});
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(getByText(LOADING_CASES)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the error panel if an error is returned', () => {
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: false,
|
||||
error: true,
|
||||
});
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByText(ERROR_LOADING_CASES)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the error panel if data is undefined', () => {
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: false,
|
||||
error: false,
|
||||
relatedCases: undefined,
|
||||
});
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByText(ERROR_LOADING_CASES)).toBeVisible();
|
||||
});
|
||||
|
||||
describe('Partial permissions', () => {
|
||||
it('should only render the add to new case button', () => {
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: false,
|
||||
relatedCases: [],
|
||||
});
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
|
||||
create: true,
|
||||
update: false,
|
||||
});
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('add-to-new-case-button')).toBeVisible();
|
||||
expect(queryByTestId('add-to-existing-case-button')).toBe(null);
|
||||
});
|
||||
|
||||
it('should only render the add to existing case button', () => {
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: false,
|
||||
relatedCases: [],
|
||||
});
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
|
||||
create: false,
|
||||
update: true,
|
||||
});
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('add-to-existing-case-button')).toBeVisible();
|
||||
expect(queryByTestId('add-to-new-case-button')).toBe(null);
|
||||
});
|
||||
|
||||
it('should render both add to new case and add to existing case buttons', () => {
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: false,
|
||||
relatedCases: [],
|
||||
});
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
|
||||
create: true,
|
||||
update: true,
|
||||
});
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('add-to-new-case-button')).toBeVisible();
|
||||
expect(queryByTestId('add-to-existing-case-button')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('has related cases', () => {
|
||||
const mockRelatedCase = {
|
||||
title: 'test case',
|
||||
id: 'test-case-id',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
|
||||
create: true,
|
||||
update: true,
|
||||
});
|
||||
(useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({
|
||||
loading: false,
|
||||
relatedCases: [mockRelatedCase],
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the related case', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<CasesPanel {...defaultPanelProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('case-panel')).toHaveTextContent(mockRelatedCase.title);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import type { CasesPermissions } from '@kbn/cases-plugin/common';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import type { CasesPanelProps } from '.';
|
||||
import {
|
||||
ADD_TO_EXISTING_CASE_BUTTON,
|
||||
ADD_TO_NEW_CASE_BUTTON,
|
||||
SUMMARY_PANEL_ACTIONS,
|
||||
} from '../translation';
|
||||
|
||||
export const CASES_PANEL_ACTIONS_CLASS = 'cases-panel-actions-trigger';
|
||||
|
||||
export interface CasesPanelActionsProps extends CasesPanelProps {
|
||||
addToNewCase: () => void;
|
||||
addToExistingCase: () => void;
|
||||
className?: string;
|
||||
userCasesPermissions: CasesPermissions;
|
||||
}
|
||||
|
||||
export const CasesPanelActions = React.memo(
|
||||
({
|
||||
addToNewCase,
|
||||
addToExistingCase,
|
||||
className,
|
||||
userCasesPermissions,
|
||||
}: CasesPanelActionsProps) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
setPopover(!isPopoverOpen);
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const closePopover = () => {
|
||||
setPopover(false);
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
const options = [];
|
||||
|
||||
if (userCasesPermissions.create) {
|
||||
options.push(
|
||||
<EuiContextMenuItem
|
||||
key="casesActionsAddToNewCase"
|
||||
onClick={addToNewCase}
|
||||
data-test-subj="cases-panel-actions-add-to-new-case"
|
||||
>
|
||||
{ADD_TO_NEW_CASE_BUTTON}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (userCasesPermissions.update) {
|
||||
options.push(
|
||||
<EuiContextMenuItem
|
||||
key="caseActionsAddToExistingCase"
|
||||
data-test-subj="cases-panel-actions-add-to-existing-case"
|
||||
onClick={addToExistingCase}
|
||||
target="_blank"
|
||||
>
|
||||
{ADD_TO_EXISTING_CASE_BUTTON}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}, [addToExistingCase, addToNewCase, userCasesPermissions.create, userCasesPermissions.update]);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiButtonIcon
|
||||
aria-label={SUMMARY_PANEL_ACTIONS}
|
||||
className={CASES_PANEL_ACTIONS_CLASS}
|
||||
iconType="boxesHorizontal"
|
||||
onClick={onButtonClick}
|
||||
/>
|
||||
),
|
||||
[onButtonClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
panelClassName="withHoverActions__popover"
|
||||
>
|
||||
<EuiContextMenuPanel data-test-subj="cases-actions-panel" size="s" items={items} />
|
||||
</EuiPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CasesPanelActions.displayName = 'CasesPanelActions';
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* 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, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingSpinner,
|
||||
} from '@elastic/eui';
|
||||
import type { Ecs } from '@kbn/cases-plugin/common';
|
||||
import { CommentType } from '@kbn/cases-plugin/common';
|
||||
import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../../../../common/search_strategy';
|
||||
import { useGetUserCasesPermissions, useKibana } from '../../../../../../common/lib/kibana';
|
||||
import { CaseDetailsLink } from '../../../../../../common/components/links';
|
||||
import { useGetRelatedCasesByEvent } from '../../../../../../common/containers/cases/use_get_related_cases_by_event';
|
||||
import {
|
||||
ADD_TO_EXISTING_CASE_BUTTON,
|
||||
ADD_TO_NEW_CASE_BUTTON,
|
||||
CASES_PANEL_TITLE,
|
||||
CASE_NO_READ_PERMISSIONS,
|
||||
ERROR_LOADING_CASES,
|
||||
LOADING_CASES,
|
||||
NO_RELATED_CASES_FOUND,
|
||||
} from '../translation';
|
||||
import { SummaryPanel } from '../wrappers';
|
||||
import { CasesPanelActions, CASES_PANEL_ACTIONS_CLASS } from './cases_panel_actions';
|
||||
|
||||
export interface CasesPanelProps {
|
||||
eventId: string;
|
||||
dataAsNestedObject: Ecs | null;
|
||||
detailsData: TimelineEventsDetailsItem[];
|
||||
}
|
||||
|
||||
const CasesPanelLoading = () => (
|
||||
<EuiEmptyPrompt
|
||||
icon={<EuiLoadingSpinner size="l" />}
|
||||
title={<h2>{LOADING_CASES}</h2>}
|
||||
titleSize="xxs"
|
||||
/>
|
||||
);
|
||||
|
||||
const CasesPanelError = () => <>{ERROR_LOADING_CASES}</>;
|
||||
|
||||
export const CasesPanelNoReadPermissions = () => <EuiEmptyPrompt body={CASE_NO_READ_PERMISSIONS} />;
|
||||
|
||||
export const CasesPanel = React.memo<CasesPanelProps>(
|
||||
({ eventId, dataAsNestedObject, detailsData }) => {
|
||||
const { cases: casesUi } = useKibana().services;
|
||||
const { loading, error, relatedCases, refetchRelatedCases } =
|
||||
useGetRelatedCasesByEvent(eventId);
|
||||
const userCasesPermissions = useGetUserCasesPermissions();
|
||||
|
||||
const caseAttachments: CaseAttachmentsWithoutOwner = useMemo(() => {
|
||||
return dataAsNestedObject
|
||||
? [
|
||||
{
|
||||
alertId: eventId,
|
||||
index: dataAsNestedObject._index ?? '',
|
||||
type: CommentType.alert,
|
||||
rule: casesUi.helpers.getRuleIdFromEvent({
|
||||
ecs: dataAsNestedObject,
|
||||
data: detailsData,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [casesUi.helpers, dataAsNestedObject, detailsData, eventId]);
|
||||
|
||||
const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({
|
||||
onSuccess: refetchRelatedCases,
|
||||
});
|
||||
|
||||
const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({
|
||||
onRowClick: refetchRelatedCases,
|
||||
});
|
||||
|
||||
const addToNewCase = useCallback(() => {
|
||||
if (userCasesPermissions.create) {
|
||||
createCaseFlyout.open({ attachments: caseAttachments });
|
||||
}
|
||||
}, [userCasesPermissions.create, createCaseFlyout, caseAttachments]);
|
||||
|
||||
const addToExistingCase = useCallback(() => {
|
||||
if (userCasesPermissions.update) {
|
||||
selectCaseModal.open({ attachments: caseAttachments });
|
||||
}
|
||||
}, [caseAttachments, selectCaseModal, userCasesPermissions.update]);
|
||||
|
||||
const renderCasesActions = useCallback(
|
||||
() => (
|
||||
<CasesPanelActions
|
||||
addToNewCase={addToNewCase}
|
||||
addToExistingCase={addToExistingCase}
|
||||
eventId={eventId}
|
||||
dataAsNestedObject={dataAsNestedObject}
|
||||
detailsData={detailsData}
|
||||
userCasesPermissions={userCasesPermissions}
|
||||
/>
|
||||
),
|
||||
[
|
||||
addToExistingCase,
|
||||
addToNewCase,
|
||||
dataAsNestedObject,
|
||||
detailsData,
|
||||
eventId,
|
||||
userCasesPermissions,
|
||||
]
|
||||
);
|
||||
|
||||
if (loading) return <CasesPanelLoading />;
|
||||
|
||||
if (error || relatedCases === undefined) return <CasesPanelError />;
|
||||
|
||||
const hasRelatedCases = relatedCases && relatedCases.length > 0;
|
||||
|
||||
return (
|
||||
<SummaryPanel
|
||||
actionsClassName={CASES_PANEL_ACTIONS_CLASS}
|
||||
title={CASES_PANEL_TITLE}
|
||||
renderActionsPopover={hasRelatedCases ? renderCasesActions : undefined}
|
||||
>
|
||||
{hasRelatedCases ? (
|
||||
<EuiFlexGroup direction="column" data-test-subj="case-panel">
|
||||
{relatedCases?.map(({ id, title }) => (
|
||||
<EuiFlexItem key={id}>
|
||||
<CaseDetailsLink detailName={id} title={title}>
|
||||
{title}
|
||||
</CaseDetailsLink>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<EuiEmptyPrompt
|
||||
iconColor="default"
|
||||
body={NO_RELATED_CASES_FOUND}
|
||||
actions={
|
||||
<EuiFlexGroup>
|
||||
{userCasesPermissions.update && (
|
||||
<EuiFlexItem>
|
||||
<EuiButton
|
||||
size="s"
|
||||
data-test-subj="add-to-existing-case-button"
|
||||
color="primary"
|
||||
onClick={addToExistingCase}
|
||||
>
|
||||
{ADD_TO_EXISTING_CASE_BUTTON}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{userCasesPermissions.create && (
|
||||
<EuiFlexItem>
|
||||
<EuiButton
|
||||
size="s"
|
||||
data-test-subj="add-to-new-case-button"
|
||||
color="primary"
|
||||
fill
|
||||
onClick={addToNewCase}
|
||||
>
|
||||
{ADD_TO_NEW_CASE_BUTTON}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SummaryPanel>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CasesPanel.displayName = 'CasesPanel';
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 {
|
||||
Threat,
|
||||
Threats,
|
||||
ThreatSubtechnique,
|
||||
} from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import { find } from 'lodash/fp';
|
||||
import {
|
||||
ALERT_THREAT_FRAMEWORK,
|
||||
ALERT_THREAT_TACTIC_ID,
|
||||
ALERT_THREAT_TACTIC_NAME,
|
||||
ALERT_THREAT_TACTIC_REFERENCE,
|
||||
ALERT_THREAT_TECHNIQUE_ID,
|
||||
ALERT_THREAT_TECHNIQUE_NAME,
|
||||
ALERT_THREAT_TECHNIQUE_REFERENCE,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME,
|
||||
ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE,
|
||||
KIBANA_NAMESPACE,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { buildThreatDescription } from '../../../../components/rules/description_step/helpers';
|
||||
|
||||
// TODO - it may make more sense to query source here for this information rather than piecing it together from the fields api
|
||||
export const getMitreTitleAndDescription = (data: TimelineEventsDetailsItem[] | null) => {
|
||||
const threatFrameworks = [
|
||||
...(find({ field: ALERT_THREAT_FRAMEWORK, category: KIBANA_NAMESPACE }, data)?.values ?? []),
|
||||
];
|
||||
|
||||
const tacticIdValues = [
|
||||
...(find({ field: ALERT_THREAT_TACTIC_ID, category: KIBANA_NAMESPACE }, data)?.values ?? []),
|
||||
];
|
||||
const tacticNameValues = [
|
||||
...(find({ field: ALERT_THREAT_TACTIC_NAME, category: KIBANA_NAMESPACE }, data)?.values ?? []),
|
||||
];
|
||||
const tacticReferenceValues = [
|
||||
...(find({ field: ALERT_THREAT_TACTIC_REFERENCE, category: KIBANA_NAMESPACE }, data)?.values ??
|
||||
[]),
|
||||
];
|
||||
|
||||
const techniqueIdValues = [
|
||||
...(find({ field: ALERT_THREAT_TECHNIQUE_ID, category: KIBANA_NAMESPACE }, data)?.values ?? []),
|
||||
];
|
||||
const techniqueNameValues = [
|
||||
...(find({ field: ALERT_THREAT_TECHNIQUE_NAME, category: KIBANA_NAMESPACE }, data)?.values ??
|
||||
[]),
|
||||
];
|
||||
const techniqueReferenceValues = [
|
||||
...(find({ field: ALERT_THREAT_TECHNIQUE_REFERENCE, category: KIBANA_NAMESPACE }, data)
|
||||
?.values ?? []),
|
||||
];
|
||||
|
||||
const subTechniqueIdValues = [
|
||||
...(find({ field: ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, category: KIBANA_NAMESPACE }, data)
|
||||
?.values ?? []),
|
||||
];
|
||||
const subTechniqueNameValues = [
|
||||
...(find({ field: ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, category: KIBANA_NAMESPACE }, data)
|
||||
?.values ?? []),
|
||||
];
|
||||
const subTechniqueReferenceValues = [
|
||||
...(find(
|
||||
{ field: ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, category: KIBANA_NAMESPACE },
|
||||
data
|
||||
)?.values ?? []),
|
||||
];
|
||||
|
||||
const threatData: Threats =
|
||||
// Use the top level framework as every threat should have a framework
|
||||
threatFrameworks?.map((framework, index) => {
|
||||
const threat: Threat = {
|
||||
framework,
|
||||
tactic: {
|
||||
id: tacticIdValues[index],
|
||||
name: tacticNameValues[index],
|
||||
reference: tacticReferenceValues[index],
|
||||
},
|
||||
technique: [],
|
||||
};
|
||||
|
||||
// TODO:
|
||||
// Fields api doesn't provide null entries to keep the same length of values for flattend objects
|
||||
// So for the time being rather than showing incorrect data, we'll only show tactic information when the length of both line up
|
||||
// We can replace this with a _source request and just pass that.
|
||||
if (tacticIdValues.length === techniqueIdValues.length) {
|
||||
const subtechnique: ThreatSubtechnique[] = [];
|
||||
const techniqueId = techniqueIdValues[index];
|
||||
subTechniqueIdValues.forEach((subId, subIndex) => {
|
||||
// TODO: see above comment. Without this matching, a subtechnique can be incorrectly matched with a higher level technique
|
||||
if (subId.includes(techniqueId)) {
|
||||
subtechnique.push({
|
||||
id: subTechniqueIdValues[subIndex],
|
||||
name: subTechniqueNameValues[subIndex],
|
||||
reference: subTechniqueReferenceValues[subIndex],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
threat.technique?.push({
|
||||
id: techniqueId,
|
||||
name: techniqueNameValues[index],
|
||||
reference: techniqueReferenceValues[index],
|
||||
subtechnique,
|
||||
});
|
||||
}
|
||||
|
||||
return threat;
|
||||
}) ?? [];
|
||||
|
||||
// TODO: discuss moving buildThreatDescription to a shared common folder
|
||||
return threatData && threatData.length > 0
|
||||
? buildThreatDescription({
|
||||
label: threatData[0].framework,
|
||||
threat: threatData,
|
||||
})
|
||||
: null;
|
||||
};
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { find } from 'lodash/fp';
|
||||
import { TestProviders } from '../../../../../../common/mock';
|
||||
import {
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
} from '../../../__mocks__';
|
||||
import type { HostPanelProps } from '.';
|
||||
import { HostPanel } from '.';
|
||||
import { mockBrowserFields } from '../../../../../../common/containers/source/mock';
|
||||
import { getTimelineEventData } from '../../../utils/get_timeline_event_data';
|
||||
import { RiskSeverity } from '../../../../../../../common/search_strategy';
|
||||
import { useRiskScore } from '../../../../../../risk_score/containers';
|
||||
|
||||
jest.mock('../../../../../../risk_score/containers');
|
||||
const mockUseRiskScore = useRiskScore as jest.Mock;
|
||||
|
||||
jest.mock('../../../../../containers/detection_engine/alerts/use_host_isolation_status', () => {
|
||||
return {
|
||||
useHostIsolationStatus: jest.fn().mockReturnValue({
|
||||
loading: false,
|
||||
isIsolated: false,
|
||||
agentStatus: 'healthy',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AlertDetailsPage - SummaryTab - HostPanel', () => {
|
||||
const defaultRiskReturnValues = {
|
||||
inspect: null,
|
||||
refetch: () => {},
|
||||
isModuleEnabled: true,
|
||||
isLicenseValid: true,
|
||||
loading: false,
|
||||
};
|
||||
const HostPanelWithDefaultProps = (propOverrides: Partial<HostPanelProps>) => (
|
||||
<TestProviders>
|
||||
<HostPanel
|
||||
data={mockAlertDetailsTimelineResponse}
|
||||
openHostDetailsPanel={jest.fn}
|
||||
id={mockAlertNestedDetailsTimelineResponse._id}
|
||||
browserFields={mockBrowserFields}
|
||||
selectedPatterns={['random-pattern']}
|
||||
{...propOverrides}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseRiskScore.mockReturnValue({ ...defaultRiskReturnValues });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render basic host fields', () => {
|
||||
const { getByTestId } = render(<HostPanelWithDefaultProps />);
|
||||
const simpleHostFields = ['host.name', 'host.os.name'];
|
||||
|
||||
simpleHostFields.forEach((simpleHostField) => {
|
||||
expect(getByTestId('host-panel')).toHaveTextContent(
|
||||
getTimelineEventData(simpleHostField, mockAlertDetailsTimelineResponse)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent status', () => {
|
||||
it('should show healthy', () => {
|
||||
const { getByTestId } = render(<HostPanelWithDefaultProps />);
|
||||
expect(getByTestId('host-panel-agent-status')).toHaveTextContent('Healthy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('host risk', () => {
|
||||
it('should not show risk if the license is not valid', () => {
|
||||
mockUseRiskScore.mockReturnValue({
|
||||
...defaultRiskReturnValues,
|
||||
isLicenseValid: false,
|
||||
data: null,
|
||||
});
|
||||
const { queryByTestId } = render(<HostPanelWithDefaultProps />);
|
||||
expect(queryByTestId('host-panel-risk')).toBe(null);
|
||||
});
|
||||
|
||||
it('should render risk fields', () => {
|
||||
const calculatedScoreNorm = 98.9;
|
||||
const calculatedLevel = RiskSeverity.critical;
|
||||
|
||||
mockUseRiskScore.mockReturnValue({
|
||||
...defaultRiskReturnValues,
|
||||
isLicenseValid: true,
|
||||
data: [
|
||||
{
|
||||
host: {
|
||||
name: mockAlertNestedDetailsTimelineResponse.host?.name,
|
||||
risk: {
|
||||
calculated_score_norm: calculatedScoreNorm,
|
||||
calculated_level: calculatedLevel,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { getByTestId } = render(<HostPanelWithDefaultProps />);
|
||||
|
||||
expect(getByTestId('host-panel-risk')).toHaveTextContent(
|
||||
`${Math.round(calculatedScoreNorm)}`
|
||||
);
|
||||
expect(getByTestId('host-panel-risk')).toHaveTextContent(calculatedLevel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host ip', () => {
|
||||
it('should render all the ip fields', () => {
|
||||
const { getByTestId } = render(<HostPanelWithDefaultProps />);
|
||||
const ipFields = find(
|
||||
{ field: 'host.ip', category: 'host' },
|
||||
mockAlertDetailsTimelineResponse
|
||||
)?.values as string[];
|
||||
expect(getByTestId('host-panel-ip')).toHaveTextContent(ipFields[0]);
|
||||
expect(getByTestId('host-panel-ip')).toHaveTextContent('+1 More');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { SecurityPageName } from '../../../../../../app/types';
|
||||
import { useGetSecuritySolutionLinkProps } from '../../../../../../common/components/links';
|
||||
import { getHostDetailsUrl } from '../../../../../../common/components/link_to';
|
||||
|
||||
import { OPEN_HOST_DETAILS_PAGE, SUMMARY_PANEL_ACTIONS, VIEW_HOST_SUMMARY } from '../translation';
|
||||
|
||||
export const HOST_PANEL_ACTIONS_CLASS = 'host-panel-actions-trigger';
|
||||
|
||||
export const HostPanelActions = React.memo(
|
||||
({
|
||||
className,
|
||||
openHostDetailsPanel,
|
||||
hostName,
|
||||
}: {
|
||||
className?: string;
|
||||
hostName: string;
|
||||
openHostDetailsPanel: (hostName: string) => void;
|
||||
}) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
const { href } = useGetSecuritySolutionLinkProps()({
|
||||
deepLinkId: SecurityPageName.hosts,
|
||||
path: getHostDetailsUrl(hostName),
|
||||
});
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
setPopover(!isPopoverOpen);
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const closePopover = () => {
|
||||
setPopover(false);
|
||||
};
|
||||
|
||||
const handleOpenHostDetailsPanel = useCallback(() => {
|
||||
openHostDetailsPanel(hostName);
|
||||
closePopover();
|
||||
}, [hostName, openHostDetailsPanel]);
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
<EuiContextMenuItem
|
||||
icon="expand"
|
||||
key="hostActionsViewHostSummary"
|
||||
onClick={handleOpenHostDetailsPanel}
|
||||
data-test-subj="host-panel-actions-view-summary"
|
||||
>
|
||||
{VIEW_HOST_SUMMARY}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="popout"
|
||||
key="hostActionsOpenHostDetailsPage"
|
||||
data-test-subj="host-panel-actions-open-host-details"
|
||||
onClick={closePopover}
|
||||
href={href}
|
||||
target="_blank"
|
||||
>
|
||||
{OPEN_HOST_DETAILS_PAGE}
|
||||
</EuiContextMenuItem>,
|
||||
],
|
||||
[handleOpenHostDetailsPanel, href]
|
||||
);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiButtonIcon
|
||||
aria-label={SUMMARY_PANEL_ACTIONS}
|
||||
className={HOST_PANEL_ACTIONS_CLASS}
|
||||
iconType="boxesHorizontal"
|
||||
onClick={onButtonClick}
|
||||
/>
|
||||
),
|
||||
[onButtonClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
panelClassName="withHoverActions__popover"
|
||||
>
|
||||
<EuiContextMenuPanel data-test-subj="host-actions-panel" size="s" items={items} />
|
||||
</EuiPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HostPanelActions.displayName = 'HostPanelActions';
|
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
* 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 { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { find } from 'lodash/fp';
|
||||
import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item';
|
||||
import { TimelineId } from '../../../../../../../common/types';
|
||||
import { isAlertFromEndpointEvent } from '../../../../../../common/utils/endpoint_alert_check';
|
||||
import { SummaryValueCell } from '../../../../../../common/components/event_details/table/summary_value_cell';
|
||||
import { useRiskScore } from '../../../../../../risk_score/containers';
|
||||
import { RiskScoreEntity } from '../../../../../../../common/search_strategy';
|
||||
import { getEmptyTagValue } from '../../../../../../common/components/empty_value';
|
||||
import { RiskScore } from '../../../../../../common/components/severity/common';
|
||||
import {
|
||||
FirstLastSeen,
|
||||
FirstLastSeenType,
|
||||
} from '../../../../../../common/components/first_last_seen';
|
||||
import { DefaultFieldRenderer } from '../../../../../../timelines/components/field_renderers/field_renderers';
|
||||
import { HostDetailsLink, NetworkDetailsLink } from '../../../../../../common/components/links';
|
||||
import type { SelectedDataView } from '../../../../../../common/store/sourcerer/model';
|
||||
import { getEnrichedFieldInfo } from '../../../../../../common/components/event_details/helpers';
|
||||
import { getTimelineEventData } from '../../../utils/get_timeline_event_data';
|
||||
import {
|
||||
AGENT_STATUS_TITLE,
|
||||
HOST_NAME_TITLE,
|
||||
HOST_PANEL_TITLE,
|
||||
HOST_RISK_CLASSIFICATION,
|
||||
HOST_RISK_SCORE,
|
||||
IP_ADDRESSES_TITLE,
|
||||
LAST_SEEN_TITLE,
|
||||
OPERATING_SYSTEM_TITLE,
|
||||
} from '../translation';
|
||||
import { SummaryPanel } from '../wrappers';
|
||||
import { HostPanelActions, HOST_PANEL_ACTIONS_CLASS } from './host_panel_actions';
|
||||
|
||||
export interface HostPanelProps {
|
||||
data: TimelineEventsDetailsItem[];
|
||||
id: string;
|
||||
openHostDetailsPanel: (hostName: string, onClose?: (() => void) | undefined) => void;
|
||||
selectedPatterns: SelectedDataView['selectedPatterns'];
|
||||
browserFields: SelectedDataView['browserFields'];
|
||||
}
|
||||
|
||||
const HostPanelSection: React.FC<{
|
||||
title?: string | React.ReactElement;
|
||||
grow?: FlexItemGrowSize;
|
||||
}> = ({ grow, title, children }) =>
|
||||
children ? (
|
||||
<EuiFlexItem grow={grow}>
|
||||
{title && (
|
||||
<>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>{title}</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</EuiFlexItem>
|
||||
) : null;
|
||||
|
||||
export const HostPanel = React.memo(
|
||||
({ data, id, browserFields, openHostDetailsPanel, selectedPatterns }: HostPanelProps) => {
|
||||
const hostName = getTimelineEventData('host.name', data);
|
||||
const hostOs = getTimelineEventData('host.os.name', data);
|
||||
|
||||
const enrichedAgentStatus = useMemo(() => {
|
||||
const item = find({ field: 'agent.id', category: 'agent' }, data);
|
||||
if (!data || !isAlertFromEndpointEvent({ data })) return null;
|
||||
return (
|
||||
item &&
|
||||
getEnrichedFieldInfo({
|
||||
eventId: id,
|
||||
contextId: TimelineId.detectionsAlertDetailsPage,
|
||||
scopeId: TimelineId.detectionsAlertDetailsPage,
|
||||
browserFields,
|
||||
item,
|
||||
field: { id: 'agent.id', overrideField: 'agent.status' },
|
||||
linkValueField: undefined,
|
||||
})
|
||||
);
|
||||
}, [browserFields, data, id]);
|
||||
|
||||
const { data: hostRisk, isLicenseValid: isRiskLicenseValid } = useRiskScore({
|
||||
riskEntity: RiskScoreEntity.host,
|
||||
skip: hostName == null,
|
||||
});
|
||||
|
||||
const [hostRiskScore, hostRiskLevel] = useMemo(() => {
|
||||
const hostRiskData = hostRisk && hostRisk.length > 0 ? hostRisk[0] : undefined;
|
||||
const hostRiskValue = hostRiskData
|
||||
? Math.round(hostRiskData.host.risk.calculated_score_norm)
|
||||
: getEmptyTagValue();
|
||||
const hostRiskSeverity = hostRiskData ? (
|
||||
<RiskScore severity={hostRiskData.host.risk.calculated_level} hideBackgroundColor />
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
);
|
||||
|
||||
return [hostRiskValue, hostRiskSeverity];
|
||||
}, [hostRisk]);
|
||||
|
||||
const hostIpFields = useMemo(
|
||||
() => find({ field: 'host.ip', category: 'host' }, data)?.values ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
const renderHostIp = useCallback(
|
||||
(ip: string) => (ip != null ? <NetworkDetailsLink ip={ip} /> : getEmptyTagValue()),
|
||||
[]
|
||||
);
|
||||
|
||||
const renderHostActions = useCallback(
|
||||
() => <HostPanelActions openHostDetailsPanel={openHostDetailsPanel} hostName={hostName} />,
|
||||
[hostName, openHostDetailsPanel]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryPanel
|
||||
actionsClassName={HOST_PANEL_ACTIONS_CLASS}
|
||||
grow
|
||||
renderActionsPopover={hostName ? renderHostActions : undefined}
|
||||
title={HOST_PANEL_TITLE}
|
||||
>
|
||||
<EuiFlexGroup data-test-subj="host-panel">
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<HostPanelSection grow={false}>
|
||||
<EuiIcon type="storage" size="xl" />
|
||||
</HostPanelSection>
|
||||
<HostPanelSection title={HOST_NAME_TITLE}>
|
||||
<HostDetailsLink hostName={hostName} />
|
||||
</HostPanelSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup data-test-subj="host-panel-agent-status">
|
||||
<HostPanelSection title={OPERATING_SYSTEM_TITLE}>{hostOs}</HostPanelSection>
|
||||
{enrichedAgentStatus && (
|
||||
<HostPanelSection title={AGENT_STATUS_TITLE}>
|
||||
<SummaryValueCell {...enrichedAgentStatus} />
|
||||
</HostPanelSection>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
{isRiskLicenseValid && (
|
||||
<>
|
||||
<EuiFlexGroup data-test-subj="host-panel-risk">
|
||||
{hostRiskScore && (
|
||||
<HostPanelSection title={HOST_RISK_SCORE}>{hostRiskScore}</HostPanelSection>
|
||||
)}
|
||||
{hostRiskLevel && (
|
||||
<HostPanelSection title={HOST_RISK_CLASSIFICATION}>
|
||||
{hostRiskLevel}
|
||||
</HostPanelSection>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
</>
|
||||
)}
|
||||
<EuiFlexGroup data-test-subj="host-panel-ip">
|
||||
<HostPanelSection title={IP_ADDRESSES_TITLE} grow={2}>
|
||||
<DefaultFieldRenderer
|
||||
rowItems={hostIpFields}
|
||||
attrName={'host.ip'}
|
||||
idPrefix="alert-details-page-user"
|
||||
render={renderHostIp}
|
||||
/>
|
||||
</HostPanelSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup>
|
||||
<HostPanelSection title={LAST_SEEN_TITLE} grow={2}>
|
||||
<FirstLastSeen
|
||||
indexPatterns={selectedPatterns}
|
||||
field={'host.name'}
|
||||
value={hostName}
|
||||
type={FirstLastSeenType.LAST_SEEN}
|
||||
/>
|
||||
</HostPanelSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</SummaryPanel>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HostPanel.displayName = 'HostPanel';
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { TimelineId } from '../../../../../../common/types';
|
||||
import { useDetailPanel } from '../../../../../timelines/components/side_panel/hooks/use_detail_panel';
|
||||
import { useGetUserCasesPermissions } from '../../../../../common/lib/kibana';
|
||||
import type { SelectedDataView } from '../../../../../common/store/sourcerer/model';
|
||||
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
|
||||
import type { Ecs } from '../../../../../../common/ecs';
|
||||
import { AlertRendererPanel } from './alert_renderer_panel';
|
||||
import { RulePanel } from './rule_panel';
|
||||
import { CasesPanel, CasesPanelNoReadPermissions } from './cases_panel';
|
||||
import { HostPanel } from './host_panel';
|
||||
import { UserPanel } from './user_panel';
|
||||
import { SummaryColumn, SummaryRow } from './wrappers';
|
||||
|
||||
export interface DetailsSummaryTabProps {
|
||||
eventId: string;
|
||||
dataAsNestedObject: Ecs | null;
|
||||
detailsData: TimelineEventsDetailsItem[];
|
||||
sourcererDataView: SelectedDataView;
|
||||
}
|
||||
|
||||
export const DetailsSummaryTab = React.memo(
|
||||
({ dataAsNestedObject, detailsData, eventId, sourcererDataView }: DetailsSummaryTabProps) => {
|
||||
const userCasesPermissions = useGetUserCasesPermissions();
|
||||
|
||||
const { DetailsPanel, openHostDetailsPanel, openUserDetailsPanel } = useDetailPanel({
|
||||
isFlyoutView: true,
|
||||
sourcererScope: SourcererScopeName.detections,
|
||||
scopeId: TimelineId.detectionsAlertDetailsPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup data-test-subj="alert-details-page-summary-tab" direction="row" wrap>
|
||||
<SummaryColumn grow={2}>
|
||||
<AlertRendererPanel dataAsNestedObject={dataAsNestedObject} />
|
||||
<RulePanel
|
||||
id={eventId}
|
||||
data={detailsData}
|
||||
browserFields={sourcererDataView.browserFields}
|
||||
/>
|
||||
<SummaryRow>
|
||||
<HostPanel
|
||||
id={eventId}
|
||||
data={detailsData}
|
||||
openHostDetailsPanel={openHostDetailsPanel}
|
||||
selectedPatterns={sourcererDataView.selectedPatterns}
|
||||
browserFields={sourcererDataView.browserFields}
|
||||
/>
|
||||
<UserPanel
|
||||
data={detailsData}
|
||||
selectedPatterns={sourcererDataView.selectedPatterns}
|
||||
openUserDetailsPanel={openUserDetailsPanel}
|
||||
/>
|
||||
</SummaryRow>
|
||||
</SummaryColumn>
|
||||
<SummaryColumn grow={1}>
|
||||
{userCasesPermissions.read ? (
|
||||
<CasesPanel
|
||||
eventId={eventId}
|
||||
dataAsNestedObject={dataAsNestedObject}
|
||||
detailsData={detailsData}
|
||||
/>
|
||||
) : (
|
||||
<CasesPanelNoReadPermissions />
|
||||
)}
|
||||
</SummaryColumn>
|
||||
</EuiFlexGroup>
|
||||
{DetailsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DetailsSummaryTab.displayName = 'DetailsSummaryTab';
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* 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 { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { find } from 'lodash/fp';
|
||||
import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item';
|
||||
import {
|
||||
ALERT_RISK_SCORE,
|
||||
ALERT_RULE_DESCRIPTION,
|
||||
ALERT_RULE_NAME,
|
||||
ALERT_RULE_UUID,
|
||||
ALERT_SEVERITY,
|
||||
KIBANA_NAMESPACE,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { TimelineId } from '../../../../../../../common/types';
|
||||
import { SeverityBadge } from '../../../../../components/rules/severity_badge';
|
||||
import { getEnrichedFieldInfo } from '../../../../../../common/components/event_details/helpers';
|
||||
import type { SelectedDataView } from '../../../../../../common/store/sourcerer/model';
|
||||
import { FormattedFieldValue } from '../../../../../../timelines/components/timeline/body/renderers/formatted_field';
|
||||
import {
|
||||
RISK_SCORE_TITLE,
|
||||
RULE_DESCRIPTION_TITLE,
|
||||
RULE_NAME_TITLE,
|
||||
RULE_PANEL_TITLE,
|
||||
SEVERITY_TITLE,
|
||||
} from '../translation';
|
||||
import { getMitreTitleAndDescription } from '../get_mitre_threat_component';
|
||||
import { getTimelineEventData } from '../../../utils/get_timeline_event_data';
|
||||
import { SummaryPanel } from '../wrappers';
|
||||
import { RulePanelActions, RULE_PANEL_ACTIONS_CLASS } from './rule_panel_actions';
|
||||
|
||||
export interface RulePanelProps {
|
||||
data: TimelineEventsDetailsItem[];
|
||||
id: string;
|
||||
browserFields: SelectedDataView['browserFields'];
|
||||
}
|
||||
|
||||
const threatTacticContainerStyles = css`
|
||||
flex-wrap: nowrap;
|
||||
& .euiFlexGroup {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
interface RuleSectionProps {
|
||||
['data-test-subj']?: string;
|
||||
title: string;
|
||||
grow?: FlexItemGrowSize;
|
||||
}
|
||||
const RuleSection: React.FC<RuleSectionProps> = ({
|
||||
grow,
|
||||
title,
|
||||
children,
|
||||
'data-test-subj': dataTestSubj,
|
||||
}) => (
|
||||
<EuiFlexItem grow={grow} data-test-subj={dataTestSubj}>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>{title}</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
{children}
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
||||
export const RulePanel = React.memo(({ data, id, browserFields }: RulePanelProps) => {
|
||||
const ruleUuid = useMemo(() => getTimelineEventData(ALERT_RULE_UUID, data), [data]);
|
||||
const threatDetails = useMemo(() => getMitreTitleAndDescription(data), [data]);
|
||||
const alertRiskScore = useMemo(() => getTimelineEventData(ALERT_RISK_SCORE, data), [data]);
|
||||
const alertSeverity = useMemo(
|
||||
() => getTimelineEventData(ALERT_SEVERITY, data) as Severity,
|
||||
[data]
|
||||
);
|
||||
const alertRuleDescription = useMemo(
|
||||
() => getTimelineEventData(ALERT_RULE_DESCRIPTION, data),
|
||||
[data]
|
||||
);
|
||||
const shouldShowThreatDetails = !!threatDetails && threatDetails?.length > 0;
|
||||
|
||||
const renderRuleActions = useCallback(() => <RulePanelActions ruleUuid={ruleUuid} />, [ruleUuid]);
|
||||
const ruleNameData = useMemo(() => {
|
||||
const item = find({ field: ALERT_RULE_NAME, category: KIBANA_NAMESPACE }, data);
|
||||
const linkValueField = find({ field: ALERT_RULE_UUID, category: KIBANA_NAMESPACE }, data);
|
||||
return (
|
||||
item &&
|
||||
getEnrichedFieldInfo({
|
||||
eventId: id,
|
||||
contextId: TimelineId.detectionsAlertDetailsPage,
|
||||
scopeId: TimelineId.detectionsAlertDetailsPage,
|
||||
browserFields,
|
||||
item,
|
||||
linkValueField,
|
||||
})
|
||||
);
|
||||
}, [browserFields, data, id]);
|
||||
|
||||
return (
|
||||
<SummaryPanel
|
||||
actionsClassName={RULE_PANEL_ACTIONS_CLASS}
|
||||
renderActionsPopover={renderRuleActions}
|
||||
title={RULE_PANEL_TITLE}
|
||||
>
|
||||
<EuiFlexGroup data-test-subj="rule-panel">
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<RuleSection title={RULE_NAME_TITLE} grow={2}>
|
||||
<FormattedFieldValue
|
||||
contextId={TimelineId.active}
|
||||
eventId={id}
|
||||
value={ruleNameData?.values?.[0]}
|
||||
fieldName={ruleNameData?.data.field ?? ''}
|
||||
linkValue={ruleNameData?.linkValue}
|
||||
fieldType={ruleNameData?.data.type}
|
||||
fieldFormat={ruleNameData?.data.format}
|
||||
isDraggable={false}
|
||||
truncate={false}
|
||||
/>
|
||||
</RuleSection>
|
||||
<RuleSection title={RISK_SCORE_TITLE}>{alertRiskScore}</RuleSection>
|
||||
<RuleSection data-test-subj="rule-panel-severity" title={SEVERITY_TITLE}>
|
||||
<SeverityBadge value={alertSeverity} />
|
||||
</RuleSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup>
|
||||
<RuleSection title={RULE_DESCRIPTION_TITLE} grow={2}>
|
||||
{alertRuleDescription}
|
||||
</RuleSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup
|
||||
data-test-subj="rule-panel-threat-tactic"
|
||||
wrap={false}
|
||||
css={threatTacticContainerStyles}
|
||||
>
|
||||
{shouldShowThreatDetails && (
|
||||
<RuleSection title={threatDetails[0].title as string} grow={2}>
|
||||
{threatDetails[0].description}
|
||||
</RuleSection>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</SummaryPanel>
|
||||
);
|
||||
});
|
||||
|
||||
RulePanel.displayName = 'RulePanel';
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ALERT_RISK_SCORE, ALERT_RULE_DESCRIPTION, ALERT_RULE_NAME } from '@kbn/rule-data-utils';
|
||||
import { TestProviders } from '../../../../../../common/mock';
|
||||
import {
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
} from '../../../__mocks__';
|
||||
import type { RulePanelProps } from '.';
|
||||
import { RulePanel } from '.';
|
||||
import { getTimelineEventData } from '../../../utils/get_timeline_event_data';
|
||||
import { mockBrowserFields } from '../../../../../../common/containers/source/mock';
|
||||
|
||||
describe('AlertDetailsPage - SummaryTab - RulePanel', () => {
|
||||
const RulePanelWithDefaultProps = (propOverrides: Partial<RulePanelProps>) => (
|
||||
<TestProviders>
|
||||
<RulePanel
|
||||
data={mockAlertDetailsTimelineResponse}
|
||||
id={mockAlertNestedDetailsTimelineResponse._id}
|
||||
browserFields={mockBrowserFields}
|
||||
{...propOverrides}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
it('should render basic rule fields', () => {
|
||||
const { getByTestId } = render(<RulePanelWithDefaultProps />);
|
||||
const simpleRuleFields = [ALERT_RISK_SCORE, ALERT_RULE_DESCRIPTION];
|
||||
|
||||
simpleRuleFields.forEach((simpleRuleField) => {
|
||||
expect(getByTestId('rule-panel')).toHaveTextContent(
|
||||
getTimelineEventData(simpleRuleField, mockAlertDetailsTimelineResponse)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the expected severity', () => {
|
||||
const { getByTestId } = render(<RulePanelWithDefaultProps />);
|
||||
expect(getByTestId('rule-panel-severity')).toHaveTextContent('Medium');
|
||||
});
|
||||
|
||||
describe('Rule name link', () => {
|
||||
it('should render the rule name as a link button', () => {
|
||||
const { getByTestId } = render(<RulePanelWithDefaultProps />);
|
||||
const ruleName = getTimelineEventData(ALERT_RULE_NAME, mockAlertDetailsTimelineResponse);
|
||||
expect(getByTestId('ruleName')).toHaveTextContent(ruleName);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { getRuleDetailsUrl } from '../../../../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../../../../app/types';
|
||||
import { useGetSecuritySolutionLinkProps } from '../../../../../../common/components/links';
|
||||
|
||||
import { SUMMARY_PANEL_ACTIONS, OPEN_RULE_DETAILS_PAGE } from '../translation';
|
||||
|
||||
export const RULE_PANEL_ACTIONS_CLASS = 'rule-panel-actions-trigger';
|
||||
|
||||
export const RulePanelActions = React.memo(
|
||||
({ className, ruleUuid }: { className?: string; ruleUuid: string }) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
const { href } = useGetSecuritySolutionLinkProps()({
|
||||
deepLinkId: SecurityPageName.rules,
|
||||
path: getRuleDetailsUrl(ruleUuid),
|
||||
});
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
setPopover(!isPopoverOpen);
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const closePopover = () => {
|
||||
setPopover(false);
|
||||
};
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
<EuiContextMenuItem
|
||||
icon="popout"
|
||||
key="ruleActionsOpenRuleDetailsPage"
|
||||
data-test-subj="rule-panel-actions-open-rule-details"
|
||||
onClick={closePopover}
|
||||
href={href}
|
||||
target="_blank"
|
||||
>
|
||||
{OPEN_RULE_DETAILS_PAGE}
|
||||
</EuiContextMenuItem>,
|
||||
],
|
||||
[href]
|
||||
);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiButtonIcon
|
||||
aria-label={SUMMARY_PANEL_ACTIONS}
|
||||
className={RULE_PANEL_ACTIONS_CLASS}
|
||||
iconType="boxesHorizontal"
|
||||
onClick={onButtonClick}
|
||||
/>
|
||||
),
|
||||
[onButtonClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
panelClassName="withHoverActions__popover"
|
||||
>
|
||||
<EuiContextMenuPanel data-test-subj="rule-actions-panel" size="s" items={items} />
|
||||
</EuiPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
RulePanelActions.displayName = 'RulePanelActions';
|
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CASES_PANEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.cases.title',
|
||||
{
|
||||
defaultMessage: 'Cases',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_REASON_PANEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.alertReason.title',
|
||||
{
|
||||
defaultMessage: 'Alert reason',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_PANEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.rule.title',
|
||||
{
|
||||
defaultMessage: 'Rule',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_PANEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.title',
|
||||
{
|
||||
defaultMessage: 'Host',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_PANEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.user.title',
|
||||
{
|
||||
defaultMessage: 'User',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_NAME_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.rule.name',
|
||||
{
|
||||
defaultMessage: 'Rule name',
|
||||
}
|
||||
);
|
||||
|
||||
export const RISK_SCORE_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.rule.riskScore',
|
||||
{
|
||||
defaultMessage: 'Risk score',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.rule.severity',
|
||||
{
|
||||
defaultMessage: 'Severity',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_DESCRIPTION_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.rule.description',
|
||||
{
|
||||
defaultMessage: 'Rule description',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPEN_RULE_DETAILS_PAGE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.rule.action.openRuleDetailsPage',
|
||||
{
|
||||
defaultMessage: 'Open rule details page',
|
||||
}
|
||||
);
|
||||
|
||||
export const NO_RELATED_CASES_FOUND = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.case.noCasesFound',
|
||||
{
|
||||
defaultMessage: 'Related cases were not found for this alert',
|
||||
}
|
||||
);
|
||||
|
||||
export const LOADING_CASES = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.case.loading',
|
||||
{
|
||||
defaultMessage: 'Loading related cases...',
|
||||
}
|
||||
);
|
||||
|
||||
export const ERROR_LOADING_CASES = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.case.error',
|
||||
{
|
||||
defaultMessage: 'Error loading related cases',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASE_NO_READ_PERMISSIONS = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.case.noRead',
|
||||
{
|
||||
defaultMessage:
|
||||
'You do not have the required permissions to view related cases. If you need to view cases, contact your Kibana administrator',
|
||||
}
|
||||
);
|
||||
|
||||
export const ADD_TO_EXISTING_CASE_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.case.addToExistingCase',
|
||||
{
|
||||
defaultMessage: 'Add to existing case',
|
||||
}
|
||||
);
|
||||
|
||||
export const ADD_TO_NEW_CASE_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.case.addToNewCase',
|
||||
{
|
||||
defaultMessage: 'Add to new case',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_NAME_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.hostName.title',
|
||||
{
|
||||
defaultMessage: 'Host name',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPERATING_SYSTEM_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.osName.title',
|
||||
{
|
||||
defaultMessage: 'Operating system',
|
||||
}
|
||||
);
|
||||
|
||||
export const AGENT_STATUS_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.agentStatus.title',
|
||||
{
|
||||
defaultMessage: 'Agent status',
|
||||
}
|
||||
);
|
||||
|
||||
export const IP_ADDRESSES_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.ipAddresses.title',
|
||||
{
|
||||
defaultMessage: 'IP addresses',
|
||||
}
|
||||
);
|
||||
|
||||
export const LAST_SEEN_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.lastSeen.title',
|
||||
{
|
||||
defaultMessage: 'Last seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const VIEW_HOST_SUMMARY = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.action.viewHostSummary',
|
||||
{
|
||||
defaultMessage: 'View host summary',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPEN_HOST_DETAILS_PAGE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.action.openHostDetailsPage',
|
||||
{
|
||||
defaultMessage: 'Open host details page',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_RISK_SCORE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.riskScore',
|
||||
{
|
||||
defaultMessage: 'Host risk score',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_RISK_CLASSIFICATION = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.host.riskClassification',
|
||||
{
|
||||
defaultMessage: 'Host risk classification',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_NAME_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.user.userName.title',
|
||||
{
|
||||
defaultMessage: 'User name',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_RISK_SCORE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.user.riskScore',
|
||||
{
|
||||
defaultMessage: 'User risk score',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_RISK_CLASSIFICATION = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.user.riskClassification',
|
||||
{
|
||||
defaultMessage: 'User risk classification',
|
||||
}
|
||||
);
|
||||
|
||||
export const VIEW_USER_SUMMARY = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.user.action.viewUserSummary',
|
||||
{
|
||||
defaultMessage: 'View user summary',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPEN_USER_DETAILS_PAGE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.user.action.openUserDetailsPage',
|
||||
{
|
||||
defaultMessage: 'Open user details page',
|
||||
}
|
||||
);
|
||||
|
||||
export const SUMMARY_PANEL_ACTIONS = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.summary.panelMoreActions',
|
||||
{
|
||||
defaultMessage: 'More actions',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* 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 { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { find } from 'lodash/fp';
|
||||
import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item';
|
||||
import { useRiskScore } from '../../../../../../risk_score/containers';
|
||||
import { RiskScoreEntity } from '../../../../../../../common/search_strategy';
|
||||
import { getEmptyTagValue } from '../../../../../../common/components/empty_value';
|
||||
import { RiskScore } from '../../../../../../common/components/severity/common';
|
||||
import {
|
||||
FirstLastSeen,
|
||||
FirstLastSeenType,
|
||||
} from '../../../../../../common/components/first_last_seen';
|
||||
import { DefaultFieldRenderer } from '../../../../../../timelines/components/field_renderers/field_renderers';
|
||||
import { NetworkDetailsLink, UserDetailsLink } from '../../../../../../common/components/links';
|
||||
import type { SelectedDataView } from '../../../../../../common/store/sourcerer/model';
|
||||
import { getTimelineEventData } from '../../../utils/get_timeline_event_data';
|
||||
import {
|
||||
IP_ADDRESSES_TITLE,
|
||||
LAST_SEEN_TITLE,
|
||||
USER_NAME_TITLE,
|
||||
USER_PANEL_TITLE,
|
||||
USER_RISK_CLASSIFICATION,
|
||||
USER_RISK_SCORE,
|
||||
} from '../translation';
|
||||
import { SummaryPanel } from '../wrappers';
|
||||
import { UserPanelActions, USER_PANEL_ACTIONS_CLASS } from './user_panel_actions';
|
||||
|
||||
export interface UserPanelProps {
|
||||
data: TimelineEventsDetailsItem[] | null;
|
||||
selectedPatterns: SelectedDataView['selectedPatterns'];
|
||||
openUserDetailsPanel: (userName: string, onClose?: (() => void) | undefined) => void;
|
||||
}
|
||||
|
||||
const UserPanelSection: React.FC<{
|
||||
title?: string | React.ReactElement;
|
||||
grow?: FlexItemGrowSize;
|
||||
}> = ({ grow, title, children }) =>
|
||||
children ? (
|
||||
<EuiFlexItem grow={grow}>
|
||||
{title && (
|
||||
<>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>{title}</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</EuiFlexItem>
|
||||
) : null;
|
||||
|
||||
export const UserPanel = React.memo(
|
||||
({ data, selectedPatterns, openUserDetailsPanel }: UserPanelProps) => {
|
||||
const userName = useMemo(() => getTimelineEventData('user.name', data), [data]);
|
||||
|
||||
const { data: userRisk, isLicenseValid: isRiskLicenseValid } = useRiskScore({
|
||||
riskEntity: RiskScoreEntity.user,
|
||||
skip: userName == null,
|
||||
});
|
||||
|
||||
const renderUserActions = useCallback(
|
||||
() => <UserPanelActions openUserDetailsPanel={openUserDetailsPanel} userName={userName} />,
|
||||
[openUserDetailsPanel, userName]
|
||||
);
|
||||
|
||||
const [userRiskScore, userRiskLevel] = useMemo(() => {
|
||||
const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined;
|
||||
const userRiskValue = userRiskData
|
||||
? Math.round(userRiskData.user.risk.calculated_score_norm)
|
||||
: getEmptyTagValue();
|
||||
const userRiskSeverity = userRiskData ? (
|
||||
<RiskScore severity={userRiskData.user.risk.calculated_level} hideBackgroundColor />
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
);
|
||||
|
||||
return [userRiskValue, userRiskSeverity];
|
||||
}, [userRisk]);
|
||||
|
||||
const sourceIpFields = useMemo(
|
||||
() => find({ field: 'source.ip', category: 'source' }, data)?.values ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
const renderSourceIp = useCallback(
|
||||
(ip: string) => (ip != null ? <NetworkDetailsLink ip={ip} /> : getEmptyTagValue()),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryPanel
|
||||
actionsClassName={USER_PANEL_ACTIONS_CLASS}
|
||||
grow
|
||||
renderActionsPopover={userName ? renderUserActions : undefined}
|
||||
title={USER_PANEL_TITLE}
|
||||
>
|
||||
<EuiFlexGroup data-test-subj="user-panel">
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<UserPanelSection grow={false}>
|
||||
<EuiIcon type="userAvatar" size="xl" />
|
||||
</UserPanelSection>
|
||||
<UserPanelSection title={USER_NAME_TITLE}>
|
||||
{userName ? <UserDetailsLink userName={userName} /> : getEmptyTagValue()}
|
||||
</UserPanelSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
{isRiskLicenseValid && (
|
||||
<>
|
||||
<EuiFlexGroup data-test-subj="user-panel-risk">
|
||||
{userRiskScore && (
|
||||
<UserPanelSection title={USER_RISK_SCORE}>{userRiskScore}</UserPanelSection>
|
||||
)}
|
||||
{userRiskLevel && (
|
||||
<UserPanelSection title={USER_RISK_CLASSIFICATION}>
|
||||
{userRiskLevel}
|
||||
</UserPanelSection>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
</>
|
||||
)}
|
||||
<EuiFlexGroup data-test-subj="user-panel-ip">
|
||||
<UserPanelSection title={IP_ADDRESSES_TITLE} grow={2}>
|
||||
<DefaultFieldRenderer
|
||||
rowItems={sourceIpFields}
|
||||
attrName={'source.ip'}
|
||||
idPrefix="alert-details-page-user"
|
||||
render={renderSourceIp}
|
||||
/>
|
||||
</UserPanelSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup>
|
||||
<UserPanelSection title={LAST_SEEN_TITLE} grow={2}>
|
||||
<FirstLastSeen
|
||||
indexPatterns={selectedPatterns}
|
||||
field={'user.name'}
|
||||
value={userName}
|
||||
type={FirstLastSeenType.LAST_SEEN}
|
||||
/>
|
||||
</UserPanelSection>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</SummaryPanel>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UserPanel.displayName = 'UserPanel';
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { TestProviders } from '../../../../../../common/mock';
|
||||
import {
|
||||
mockAlertDetailsTimelineResponse,
|
||||
mockAlertNestedDetailsTimelineResponse,
|
||||
} from '../../../__mocks__';
|
||||
import type { UserPanelProps } from '.';
|
||||
import { UserPanel } from '.';
|
||||
import { getTimelineEventData } from '../../../utils/get_timeline_event_data';
|
||||
import { RiskSeverity } from '../../../../../../../common/search_strategy';
|
||||
import { useRiskScore } from '../../../../../../risk_score/containers';
|
||||
import { find } from 'lodash/fp';
|
||||
|
||||
jest.mock('../../../../../../risk_score/containers');
|
||||
const mockUseRiskScore = useRiskScore as jest.Mock;
|
||||
|
||||
describe('AlertDetailsPage - SummaryTab - UserPanel', () => {
|
||||
const defaultRiskReturnValues = {
|
||||
inspect: null,
|
||||
refetch: () => {},
|
||||
isModuleEnabled: true,
|
||||
isLicenseValid: true,
|
||||
loading: false,
|
||||
};
|
||||
const UserPanelWithDefaultProps = (propOverrides: Partial<UserPanelProps>) => (
|
||||
<TestProviders>
|
||||
<UserPanel
|
||||
openUserDetailsPanel={jest.fn}
|
||||
data={mockAlertDetailsTimelineResponse}
|
||||
selectedPatterns={['random-pattern']}
|
||||
{...propOverrides}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseRiskScore.mockReturnValue({ ...defaultRiskReturnValues });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render basic user fields', () => {
|
||||
const { getByTestId } = render(<UserPanelWithDefaultProps />);
|
||||
const simpleUserFields = ['user.name'];
|
||||
|
||||
simpleUserFields.forEach((simpleUserField) => {
|
||||
expect(getByTestId('user-panel')).toHaveTextContent(
|
||||
getTimelineEventData(simpleUserField, mockAlertDetailsTimelineResponse)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user risk', () => {
|
||||
it('should not show risk if the license is not valid', () => {
|
||||
mockUseRiskScore.mockReturnValue({
|
||||
...defaultRiskReturnValues,
|
||||
isLicenseValid: false,
|
||||
data: null,
|
||||
});
|
||||
const { queryByTestId } = render(<UserPanelWithDefaultProps />);
|
||||
expect(queryByTestId('user-panel-risk')).toBe(null);
|
||||
});
|
||||
|
||||
it('should render risk fields', () => {
|
||||
const calculatedScoreNorm = 98.9;
|
||||
const calculatedLevel = RiskSeverity.critical;
|
||||
|
||||
mockUseRiskScore.mockReturnValue({
|
||||
...defaultRiskReturnValues,
|
||||
isLicenseValid: true,
|
||||
data: [
|
||||
{
|
||||
user: {
|
||||
name: mockAlertNestedDetailsTimelineResponse.user?.name,
|
||||
risk: {
|
||||
calculated_score_norm: calculatedScoreNorm,
|
||||
calculated_level: calculatedLevel,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { getByTestId } = render(<UserPanelWithDefaultProps />);
|
||||
|
||||
expect(getByTestId('user-panel-risk')).toHaveTextContent(
|
||||
`${Math.round(calculatedScoreNorm)}`
|
||||
);
|
||||
expect(getByTestId('user-panel-risk')).toHaveTextContent(calculatedLevel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('source ip', () => {
|
||||
it('should render all the ip fields', () => {
|
||||
const { getByTestId } = render(<UserPanelWithDefaultProps />);
|
||||
const ipFields = find(
|
||||
{ field: 'source.ip', category: 'source' },
|
||||
mockAlertDetailsTimelineResponse
|
||||
)?.values as string[];
|
||||
expect(getByTestId('user-panel-ip')).toHaveTextContent(ipFields[0]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { getUsersDetailsUrl } from '../../../../../../common/components/link_to/redirect_to_users';
|
||||
import { SecurityPageName } from '../../../../../../app/types';
|
||||
import { useGetSecuritySolutionLinkProps } from '../../../../../../common/components/links';
|
||||
|
||||
import { OPEN_USER_DETAILS_PAGE, SUMMARY_PANEL_ACTIONS, VIEW_USER_SUMMARY } from '../translation';
|
||||
|
||||
export const USER_PANEL_ACTIONS_CLASS = 'user-panel-actions-trigger';
|
||||
|
||||
export const UserPanelActions = React.memo(
|
||||
({
|
||||
className,
|
||||
openUserDetailsPanel,
|
||||
userName,
|
||||
}: {
|
||||
className?: string;
|
||||
userName: string;
|
||||
openUserDetailsPanel: (userName: string) => void;
|
||||
}) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
const { href } = useGetSecuritySolutionLinkProps()({
|
||||
deepLinkId: SecurityPageName.users,
|
||||
path: getUsersDetailsUrl(userName),
|
||||
});
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
setPopover(!isPopoverOpen);
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const closePopover = () => {
|
||||
setPopover(false);
|
||||
};
|
||||
|
||||
const handleopenUserDetailsPanel = useCallback(() => {
|
||||
openUserDetailsPanel(userName);
|
||||
closePopover();
|
||||
}, [userName, openUserDetailsPanel]);
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
<EuiContextMenuItem
|
||||
icon="expand"
|
||||
key="userActionsViewUserSummary"
|
||||
onClick={handleopenUserDetailsPanel}
|
||||
data-test-subj="user-panel-actions-view-summary"
|
||||
>
|
||||
{VIEW_USER_SUMMARY}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="popout"
|
||||
key="userActionsOpenUserDetailsPage"
|
||||
data-test-subj="user-panel-actions-open-user-details"
|
||||
onClick={closePopover}
|
||||
href={href}
|
||||
target="_blank"
|
||||
>
|
||||
{OPEN_USER_DETAILS_PAGE}
|
||||
</EuiContextMenuItem>,
|
||||
],
|
||||
[handleopenUserDetailsPanel, href]
|
||||
);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiButtonIcon
|
||||
aria-label={SUMMARY_PANEL_ACTIONS}
|
||||
className={USER_PANEL_ACTIONS_CLASS}
|
||||
iconType="boxesHorizontal"
|
||||
onClick={onButtonClick}
|
||||
/>
|
||||
),
|
||||
[onButtonClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
panelClassName="withHoverActions__popover"
|
||||
>
|
||||
<EuiContextMenuPanel data-test-subj="user-actions-panel" size="s" items={items} />
|
||||
</EuiPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UserPanelActions.displayName = 'UserPanelActions';
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item';
|
||||
import { HoverVisibilityContainer } from '../../../../../common/components/hover_visibility_container';
|
||||
|
||||
export const SummaryColumn: React.FC<{ grow?: FlexItemGrowSize }> = ({ children, grow }) => (
|
||||
<EuiFlexItem grow={grow}>
|
||||
<EuiFlexGroup
|
||||
direction="column"
|
||||
wrap={false}
|
||||
css={css`
|
||||
flex-wrap: nowrap;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
||||
export const SummaryRow: React.FC<{ grow?: FlexItemGrowSize }> = ({ children, grow }) => (
|
||||
<EuiFlexItem grow={grow}>
|
||||
<EuiFlexGroup direction="row" wrap>
|
||||
{children}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
||||
export const SummaryPanel: React.FC<{
|
||||
grow?: FlexItemGrowSize;
|
||||
title: string;
|
||||
actionsClassName?: string;
|
||||
renderActionsPopover?: () => JSX.Element;
|
||||
}> = ({ actionsClassName, children, grow = false, renderActionsPopover, title }) => (
|
||||
<EuiFlexItem grow={grow}>
|
||||
<EuiPanel hasShadow={false} hasBorder>
|
||||
<HoverVisibilityContainer targetClassNames={[actionsClassName ?? '']}>
|
||||
<EuiFlexGroup direction="row" justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<h3>{title}</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{actionsClassName && renderActionsPopover ? (
|
||||
<EuiFlexItem grow={false}>{renderActionsPopover()}</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
</HoverVisibilityContainer>
|
||||
<EuiSpacer size="l" />
|
||||
{children}
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
);
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const SUMMARY_PAGE_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.navigation.summary',
|
||||
{
|
||||
defaultMessage: 'Summary',
|
||||
}
|
||||
);
|
||||
|
||||
export const BACK_TO_ALERTS_LINK = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.header.backToAlerts',
|
||||
{
|
||||
defaultMessage: 'Back to alerts',
|
||||
}
|
||||
);
|
||||
|
||||
export const LOADING_PAGE_MESSAGE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.loadingPage.message',
|
||||
{
|
||||
defaultMessage: 'Loading details page...',
|
||||
}
|
||||
);
|
||||
|
||||
export const ERROR_PAGE_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.errorPage.title',
|
||||
{
|
||||
defaultMessage: 'Unable to load the details page',
|
||||
}
|
||||
);
|
||||
|
||||
export const ERROR_PAGE_BODY = i18n.translate(
|
||||
'xpack.securitySolution.alerts.alertDetails.errorPage.message',
|
||||
{
|
||||
defaultMessage:
|
||||
'There was an error loading the details page. Please confirm the following id points to a valid document',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { NavTab } from '../../../common/components/navigation/types';
|
||||
|
||||
export enum AlertDetailRouteType {
|
||||
summary = 'summary',
|
||||
}
|
||||
|
||||
export type AlertDetailNavTabs = Record<`${AlertDetailRouteType}`, NavTab>;
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 { ChromeBreadcrumb } from '@kbn/core/public';
|
||||
import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to';
|
||||
import { getAlertDetailsUrl } from '../../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../../../common/constants';
|
||||
import type { AlertDetailRouteSpyState } from '../../../../common/utils/route/types';
|
||||
import { AlertDetailRouteType } from '../types';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
const TabNameMappedToI18nKey: Record<AlertDetailRouteType, string> = {
|
||||
[AlertDetailRouteType.summary]: i18n.SUMMARY_PAGE_TITLE,
|
||||
};
|
||||
|
||||
export const getTrailingBreadcrumbs = (
|
||||
params: AlertDetailRouteSpyState,
|
||||
getSecuritySolutionUrl: GetSecuritySolutionUrl
|
||||
): ChromeBreadcrumb[] => {
|
||||
let breadcrumb: ChromeBreadcrumb[] = [];
|
||||
|
||||
if (params.detailName != null) {
|
||||
breadcrumb = [
|
||||
...breadcrumb,
|
||||
{
|
||||
text: params.state?.ruleName ?? params.detailName,
|
||||
href: getSecuritySolutionUrl({
|
||||
path: getAlertDetailsUrl(params.detailName, ''),
|
||||
deepLinkId: SecurityPageName.alerts,
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (params.tabName != null) {
|
||||
breadcrumb = [
|
||||
...breadcrumb,
|
||||
{
|
||||
text: TabNameMappedToI18nKey[params.tabName],
|
||||
href: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
return breadcrumb;
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 { TimelineEventsDetailsItem } from '../../../../../common/search_strategy';
|
||||
|
||||
export const getTimelineEventData = (field: string, data: TimelineEventsDetailsItem[] | null) => {
|
||||
const valueArray = data?.find((datum) => datum.field === field)?.values;
|
||||
return valueArray && valueArray.length > 0 ? valueArray[0] : '';
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { AlertDetailNavTabs } from '../types';
|
||||
import { ALERTS_PATH } from '../../../../../common/constants';
|
||||
import { AlertDetailRouteType } from '../types';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
export const getAlertDetailsTabUrl = (alertId: string, tabName: AlertDetailRouteType) =>
|
||||
`${ALERTS_PATH}/${alertId}/${tabName}`;
|
||||
|
||||
export const getAlertDetailsNavTabs = (alertId: string): AlertDetailNavTabs => ({
|
||||
[AlertDetailRouteType.summary]: {
|
||||
id: AlertDetailRouteType.summary,
|
||||
name: i18n.SUMMARY_PAGE_TITLE,
|
||||
href: getAlertDetailsTabUrl(alertId, AlertDetailRouteType.summary),
|
||||
disabled: false,
|
||||
},
|
||||
});
|
|
@ -6,16 +6,20 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Switch } from 'react-router-dom';
|
||||
import { Redirect, Switch } from 'react-router-dom';
|
||||
import { Route } from '@kbn/kibana-react-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 { 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';
|
||||
|
||||
const AlertsRoute = () => (
|
||||
<TrackApplicationView viewId={SecurityPageName.alerts}>
|
||||
|
@ -24,12 +28,40 @@ const AlertsRoute = () => (
|
|||
</TrackApplicationView>
|
||||
);
|
||||
|
||||
const AlertDetailsRoute = () => (
|
||||
<TrackApplicationView viewId={SecurityPageName.alerts}>
|
||||
<AlertDetailsPage />
|
||||
</TrackApplicationView>
|
||||
);
|
||||
|
||||
const AlertsContainerComponent: React.FC = () => {
|
||||
useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP);
|
||||
|
||||
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={ALERTS_PATH} exact component={AlertsRoute} />
|
||||
{isAlertDetailsPageEnabled && (
|
||||
<>
|
||||
{/* 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} />
|
||||
</Switch>
|
||||
);
|
||||
|
|
|
@ -168,6 +168,14 @@ export const isDetectionsPath = (pathname: string): boolean => {
|
|||
});
|
||||
};
|
||||
|
||||
export const isAlertDetailsPage = (pathname: string): boolean => {
|
||||
return !!matchPath(pathname, {
|
||||
path: `${ALERTS_PATH}/:detailName/:tabName`,
|
||||
strict: false,
|
||||
exact: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const isThreatIntelligencePath = (pathname: string): boolean => {
|
||||
return !!matchPath(pathname, {
|
||||
path: `(${THREAT_INTELLIGENCE_PATH})`,
|
||||
|
|
|
@ -18,6 +18,12 @@ import {
|
|||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { getAlertDetailsUrl } from '../../../../common/components/link_to';
|
||||
import {
|
||||
SecuritySolutionLinkAnchor,
|
||||
useGetSecuritySolutionLinkProps,
|
||||
} from '../../../../common/components/links';
|
||||
import type { Ecs } from '../../../../../common/ecs';
|
||||
import type { TimelineTabs } from '../../../../../common/types/timeline';
|
||||
import type { BrowserFields } from '../../../../common/containers/source';
|
||||
|
@ -25,6 +31,7 @@ import { EventDetails } from '../../../../common/components/event_details/event_
|
|||
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
|
||||
import * as i18n from './translations';
|
||||
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
|
||||
import { SecurityPageName } from '../../../../../common/constants';
|
||||
|
||||
export type HandleOnEventClosed = () => void;
|
||||
interface Props {
|
||||
|
@ -44,6 +51,7 @@ interface Props {
|
|||
}
|
||||
|
||||
interface ExpandableEventTitleProps {
|
||||
eventId: string;
|
||||
isAlert: boolean;
|
||||
loading: boolean;
|
||||
ruleName?: string;
|
||||
|
@ -68,31 +76,50 @@ const StyledEuiFlexItem = styled(EuiFlexItem)`
|
|||
`;
|
||||
|
||||
export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
||||
({ isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => (
|
||||
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{!loading && (
|
||||
<>
|
||||
<EuiTitle size="s">
|
||||
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}</h4>
|
||||
</EuiTitle>
|
||||
{timestamp && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<PreferenceFormattedDate value={new Date(timestamp)} />
|
||||
</>
|
||||
)}
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
{handleOnEventClosed && (
|
||||
({ eventId, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => {
|
||||
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
|
||||
const { onClick } = useGetSecuritySolutionLinkProps()({
|
||||
deepLinkId: SecurityPageName.alerts,
|
||||
path: eventId && isAlert ? getAlertDetailsUrl(eventId) : '',
|
||||
});
|
||||
return (
|
||||
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon iconType="cross" aria-label={i18n.CLOSE} onClick={handleOnEventClosed} />
|
||||
{!loading && (
|
||||
<>
|
||||
<EuiTitle size="s">
|
||||
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}</h4>
|
||||
</EuiTitle>
|
||||
{timestamp && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<PreferenceFormattedDate value={new Date(timestamp)} />
|
||||
</>
|
||||
)}
|
||||
{isAlert && eventId && isAlertDetailsPageEnabled && (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<SecuritySolutionLinkAnchor
|
||||
data-test-subj="open-alert-details-page"
|
||||
deepLinkId={SecurityPageName.alerts}
|
||||
onClick={onClick}
|
||||
>
|
||||
{i18n.OPEN_ALERT_DETAILS_PAGE}
|
||||
</SecuritySolutionLinkAnchor>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</StyledEuiFlexGroup>
|
||||
)
|
||||
{handleOnEventClosed && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon iconType="cross" aria-label={i18n.CLOSE} onClick={handleOnEventClosed} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</StyledEuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ExpandableEventTitle.displayName = 'ExpandableEventTitle';
|
||||
|
|
|
@ -12,6 +12,7 @@ import { ExpandableEventTitle } from '../expandable_event';
|
|||
import { BackToAlertDetailsLink } from './back_to_alert_details_link';
|
||||
|
||||
interface FlyoutHeaderComponentProps {
|
||||
eventId: string;
|
||||
isAlert: boolean;
|
||||
isHostIsolationPanelOpen: boolean;
|
||||
isolateAction: 'isolateHost' | 'unisolateHost';
|
||||
|
@ -22,6 +23,7 @@ interface FlyoutHeaderComponentProps {
|
|||
}
|
||||
|
||||
const FlyoutHeaderContentComponent = ({
|
||||
eventId,
|
||||
isAlert,
|
||||
isHostIsolationPanelOpen,
|
||||
isolateAction,
|
||||
|
@ -36,6 +38,7 @@ const FlyoutHeaderContentComponent = ({
|
|||
<BackToAlertDetailsLink isolateAction={isolateAction} showAlertDetails={showAlertDetails} />
|
||||
) : (
|
||||
<ExpandableEventTitle
|
||||
eventId={eventId}
|
||||
isAlert={isAlert}
|
||||
loading={loading}
|
||||
ruleName={ruleName}
|
||||
|
@ -48,6 +51,7 @@ const FlyoutHeaderContentComponent = ({
|
|||
const FlyoutHeaderContent = React.memo(FlyoutHeaderContentComponent);
|
||||
|
||||
const FlyoutHeaderComponent = ({
|
||||
eventId,
|
||||
isAlert,
|
||||
isHostIsolationPanelOpen,
|
||||
isolateAction,
|
||||
|
@ -59,6 +63,7 @@ const FlyoutHeaderComponent = ({
|
|||
return (
|
||||
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
|
||||
<FlyoutHeaderContentComponent
|
||||
eventId={eventId}
|
||||
isAlert={isAlert}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
isolateAction={isolateAction}
|
||||
|
|
|
@ -107,6 +107,7 @@ export const useToGetInternalFlyout = () => {
|
|||
<FlyoutHeaderContent
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
isAlert={isAlert}
|
||||
eventId={alertId}
|
||||
isolateAction={isolateAction}
|
||||
loading={isLoading || loading}
|
||||
ruleName={ruleName}
|
||||
|
@ -117,6 +118,7 @@ export const useToGetInternalFlyout = () => {
|
|||
},
|
||||
[
|
||||
isAlert,
|
||||
alertId,
|
||||
isHostIsolationPanelOpen,
|
||||
isolateAction,
|
||||
loading,
|
||||
|
|
|
@ -80,6 +80,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
() =>
|
||||
isFlyoutView || isHostIsolationPanelOpen ? (
|
||||
<FlyoutHeader
|
||||
eventId={expandedEvent.eventId}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
isAlert={isAlert}
|
||||
isolateAction={isolateAction}
|
||||
|
@ -90,6 +91,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
/>
|
||||
) : (
|
||||
<ExpandableEventTitle
|
||||
eventId={expandedEvent.eventId}
|
||||
isAlert={isAlert}
|
||||
loading={loading}
|
||||
ruleName={ruleName}
|
||||
|
@ -97,6 +99,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
/>
|
||||
),
|
||||
[
|
||||
expandedEvent.eventId,
|
||||
handleOnEventClosed,
|
||||
isAlert,
|
||||
isFlyoutView,
|
||||
|
|
|
@ -14,6 +14,13 @@ export const MESSAGE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const OPEN_ALERT_DETAILS_PAGE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.expandableEvent.openAlertDetails',
|
||||
{
|
||||
defaultMessage: 'Open alert details page',
|
||||
}
|
||||
);
|
||||
|
||||
export const CLOSE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel',
|
||||
{
|
||||
|
|
|
@ -11,6 +11,7 @@ import { timelineActions } from '../../../store/timeline';
|
|||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { TimelineId, TimelineTabs } from '../../../../../common/types';
|
||||
import { FlowTargetSourceDest } from '../../../../../common/search_strategy';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
@ -51,71 +52,237 @@ describe('useDetailPanel', () => {
|
|||
(useDeepEqualSelector as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
test('should return openDetailsPanel fn, handleOnDetailsPanelClosed fn, shouldShowDetailsPanel, and the DetailsPanel component', async () => {
|
||||
test('should return open fns (event, host, network, user), handleOnDetailsPanelClosed fn, shouldShowDetailsPanel, and the DetailsPanel component', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.openDetailsPanel).toBeDefined();
|
||||
expect(result.current.openEventDetailsPanel).toBeDefined();
|
||||
expect(result.current.openHostDetailsPanel).toBeDefined();
|
||||
expect(result.current.openNetworkDetailsPanel).toBeDefined();
|
||||
expect(result.current.openUserDetailsPanel).toBeDefined();
|
||||
expect(result.current.handleOnDetailsPanelClosed).toBeDefined();
|
||||
expect(result.current.shouldShowDetailsPanel).toBe(false);
|
||||
expect(result.current.DetailsPanel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('should fire redux action to open details panel', async () => {
|
||||
describe('open event details', () => {
|
||||
const testEventId = '123';
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
test('should fire redux action to open event details panel', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current?.openEventDetailsPanel(testEventId);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).toHaveBeenCalled();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
result.current?.openDetailsPanel(testEventId);
|
||||
test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).toHaveBeenCalled();
|
||||
const mockOnClose = jest.fn();
|
||||
result.current?.openEventDetailsPanel(testEventId, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
// Test that the onClose ref is properly updated
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const secondMockOnClose = jest.fn();
|
||||
result.current?.openEventDetailsPanel(testEventId, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
result.current?.openEventDetailsPanel(testEventId, secondMockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(secondMockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should call provided onClose callback provided to openDetailsPanel fn', async () => {
|
||||
const testEventId = '123';
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
describe('open host details', () => {
|
||||
const hostName = 'my-host';
|
||||
test('should fire redux action to open host details panel', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current?.openHostDetailsPanel(hostName);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).toHaveBeenCalled();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
result.current?.openDetailsPanel(testEventId, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
const mockOnClose = jest.fn();
|
||||
result.current?.openHostDetailsPanel(hostName, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
// Test that the onClose ref is properly updated
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const secondMockOnClose = jest.fn();
|
||||
result.current?.openHostDetailsPanel(hostName, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
result.current?.openEventDetailsPanel(hostName, secondMockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(secondMockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should call the last onClose callback provided to openDetailsPanel fn', async () => {
|
||||
// Test that the onClose ref is properly updated
|
||||
const testEventId = '123';
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
describe('open network details', () => {
|
||||
const ip = '1.2.3.4';
|
||||
const flowTarget = FlowTargetSourceDest.source;
|
||||
test('should fire redux action to open host details panel', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current?.openNetworkDetailsPanel(ip, flowTarget);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).toHaveBeenCalled();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const secondMockOnClose = jest.fn();
|
||||
result.current?.openDetailsPanel(testEventId, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
const mockOnClose = jest.fn();
|
||||
result.current?.openNetworkDetailsPanel(ip, flowTarget, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
result.current?.openDetailsPanel(testEventId, secondMockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
expect(secondMockOnClose).toHaveBeenCalled();
|
||||
test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
// Test that the onClose ref is properly updated
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const secondMockOnClose = jest.fn();
|
||||
result.current?.openNetworkDetailsPanel(ip, flowTarget, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
result.current?.openNetworkDetailsPanel(ip, flowTarget, secondMockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(secondMockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('open user details', () => {
|
||||
const userName = 'IAmBatman';
|
||||
test('should fire redux action to open host details panel', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current?.openUserDetailsPanel(userName);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
result.current?.openUserDetailsPanel(userName, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => {
|
||||
// Test that the onClose ref is properly updated
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
return useDetailPanel(defaultProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const secondMockOnClose = jest.fn();
|
||||
result.current?.openUserDetailsPanel(userName, mockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
result.current?.openUserDetailsPanel(userName, secondMockOnClose);
|
||||
result.current?.handleOnDetailsPanelClosed();
|
||||
|
||||
expect(secondMockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -9,12 +9,13 @@ import React, { useMemo, useCallback, useRef } from 'react';
|
|||
import { useDispatch } from 'react-redux';
|
||||
import type { EntityType } from '@kbn/timelines-plugin/common';
|
||||
import { getScopedActions, isInTableScope, isTimelineScope } from '../../../../helpers';
|
||||
import type { FlowTargetSourceDest } from '../../../../../common/search_strategy';
|
||||
import { timelineSelectors } from '../../../store/timeline';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import type { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { activeTimeline } from '../../../containers/active_timeline_context';
|
||||
import type { TimelineTabs } from '../../../../../common/types/timeline';
|
||||
import { TimelineId } from '../../../../../common/types/timeline';
|
||||
import type { TimelineExpandedDetailType } from '../../../../../common/types/timeline';
|
||||
import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline';
|
||||
import { timelineDefaults } from '../../../store/timeline/defaults';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { DetailsPanel as DetailsPanelComponent } from '..';
|
||||
|
@ -28,7 +29,14 @@ export interface UseDetailPanelConfig {
|
|||
tabType?: TimelineTabs;
|
||||
}
|
||||
export interface UseDetailPanelReturn {
|
||||
openDetailsPanel: (eventId?: string, onClose?: () => void) => void;
|
||||
openEventDetailsPanel: (eventId?: string, onClose?: () => void) => void;
|
||||
openHostDetailsPanel: (hostName: string, onClose?: () => void) => void;
|
||||
openNetworkDetailsPanel: (
|
||||
ip: string,
|
||||
flowTarget: FlowTargetSourceDest,
|
||||
onClose?: () => void
|
||||
) => void;
|
||||
openUserDetailsPanel: (userName: string, onClose?: () => void) => void;
|
||||
handleOnDetailsPanelClosed: () => void;
|
||||
DetailsPanel: JSX.Element | null;
|
||||
shouldShowDetailsPanel: boolean;
|
||||
|
@ -39,7 +47,7 @@ export const useDetailPanel = ({
|
|||
isFlyoutView,
|
||||
sourcererScope,
|
||||
scopeId,
|
||||
tabType,
|
||||
tabType = TimelineTabs.query,
|
||||
}: UseDetailPanelConfig): UseDetailPanelReturn => {
|
||||
const { browserFields, selectedPatterns, runtimeMappings } = useSourcererDataView(sourcererScope);
|
||||
const dispatch = useDispatch();
|
||||
|
@ -50,11 +58,13 @@ export const useDetailPanel = ({
|
|||
return dataTableSelectors.getTableByIdSelector();
|
||||
}
|
||||
}, [scopeId]);
|
||||
const eventDetailsIndex = useMemo(() => selectedPatterns.join(','), [selectedPatterns]);
|
||||
|
||||
const expandedDetail = useDeepEqualSelector(
|
||||
(state) => ((getScope && getScope(state, scopeId)) ?? timelineDefaults)?.expandedDetail
|
||||
);
|
||||
const onPanelClose = useRef(() => {});
|
||||
const noopPanelClose = () => {};
|
||||
|
||||
const shouldShowDetailsPanel = useMemo(() => {
|
||||
if (
|
||||
|
@ -68,31 +78,59 @@ export const useDetailPanel = ({
|
|||
return false;
|
||||
}, [expandedDetail, tabType]);
|
||||
const scopedActions = getScopedActions(scopeId);
|
||||
|
||||
// We could just surface load details panel, but rather than have users be concerned
|
||||
// of the config for a panel, they can just pass the base necessary values to a panel specific function
|
||||
const loadDetailsPanel = useCallback(
|
||||
(eventId?: string) => {
|
||||
if (eventId && scopedActions) {
|
||||
(panelConfig?: TimelineExpandedDetailType) => {
|
||||
if (panelConfig && scopedActions) {
|
||||
if (isTimelineScope(scopeId)) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
panelView: 'eventDetail',
|
||||
...panelConfig,
|
||||
tabType,
|
||||
id: scopeId,
|
||||
params: {
|
||||
eventId,
|
||||
indexName: selectedPatterns.join(','),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[scopedActions, scopeId, dispatch, tabType, selectedPatterns]
|
||||
[scopedActions, scopeId, dispatch, tabType]
|
||||
);
|
||||
|
||||
const openDetailsPanel = useCallback(
|
||||
const openEventDetailsPanel = useCallback(
|
||||
(eventId?: string, onClose?: () => void) => {
|
||||
loadDetailsPanel(eventId);
|
||||
onPanelClose.current = onClose ?? (() => {});
|
||||
if (eventId) {
|
||||
loadDetailsPanel({
|
||||
panelView: 'eventDetail',
|
||||
params: { eventId, indexName: eventDetailsIndex },
|
||||
});
|
||||
}
|
||||
onPanelClose.current = onClose ?? noopPanelClose;
|
||||
},
|
||||
[loadDetailsPanel, eventDetailsIndex]
|
||||
);
|
||||
|
||||
const openHostDetailsPanel = useCallback(
|
||||
(hostName: string, onClose?: () => void) => {
|
||||
loadDetailsPanel({ panelView: 'hostDetail', params: { hostName } });
|
||||
onPanelClose.current = onClose ?? noopPanelClose;
|
||||
},
|
||||
[loadDetailsPanel]
|
||||
);
|
||||
|
||||
const openNetworkDetailsPanel = useCallback(
|
||||
(ip: string, flowTarget: FlowTargetSourceDest, onClose?: () => void) => {
|
||||
loadDetailsPanel({ panelView: 'networkDetail', params: { ip, flowTarget } });
|
||||
onPanelClose.current = onClose ?? noopPanelClose;
|
||||
},
|
||||
[loadDetailsPanel]
|
||||
);
|
||||
|
||||
const openUserDetailsPanel = useCallback(
|
||||
(userName: string, onClose?: () => void) => {
|
||||
loadDetailsPanel({ panelView: 'userDetail', params: { userName } });
|
||||
onPanelClose.current = onClose ?? noopPanelClose;
|
||||
},
|
||||
[loadDetailsPanel]
|
||||
);
|
||||
|
@ -139,7 +177,10 @@ export const useDetailPanel = ({
|
|||
);
|
||||
|
||||
return {
|
||||
openDetailsPanel,
|
||||
openEventDetailsPanel,
|
||||
openHostDetailsPanel,
|
||||
openNetworkDetailsPanel,
|
||||
openUserDetailsPanel,
|
||||
handleOnDetailsPanelClosed,
|
||||
shouldShowDetailsPanel,
|
||||
DetailsPanel,
|
||||
|
|
|
@ -60,6 +60,9 @@ jest.mock('../../../../../common/lib/kibana', () => {
|
|||
addSuccess: jest.fn(),
|
||||
addWarning: jest.fn(),
|
||||
}),
|
||||
useNavigateTo: jest.fn().mockReturnValue({
|
||||
navigateTo: jest.fn(),
|
||||
}),
|
||||
useGetUserCasesPermissions: originalKibanaLib.useGetUserCasesPermissions,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
|
||||
import { noop } from 'lodash/fp';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public';
|
||||
|
@ -65,7 +64,6 @@ const ActionsComponent: React.FC<ActionProps> = ({
|
|||
onEventDetailsPanelOpened,
|
||||
onRowSelected,
|
||||
onRuleChange,
|
||||
refetch,
|
||||
showCheckboxes,
|
||||
showNotes,
|
||||
timelineId,
|
||||
|
@ -298,7 +296,6 @@ const ActionsComponent: React.FC<ActionProps> = ({
|
|||
ecsRowData={ecsData}
|
||||
scopeId={timelineId}
|
||||
disabled={isContextMenuDisabled}
|
||||
refetch={refetch ?? noop}
|
||||
onRuleChange={onRuleChange}
|
||||
/>
|
||||
{isDisabled === false ? (
|
||||
|
|
|
@ -58,6 +58,9 @@ jest.mock('../../../../../common/lib/kibana', () => {
|
|||
cases: mockCasesContract(),
|
||||
},
|
||||
}),
|
||||
useNavigateTo: () => ({
|
||||
navigateTo: jest.fn(),
|
||||
}),
|
||||
useToasts: jest.fn().mockReturnValue({
|
||||
addError: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
|
|
|
@ -37,6 +37,21 @@ import * as i18n from './translations';
|
|||
|
||||
export const DEFAULT_CONTEXT_ID = 'alert-renderer';
|
||||
|
||||
export const ALERT_RENDERER_FIELDS = [
|
||||
DESTINATION_IP,
|
||||
DESTINATION_PORT,
|
||||
EVENT_CATEGORY,
|
||||
FILE_NAME,
|
||||
HOST_NAME,
|
||||
KIBANA_ALERT_RULE_NAME,
|
||||
KIBANA_ALERT_SEVERITY,
|
||||
PROCESS_NAME,
|
||||
PROCESS_PARENT_NAME,
|
||||
SOURCE_IP,
|
||||
SOURCE_PORT,
|
||||
USER_NAME,
|
||||
];
|
||||
|
||||
const AlertRendererFlexGroup = styled(EuiFlexGroup)`
|
||||
gap: ${({ theme }) => theme.eui.euiSizeXS};
|
||||
`;
|
||||
|
|
|
@ -71,12 +71,12 @@ jest.mock('../../../../common/lib/kibana', () => {
|
|||
}),
|
||||
};
|
||||
});
|
||||
const mockDetails = () => {};
|
||||
const mockOpenDetailFn = jest.fn();
|
||||
|
||||
jest.mock('../../side_panel/hooks/use_detail_panel', () => {
|
||||
return {
|
||||
useDetailPanel: () => ({
|
||||
openDetailsPanel: mockDetails,
|
||||
openEventDetailsPanel: mockOpenDetailFn,
|
||||
handleOnDetailsPanelClosed: () => {},
|
||||
DetailsPanel: () => <div />,
|
||||
shouldShowDetailsPanel: false,
|
||||
|
@ -161,7 +161,7 @@ describe('useSessionView with active timeline and a session id and graph event i
|
|||
expect(kibana.services.sessionView.getSessionView).toHaveBeenCalledWith({
|
||||
height: 1000,
|
||||
sessionEntityId: 'test',
|
||||
loadAlertDetails: mockDetails,
|
||||
loadAlertDetails: mockOpenDetailFn,
|
||||
canAccessEndpointManagement: false,
|
||||
});
|
||||
});
|
||||
|
@ -241,7 +241,7 @@ describe('useSessionView with active timeline and a session id and graph event i
|
|||
);
|
||||
expect(kibana.services.sessionView.getSessionView).toHaveBeenCalled();
|
||||
|
||||
expect(result.current).toHaveProperty('openDetailsPanel');
|
||||
expect(result.current).toHaveProperty('openEventDetailsPanel');
|
||||
expect(result.current).toHaveProperty('shouldShowDetailsPanel');
|
||||
expect(result.current).toHaveProperty('SessionView');
|
||||
expect(result.current).toHaveProperty('DetailsPanel');
|
||||
|
|
|
@ -295,7 +295,7 @@ export const useSessionView = ({
|
|||
return SourcererScopeName.default;
|
||||
}
|
||||
}, [scopeId]);
|
||||
const { openDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({
|
||||
const { openEventDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({
|
||||
isFlyoutView: !isActiveTimeline(scopeId),
|
||||
entityType,
|
||||
sourcererScope,
|
||||
|
@ -309,7 +309,7 @@ export const useSessionView = ({
|
|||
return sessionViewConfig !== null
|
||||
? sessionView.getSessionView({
|
||||
...sessionViewConfig,
|
||||
loadAlertDetails: openDetailsPanel,
|
||||
loadAlertDetails: openEventDetailsPanel,
|
||||
isFullScreen: fullScreen,
|
||||
height: heightMinusSearchBar,
|
||||
canAccessEndpointManagement,
|
||||
|
@ -319,13 +319,13 @@ export const useSessionView = ({
|
|||
height,
|
||||
sessionViewConfig,
|
||||
sessionView,
|
||||
openDetailsPanel,
|
||||
openEventDetailsPanel,
|
||||
fullScreen,
|
||||
canAccessEndpointManagement,
|
||||
]);
|
||||
|
||||
return {
|
||||
openDetailsPanel,
|
||||
openEventDetailsPanel,
|
||||
shouldShowDetailsPanel,
|
||||
SessionView: sessionViewComponent,
|
||||
DetailsPanel,
|
||||
|
|
|
@ -16,6 +16,7 @@ import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common';
|
|||
import { EntityType } from '@kbn/timelines-plugin/common';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import type {
|
||||
SearchHit,
|
||||
TimelineEventsDetailsItem,
|
||||
TimelineEventsDetailsRequestOptions,
|
||||
TimelineEventsDetailsStrategyResponse,
|
||||
|
@ -47,7 +48,7 @@ export const useTimelineEventsDetails = ({
|
|||
}: UseTimelineEventsDetailsProps): [
|
||||
boolean,
|
||||
EventsArgs['detailsData'],
|
||||
object | undefined,
|
||||
SearchHit | undefined,
|
||||
EventsArgs['ecs'],
|
||||
() => Promise<void>
|
||||
] => {
|
||||
|
@ -67,7 +68,7 @@ export const useTimelineEventsDetails = ({
|
|||
useState<EventsArgs['detailsData']>(null);
|
||||
const [ecsData, setEcsData] = useState<EventsArgs['ecs']>(null);
|
||||
|
||||
const [rawEventData, setRawEventData] = useState<object | undefined>(undefined);
|
||||
const [rawEventData, setRawEventData] = useState<SearchHit | undefined>(undefined);
|
||||
const timelineDetailsSearch = useCallback(
|
||||
(request: TimelineEventsDetailsRequestOptions | null) => {
|
||||
if (request == null || skip || isEmpty(request.eventId)) {
|
||||
|
|
|
@ -46,6 +46,7 @@ export enum TableId {
|
|||
export enum TimelineId {
|
||||
active = 'timeline-1',
|
||||
casePage = 'timeline-case',
|
||||
detectionsAlertDetailsPage = 'detections-alert-details-page',
|
||||
test = 'timeline-test', // Reserved for testing purposes
|
||||
}
|
||||
|
||||
|
|
|
@ -49,7 +49,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
// See https://github.com/elastic/kibana/pull/125396 for details
|
||||
'--xpack.alerting.rules.minimumScheduleInterval.value=1s',
|
||||
'--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true',
|
||||
`--xpack.securitySolution.enableExperimental=${JSON.stringify([])}`,
|
||||
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
|
||||
'alertDetailsPageEnabled',
|
||||
])}`,
|
||||
`--home.disableWelcomeScreen=true`,
|
||||
],
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue