diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx index b6964381bbb3d..f747aeb4c5eb8 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx @@ -36,7 +36,7 @@ export default function AlertsFlyoutBody(props: FlyoutProps) { const { observabilityRuleTypeRegistry } = usePluginContext(); const alert = props.alert.start ? props.alert - : parseAlert(observabilityRuleTypeRegistry)(props.alert); + : parseAlert(observabilityRuleTypeRegistry)(props.alert as unknown as Record); const { services } = useKibana(); const { http } = services; const dateFormat = useUiSetting('dateFormat'); diff --git a/x-pack/plugins/rule_registry/common/search_strategy/index.ts b/x-pack/plugins/rule_registry/common/search_strategy/index.ts index 90ea6fbf95e70..4e0f3c63f3535 100644 --- a/x-pack/plugins/rule_registry/common/search_strategy/index.ts +++ b/x-pack/plugins/rule_registry/common/search_strategy/index.ts @@ -66,7 +66,12 @@ type DotNestedKeys = [D] extends [never] : never; export type EcsFields = DotNestedKeys>; + +export interface BasicFields { + _id: string; + _index: string; +} export type EcsFieldsResponse = { [Property in EcsFields]: string[]; -}; +} & BasicFields; export type RuleRegistrySearchResponse = IEsSearchResponse; diff --git a/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx b/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx index 3a1bcee1eed51..3117090a2fe6c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx @@ -6,12 +6,17 @@ */ import { Storage } from '@kbn/kibana-utils-plugin/public'; -import { AlertsTableConfigurationRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; +import type { + AlertsTableConfigurationRegistryContract, + GetRenderCellValue, +} from '@kbn/triggers-actions-ui-plugin/public'; import { APP_ID } from '../../../../common/constants'; import { getTimelinesInStorageByIds } from '../../../timelines/containers/local_storage'; import { TimelineId } from '../../../../common/types'; import { columns } from '../../../detections/configurations/security_solution_detections'; +import { useRenderCellValue } from '../../../detections/configurations/security_solution_detections/render_cell_value'; +import { useToGetInternalFlyout } from '../../../timelines/components/side_panel/event_details/flyout'; const registerAlertsTableConfiguration = ( registry: AlertsTableConfigurationRegistryContract, @@ -21,10 +26,17 @@ const registerAlertsTableConfiguration = ( return; } const timelineStorage = getTimelinesInStorageByIds(storage, [TimelineId.detectionsPage]); - const alertColumns = timelineStorage?.[TimelineId.detectionsPage]?.columns ?? columns; + const columnsFormStorage = timelineStorage?.[TimelineId.detectionsPage]?.columns ?? []; + const alertColumns = columnsFormStorage.length ? columnsFormStorage : columns; + registry.register({ id: APP_ID, columns: alertColumns, + getRenderCellValue: useRenderCellValue as GetRenderCellValue, + useInternalFlyout: () => { + const { header, body, footer } = useToGetInternalFlyout(); + return { header, body, footer }; + }, }); }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 4454653f6bc5c..09dcc9970ac20 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -7,6 +7,9 @@ import { EuiDataGridCellValueElementProps } from '@elastic/eui'; import React from 'react'; +import { TimelineId } from '../../../../common/types'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; @@ -59,3 +62,62 @@ export const RenderCellValue: React.FC< truncate={truncate} /> ); + +export const useRenderCellValue = ({ + setFlyoutAlert, +}: { + setFlyoutAlert?: (data: never) => void; +}) => { + const { browserFields } = useSourcererDataView(SourcererScopeName.detections); + return ({ + columnId, + colIndex, + data, + ecsData, + eventId, + globalFilters, + header, + isDetails = false, + isDraggable = false, + isExpandable, + isExpanded, + linkValues, + rowIndex, + rowRenderers, + setCellProps, + truncate = true, + }: CellValueElementProps) => { + const splitColumnId = columnId.split('.'); + let myHeader = header ?? { id: columnId }; + if (splitColumnId.length > 1 && browserFields[splitColumnId[0]]) { + const attr = (browserFields[splitColumnId[0]].fields ?? {})[columnId] ?? {}; + myHeader = { ...myHeader, ...attr }; + } else if (splitColumnId.length === 1) { + const attr = (browserFields.base.fields ?? {})[columnId] ?? {}; + myHeader = { ...myHeader, ...attr }; + } + + return ( + + ); + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx new file mode 100644 index 0000000000000..2ce97cfbce32e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiText, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { + ISOLATE_HOST, + UNISOLATE_HOST, +} from '../../../../../detections/components/host_isolation/translations'; +import { ALERT_DETAILS } from '../translations'; + +const BackToAlertDetailsLinkComponent = ({ + showAlertDetails, + isolateAction, +}: { + showAlertDetails: () => void; + isolateAction: 'isolateHost' | 'unisolateHost'; +}) => { + return ( + <> + + +

{ALERT_DETAILS}

+
+
+ +

{isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}

+
+ + ); +}; + +const BackToAlertDetailsLink = React.memo(BackToAlertDetailsLinkComponent); + +export { BackToAlertDetailsLink }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/body.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/body.tsx new file mode 100644 index 0000000000000..4244d33859320 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/body.tsx @@ -0,0 +1,110 @@ +/* + * 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 { EuiFlyoutBody } from '@elastic/eui'; +import styled from 'styled-components'; +import React from 'react'; +import { EndpointIsolateSuccess } from '../../../../../common/components/endpoint/host_isolation'; +import { HostIsolationPanel } from '../../../../../detections/components/host_isolation'; +import { BrowserFields, TimelineEventsDetailsItem } from '../../../../../../common/search_strategy'; +import { ExpandableEvent, HandleOnEventClosed } from '../expandable_event'; +import { HostRisk } from '../../../../../risk_score/containers'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow: hidden; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow: hidden; + padding: ${({ theme }) => `0 ${theme.eui.paddingSizes.m} ${theme.eui.paddingSizes.m}`}; + } + } +`; + +interface FlyoutBodyComponentProps { + alertId: string; + browserFields: BrowserFields; + detailsData: TimelineEventsDetailsItem[] | null; + event: { eventId: string; indexName: string }; + handleIsolationActionSuccess: () => void; + handleOnEventClosed: HandleOnEventClosed; + hostName: string; + hostRisk: HostRisk | null; + isAlert: boolean; + isDraggable?: boolean; + isReadOnly?: boolean; + isolateAction: 'isolateHost' | 'unisolateHost'; + isIsolateActionSuccessBannerVisible: boolean; + isHostIsolationPanelOpen: boolean; + loading: boolean; + rawEventData: object | undefined; + showAlertDetails: () => void; + timelineId: string; +} + +const FlyoutBodyComponent = ({ + alertId, + browserFields, + detailsData, + event, + handleIsolationActionSuccess, + handleOnEventClosed, + hostName, + hostRisk, + isAlert, + isDraggable, + isReadOnly, + isolateAction, + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + loading, + rawEventData, + showAlertDetails, + timelineId, +}: FlyoutBodyComponentProps) => { + return ( + + {isIsolateActionSuccessBannerVisible && ( + + )} + {isHostIsolationPanelOpen ? ( + + ) : ( + + )} + + ); +}; + +const FlyoutBody = React.memo(FlyoutBodyComponent); + +export { FlyoutBody }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx index 55cdcb229bba4..973875991da5f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx @@ -6,14 +6,14 @@ */ import React from 'react'; import { render } from '@testing-library/react'; -import { EventDetailsFooter } from './footer'; -import '../../../../common/mock/match_media'; -import { TestProviders } from '../../../../common/mock'; -import { TimelineId } from '../../../../../common/types/timeline'; -import { Ecs } from '../../../../../common/ecs'; -import { mockAlertDetailsData } from '../../../../common/components/event_details/__mocks__'; -import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; -import { KibanaServices, useKibana } from '../../../../common/lib/kibana'; +import { FlyoutFooter } from './footer'; +import '../../../../../common/mock/match_media'; +import { TestProviders } from '../../../../../common/mock'; +import { TimelineId } from '../../../../../../common/types/timeline'; +import { Ecs } from '../../../../../../common/ecs'; +import { mockAlertDetailsData } from '../../../../../common/components/event_details/__mocks__'; +import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy'; +import { KibanaServices, useKibana } from '../../../../../common/lib/kibana'; import { coreMock } from '@kbn/core/public/mocks'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; @@ -38,14 +38,14 @@ const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => { }; }) as TimelineEventsDetailsItem[]; -jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => { +jest.mock('../../../../../../common/endpoint/service/host_isolation/utils', () => { return { isIsolationSupported: jest.fn().mockReturnValue(true), }; }); jest.mock( - '../../../../detections/containers/detection_engine/alerts/use_host_isolation_status', + '../../../../../detections/containers/detection_engine/alerts/use_host_isolation_status', () => { return { useHostIsolationStatus: jest.fn().mockReturnValue({ @@ -57,30 +57,30 @@ jest.mock( } ); -jest.mock('../../../../common/hooks/use_experimental_features', () => ({ +jest.mock('../../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), })); -jest.mock('../../../../detections/components/user_info', () => ({ +jest.mock('../../../../../detections/components/user_info', () => ({ useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), })); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../../common/lib/kibana'); jest.mock( - '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', + '../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', () => ({ useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), }) ); -jest.mock('../../../../cases/components/use_insert_timeline'); +jest.mock('../../../../../cases/components/use_insert_timeline'); -jest.mock('../../../../common/utils/endpoint_alert_check', () => { +jest.mock('../../../../../common/utils/endpoint_alert_check', () => { return { isAlertFromEndpointAlert: jest.fn().mockReturnValue(true), isAlertFromEndpointEvent: jest.fn().mockReturnValue(true), }; }); jest.mock( - '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline', + '../../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline', () => { return { useInvestigateInTimeline: jest.fn().mockReturnValue({ @@ -90,7 +90,7 @@ jest.mock( }; } ); -jest.mock('../../../../detections/components/alerts_table/actions'); +jest.mock('../../../../../detections/components/alerts_table/actions'); const defaultProps = { timelineId: TimelineId.test, @@ -126,7 +126,7 @@ describe('event details footer component', () => { test('it renders the take action dropdown', () => { const wrapper = render( - + ); expect(wrapper.getByTestId('take-action-dropdown-btn')).toBeTruthy(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx similarity index 80% rename from x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx index 86a8047b3ad76..6a17592f5b764 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx @@ -9,20 +9,20 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { find } from 'lodash/fp'; import { connect, ConnectedProps } from 'react-redux'; -import { TakeActionDropdown } from '../../../../detections/components/take_action_dropdown'; -import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; -import { TimelineId } from '../../../../../common/types'; -import { useExceptionFlyout } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_flyout'; -import { AddExceptionFlyoutWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; -import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; -import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; -import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { Ecs } from '../../../../../common/ecs'; -import { inputsModel, inputsSelectors, State } from '../../../../common/store'; -import { OsqueryFlyout } from '../../../../detections/components/osquery/osquery_flyout'; - -interface EventDetailsFooterProps { +import { TakeActionDropdown } from '../../../../../detections/components/take_action_dropdown'; +import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy'; +import { useExceptionFlyout } from '../../../../../detections/components/alerts_table/timeline_actions/use_add_exception_flyout'; +import { AddExceptionFlyoutWrapper } from '../../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; +import { EventFiltersFlyout } from '../../../../../management/pages/event_filters/view/components/event_filters_flyout'; +import { useEventFilterModal } from '../../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; +import { getFieldValue } from '../../../../../detections/components/host_isolation/helpers'; +import { Status } from '../../../../../../common/detection_engine/schemas/common/schemas'; +import { Ecs } from '../../../../../../common/ecs'; +import { inputsModel, inputsSelectors, State } from '../../../../../common/store'; +import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout'; +import { TimelineId } from '../../../../../../common/types'; + +interface FlyoutFooterProps { detailsData: TimelineEventsDetailsItem[] | null; detailsEcsData: Ecs | null; expandedEvent: { @@ -32,6 +32,7 @@ interface EventDetailsFooterProps { }; handleOnEventClosed: () => void; isHostIsolationPanelOpen: boolean; + isReadOnly?: boolean; loadingEventDetails: boolean; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; timelineId: string; @@ -45,20 +46,21 @@ interface AddExceptionModalWrapperData { ruleName: string; } -export const EventDetailsFooterComponent = React.memo( +export const FlyoutFooterComponent = React.memo( ({ detailsData, detailsEcsData, expandedEvent, handleOnEventClosed, isHostIsolationPanelOpen, + isReadOnly, loadingEventDetails, onAddIsolationStatusClick, timelineId, globalQuery, timelineQuery, refetchFlyoutData, - }: EventDetailsFooterProps & PropsFromRedux) => { + }: FlyoutFooterProps & PropsFromRedux) => { const ruleIndex = useMemo( () => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ?? @@ -118,6 +120,10 @@ export const EventDetailsFooterComponent = React.memo( setOsqueryFlyoutOpenWithAgentId(null); }, [setOsqueryFlyoutOpenWithAgentId]); + if (isReadOnly) { + return null; + } + return ( <> @@ -175,7 +181,7 @@ export const EventDetailsFooterComponent = React.memo( const makeMapStateToProps = () => { const getGlobalQueries = inputsSelectors.globalQuery(); const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); - const mapStateToProps = (state: State, { timelineId }: EventDetailsFooterProps) => { + const mapStateToProps = (state: State, { timelineId }: FlyoutFooterProps) => { return { globalQuery: getGlobalQueries(state), timelineQuery: getTimelineQuery(state, timelineId), @@ -188,4 +194,4 @@ const connector = connect(makeMapStateToProps); type PropsFromRedux = ConnectedProps; -export const EventDetailsFooter = connector(React.memo(EventDetailsFooterComponent)); +export const FlyoutFooter = connector(React.memo(FlyoutFooterComponent)); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx new file mode 100644 index 0000000000000..39a4899dbc33c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -0,0 +1,76 @@ +/* + * 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 { EuiFlyoutHeader } from '@elastic/eui'; +import React from 'react'; + +import { ExpandableEventTitle } from '../expandable_event'; +import { BackToAlertDetailsLink } from './back_to_alert_details_link'; + +interface FlyoutHeaderComponentProps { + isAlert: boolean; + isHostIsolationPanelOpen: boolean; + isolateAction: 'isolateHost' | 'unisolateHost'; + loading: boolean; + ruleName: string; + showAlertDetails: () => void; + timestamp: string; +} + +const FlyoutHeaderContentComponent = ({ + isAlert, + isHostIsolationPanelOpen, + isolateAction, + loading, + ruleName, + showAlertDetails, + timestamp, +}: FlyoutHeaderComponentProps) => { + return ( + <> + {isHostIsolationPanelOpen ? ( + + ) : ( + + )} + + ); +}; +const FlyoutHeaderContent = React.memo(FlyoutHeaderContentComponent); + +const FlyoutHeaderComponent = ({ + isAlert, + isHostIsolationPanelOpen, + isolateAction, + loading, + ruleName, + showAlertDetails, + timestamp, +}: FlyoutHeaderComponentProps) => { + return ( + + + + ); +}; + +const FlyoutHeader = React.memo(FlyoutHeaderComponent); + +export { FlyoutHeader, FlyoutHeaderContent }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/index.tsx new file mode 100644 index 0000000000000..055da32e74673 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/index.tsx @@ -0,0 +1,188 @@ +/* + * 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 { AlertsTableFlyoutBaseProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { EntityType, TimelineId } from '@kbn/timelines-plugin/common'; +import { noop } from 'lodash/fp'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { buildHostNamesFilter } from '../../../../../../common/search_strategy'; +import { HostRisk, useHostRiskScore } from '../../../../../risk_score/containers'; +import { useHostIsolationTools } from '../use_host_isolation_tools'; +import { FlyoutHeaderContent } from './header'; +import { FlyoutBody } from './body'; +import { FlyoutFooter } from './footer'; +import { useTimelineEventsDetails } from '../../../../containers/details'; +import { useSourcererDataView } from '../../../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; +import { useBasicDataFromDetailsData } from '../helpers'; + +export { FlyoutBody } from './body'; +export { FlyoutHeader } from './header'; +export { FlyoutFooter } from './footer'; + +export const useToGetInternalFlyout = () => { + const { browserFields, docValueFields, runtimeMappings } = useSourcererDataView( + SourcererScopeName.detections + ); + const [alert, setAlert] = useState<{ id?: string; indexName?: string }>({ + id: undefined, + indexName: undefined, + }); + + const [loading, detailsData, rawEventData, ecsData, refetchFlyoutData] = useTimelineEventsDetails( + { + docValueFields, + entityType: EntityType.EVENTS, + indexName: alert.indexName ?? '', + eventId: alert.id ?? '', + runtimeMappings, + skip: !alert.id, + } + ); + + const { alertId, isAlert, hostName, ruleName, timestamp } = + useBasicDataFromDetailsData(detailsData); + + const [hostRiskLoading, { data, isModuleEnabled }] = useHostRiskScore({ + filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, + pagination: { + cursorStart: 0, + querySize: 1, + }, + }); + + const hostRisk: HostRisk | null = useMemo( + () => + data + ? { + loading: hostRiskLoading, + isModuleEnabled, + result: data, + } + : null, + [data, hostRiskLoading, isModuleEnabled] + ); + + const { + isolateAction, + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + handleIsolationActionSuccess, + showAlertDetails, + showHostIsolationPanel, + } = useHostIsolationTools(); + + const body = useCallback( + ({ isLoading, alert: localAlert }: AlertsTableFlyoutBaseProps) => { + setAlert((prevAlert) => { + if (prevAlert.id !== localAlert._id) { + return { id: localAlert._id, indexName: localAlert._index }; + } + return prevAlert; + }); + + return ( + + ); + }, + [ + alertId, + browserFields, + detailsData, + handleIsolationActionSuccess, + hostName, + hostRisk, + isAlert, + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + isolateAction, + loading, + rawEventData, + showAlertDetails, + ] + ); + + const header = useCallback( + ({ isLoading }: AlertsTableFlyoutBaseProps) => { + return ( + + ); + }, + [ + isAlert, + isHostIsolationPanelOpen, + isolateAction, + loading, + ruleName, + showAlertDetails, + timestamp, + ] + ); + + const footer = useCallback( + ({ isLoading, alert: localAlert }: AlertsTableFlyoutBaseProps) => { + return ( + + ); + }, + [ + detailsData, + ecsData, + isHostIsolationPanelOpen, + loading, + refetchFlyoutData, + showHostIsolationPanel, + ] + ); + + return useMemo( + () => ({ + body, + header, + footer, + }), + [body, header, footer] + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/helpers.tsx new file mode 100644 index 0000000000000..9f7019e68b17f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/helpers.tsx @@ -0,0 +1,53 @@ +/* + * 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 { some } from 'lodash/fp'; +import { useMemo } from 'react'; +import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; +import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; + +interface GetBasicDataFromDetailsData { + alertId: string; + isAlert: boolean; + hostName: string; + ruleName: string; + timestamp: string; +} + +export const useBasicDataFromDetailsData = ( + data: TimelineEventsDetailsItem[] | null +): GetBasicDataFromDetailsData => { + const isAlert = some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, data); + + const ruleName = useMemo( + () => getFieldValue({ category: 'kibana', field: 'kibana.alert.rule.name' }, data), + [data] + ); + + const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, data), [data]); + + const hostName = useMemo( + () => getFieldValue({ category: 'host', field: 'host.name' }, data), + [data] + ); + + const timestamp = useMemo( + () => getFieldValue({ category: 'base', field: '@timestamp' }, data), + [data] + ); + + return useMemo( + () => ({ + alertId, + isAlert, + hostName, + ruleName, + timestamp, + }), + [alertId, hostName, isAlert, ruleName, timestamp] + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index a5de47f5e8913..364239f2625f6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -5,17 +5,9 @@ * 2.0. */ -import { some } from 'lodash/fp'; -import { - EuiButtonEmpty, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiSpacer, - EuiTitle, - EuiText, -} from '@elastic/eui'; -import React, { useState, useCallback, useMemo } from 'react'; -import styled from 'styled-components'; +import { EuiSpacer } from '@elastic/eui'; +import React from 'react'; + import deepEqual from 'fast-deep-equal'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { EntityType } from '@kbn/timelines-plugin/common'; @@ -23,32 +15,11 @@ import { BrowserFields, DocValueFields } from '../../../../common/containers/sou import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; import { TimelineTabs } from '../../../../../common/types/timeline'; -import { HostIsolationPanel } from '../../../../detections/components/host_isolation'; -import { EndpointIsolateSuccess } from '../../../../common/components/endpoint/host_isolation'; -import { - ISOLATE_HOST, - UNISOLATE_HOST, -} from '../../../../detections/components/host_isolation/translations'; -import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; -import { ALERT_DETAILS } from './translations'; -import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; -import { EventDetailsFooter } from './footer'; import { buildHostNamesFilter } from '../../../../../common/search_strategy'; import { useHostRiskScore, HostRisk } from '../../../../risk_score/containers'; - -const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` - .euiFlyoutBody__overflow { - display: flex; - flex: 1; - overflow: hidden; - - .euiFlyoutBody__overflowContent { - flex: 1; - overflow: hidden; - padding: ${({ theme }) => `0 ${theme.eui.paddingSizes.m} ${theme.eui.paddingSizes.m}`}; - } - } -`; +import { useHostIsolationTools } from './use_host_isolation_tools'; +import { FlyoutBody, FlyoutHeader, FlyoutFooter } from './flyout'; +import { useBasicDataFromDetailsData } from './helpers'; interface EventDetailsPanelProps { browserFields: BrowserFields; @@ -92,43 +63,17 @@ const EventDetailsPanelComponent: React.FC = ({ } ); - const [isHostIsolationPanelOpen, setIsHostIsolationPanel] = useState(false); - - const [isolateAction, setIsolateAction] = useState<'isolateHost' | 'unisolateHost'>( - 'isolateHost' - ); - - const [isIsolateActionSuccessBannerVisible, setIsIsolateActionSuccessBannerVisible] = - useState(false); - - const showAlertDetails = useCallback(() => { - setIsHostIsolationPanel(false); - setIsIsolateActionSuccessBannerVisible(false); - }, []); - - const showHostIsolationPanel = useCallback((action) => { - if (action === 'isolateHost' || action === 'unisolateHost') { - setIsHostIsolationPanel(true); - setIsolateAction(action); - } - }, []); - - const isAlert = some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, detailsData); - - const ruleName = useMemo( - () => getFieldValue({ category: 'kibana', field: 'kibana.alert.rule.name' }, detailsData), - [detailsData] - ); - - const alertId = useMemo( - () => getFieldValue({ category: '_id', field: '_id' }, detailsData), - [detailsData] - ); + const { + isolateAction, + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + handleIsolationActionSuccess, + showAlertDetails, + showHostIsolationPanel, + } = useHostIsolationTools(); - const hostName = useMemo( - () => getFieldValue({ category: 'host', field: 'host.name' }, detailsData), - [detailsData] - ); + const { alertId, isAlert, hostName, ruleName, timestamp } = + useBasicDataFromDetailsData(detailsData); const [hostRiskLoading, { data, isModuleEnabled }] = useHostRiskScore({ filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, @@ -146,105 +91,53 @@ const EventDetailsPanelComponent: React.FC = ({ } : null; - const timestamp = useMemo( - () => getFieldValue({ category: 'base', field: '@timestamp' }, detailsData), - [detailsData] - ); - - const backToAlertDetailsLink = useMemo(() => { - return ( - <> - showAlertDetails()} - > - -

{ALERT_DETAILS}

-
-
- -

{isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}

-
- - ); - }, [showAlertDetails, isolateAction]); - - const caseDetailsRefresh = useWithCaseDetailsRefresh(); - - const handleIsolationActionSuccess = useCallback(() => { - setIsIsolateActionSuccessBannerVisible(true); - // If a case details refresh ref is defined, then refresh actions and comments - if (caseDetailsRefresh) { - caseDetailsRefresh.refreshCase(); - } - }, [caseDetailsRefresh]); - if (!expandedEvent?.eventId) { return null; } return isFlyoutView ? ( <> - - {isHostIsolationPanelOpen ? ( - backToAlertDetailsLink - ) : ( - - )} - - {isIsolateActionSuccessBannerVisible && ( - - )} - - {isHostIsolationPanelOpen ? ( - - ) : ( - - )} - - - {!isReadOnly && ( - - )} + + + ) : ( <> @@ -268,19 +161,18 @@ const EventDetailsPanelComponent: React.FC = ({ hostRisk={hostRisk} handleOnEventClosed={handleOnEventClosed} /> - {!isReadOnly && ( - - )} + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_host_isolation_tools.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_host_isolation_tools.tsx new file mode 100644 index 0000000000000..5667c4e0a6156 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_host_isolation_tools.tsx @@ -0,0 +1,108 @@ +/* + * 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, useReducer } from 'react'; + +import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; + +interface HostIsolationStateReducer { + isolateAction: 'isolateHost' | 'unisolateHost'; + isHostIsolationPanelOpen: boolean; + isIsolateActionSuccessBannerVisible: boolean; +} + +type HostIsolationActions = + | { + type: 'setIsHostIsolationPanel'; + isHostIsolationPanelOpen: boolean; + } + | { + type: 'setIsolateAction'; + isolateAction: 'isolateHost' | 'unisolateHost'; + } + | { + type: 'setIsIsolateActionSuccessBannerVisible'; + isIsolateActionSuccessBannerVisible: boolean; + }; + +const initialHostIsolationState: HostIsolationStateReducer = { + isolateAction: 'isolateHost', + isHostIsolationPanelOpen: false, + isIsolateActionSuccessBannerVisible: false, +}; + +function hostIsolationReducer(state: HostIsolationStateReducer, action: HostIsolationActions) { + switch (action.type) { + case 'setIsolateAction': + return { ...state, isolateAction: action.isolateAction }; + case 'setIsHostIsolationPanel': + return { ...state, isHostIsolationPanelOpen: action.isHostIsolationPanelOpen }; + case 'setIsIsolateActionSuccessBannerVisible': + return { + ...state, + isIsolateActionSuccessBannerVisible: action.isIsolateActionSuccessBannerVisible, + }; + default: + throw new Error(); + } +} + +const useHostIsolationTools = () => { + const [ + { isolateAction, isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible }, + dispatch, + ] = useReducer(hostIsolationReducer, initialHostIsolationState); + + const showAlertDetails = useCallback(() => { + dispatch({ type: 'setIsHostIsolationPanel', isHostIsolationPanelOpen: false }); + dispatch({ + type: 'setIsIsolateActionSuccessBannerVisible', + isIsolateActionSuccessBannerVisible: false, + }); + }, []); + + const showHostIsolationPanel = useCallback((action) => { + if (action === 'isolateHost' || action === 'unisolateHost') { + dispatch({ type: 'setIsHostIsolationPanel', isHostIsolationPanelOpen: true }); + dispatch({ type: 'setIsolateAction', isolateAction: action }); + } + }, []); + + const caseDetailsRefresh = useWithCaseDetailsRefresh(); + + const handleIsolationActionSuccess = useCallback(() => { + dispatch({ + type: 'setIsIsolateActionSuccessBannerVisible', + isIsolateActionSuccessBannerVisible: true, + }); + // If a case details refresh ref is defined, then refresh actions and comments + if (caseDetailsRefresh) { + caseDetailsRefresh.refreshCase(); + } + }, [caseDetailsRefresh]); + + return useMemo( + () => ({ + isolateAction, + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + handleIsolationActionSuccess, + showAlertDetails, + showHostIsolationPanel, + }), + [ + isHostIsolationPanelOpen, + isIsolateActionSuccessBannerVisible, + isolateAction, + handleIsolationActionSuccess, + showAlertDetails, + showHostIsolationPanel, + ] + ); +}; + +export { useHostIsolationTools }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.test.tsx index 74c1bdcebb9e3..4996da8008f34 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.test.tsx @@ -16,6 +16,8 @@ const props = { alert: { [AlertsField.name]: ['one'], [AlertsField.reason]: ['two'], + _id: '0123456789', + _index: '.alerts-default', }, alertsTableConfiguration: { id: 'test', @@ -34,9 +36,11 @@ const props = { externalFlyout: { body: () =>

External flyout body

, }, - internalFlyout: { + useInternalFlyout: () => ({ body: () =>

Internal flyout body

, - }, + header: null, + footer: () => null, + }), getRenderCellValue: () => jest.fn().mockImplementation((rcvProps) => { return `${rcvProps.colIndex}:${rcvProps.rowIndex}`; @@ -82,6 +86,7 @@ describe('AlertsFlyout', () => { for (const configuration of configurations) { const base = { body: () =>
Body
, + footer: () => null, }; it(`should use ${configuration} header configuration`, async () => { @@ -89,10 +94,21 @@ describe('AlertsFlyout', () => { ...props, alertsTableConfiguration: { ...props.alertsTableConfiguration, - [`${configuration}Flyout`]: { - ...base, - header: () =>

Header

, - }, + ...(configuration === AlertsTableFlyoutState.external + ? { + [`${configuration}Flyout`]: { + ...base, + header: () =>

Header

, + footer: () => null, + }, + } + : { + useInternalFlyout: () => ({ + ...base, + header: () =>

Header

, + footer: () => null, + }), + }), }, state: configuration, }; @@ -110,6 +126,17 @@ describe('AlertsFlyout', () => { ...props, alertsTableConfiguration: { ...props.alertsTableConfiguration, + ...(configuration === AlertsTableFlyoutState.external + ? { + [`${configuration}Flyout`]: { + ...base, + }, + } + : { + useInternalFlyout: () => ({ + ...base, + }), + }), [`${configuration}Flyout`]: { ...base, }, @@ -130,10 +157,19 @@ describe('AlertsFlyout', () => { ...props, alertsTableConfiguration: { ...props.alertsTableConfiguration, - [`${configuration}Flyout`]: { - ...base, - footer: () =>
Footer
, - }, + ...(configuration === AlertsTableFlyoutState.external + ? { + [`${configuration}Flyout`]: { + ...base, + footer: () =>
Footer
, + }, + } + : { + useInternalFlyout: () => ({ + ...base, + footer: () =>
Footer
, + }), + }), }, state: configuration, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx index 02c95c50c0202..af278698c565b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/alerts_flyout.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { Suspense, lazy } from 'react'; +import React, { Suspense, lazy, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlyout, @@ -56,6 +56,16 @@ export const AlertsFlyout: React.FunctionComponent = ({ let Body: AlertTableFlyoutComponent; let Footer: AlertTableFlyoutComponent; + const { + header: internalHeader, + body: internalBody, + footer: internalFooter, + } = alertsTableConfiguration?.useInternalFlyout?.() ?? { + header: null, + body: null, + footer: null, + }; + switch (state) { case AlertsTableFlyoutState.external: Header = alertsTableConfiguration?.externalFlyout?.header ?? AlertsFlyoutHeader; @@ -63,19 +73,72 @@ export const AlertsFlyout: React.FunctionComponent = ({ Footer = alertsTableConfiguration?.externalFlyout?.footer ?? null; break; case AlertsTableFlyoutState.internal: - Header = alertsTableConfiguration?.internalFlyout?.header ?? AlertsFlyoutHeader; - Body = alertsTableConfiguration?.internalFlyout?.body ?? null; - Footer = alertsTableConfiguration?.internalFlyout?.footer ?? null; + Header = internalHeader ?? AlertsFlyoutHeader; + Body = internalBody ?? null; + Footer = internalFooter ?? null; break; } - const passedProps = { - alert, - isLoading, - }; + const passedProps = useMemo( + () => ({ + alert, + isLoading, + }), + [alert, isLoading] + ); + + const FlyoutBody = useCallback( + () => + Body ? ( + + + + ) : null, + [Body, passedProps] + ); + + const FlyoutFooter = useCallback( + () => + Footer ? ( + +