diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 1dbb633e32adf..3edbd3443ffc1 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -23,6 +23,23 @@ export type StatusAllType = typeof StatusAll; export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; +/** + * The type for the `refreshRef` prop (a `React.Ref`) defined by the `CaseViewComponentProps`. + * + * @example + * const refreshRef = useRef(null); + * return + */ +export type CaseViewRefreshPropInterface = null | { + /** + * Refreshes the all of the user actions/comments in the view's timeline + * (note: this also triggers a silent `refreshCase()`) + */ + refreshUserActionsAndComments: () => Promise; + /** Refreshes the Case information only */ + refreshCase: () => Promise; +}; + export type Comment = CommentRequest & { associationType: AssociationType; id: string; diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index 9c6e9442c8f56..d5b535b8ddad1 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; import { @@ -16,11 +16,19 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector, Ecs } from '../../../common'; +import { + CaseStatuses, + CaseAttributes, + CaseType, + Case, + CaseConnector, + Ecs, + CaseViewRefreshPropInterface, +} from '../../../common'; import { HeaderPage } from '../header_page'; import { EditableTitle } from '../header_page/editable_title'; import { TagList } from '../tag_list'; -import { useGetCase } from '../../containers/use_get_case'; +import { UseGetCase, useGetCase } from '../../containers/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../containers/use_update_case'; @@ -42,6 +50,7 @@ import { getConnectorById } from '../utils'; import { DoesNotExist } from './does_not_exist'; const gutterTimeline = '70px'; // seems to be a timeline reference from the original file + export interface CaseViewComponentProps { allCasesNavigation: CasesNavigation; caseDetailsNavigation: CasesNavigation; @@ -54,12 +63,18 @@ export interface CaseViewComponentProps { subCaseId?: string; useFetchAlertData: (alertIds: string[]) => [boolean, Record]; userCanCrud: boolean; + /** + * A React `Ref` that Exposes data refresh callbacks. + * **NOTE**: Do not hold on to the `.current` object, as it could become stale + */ + refreshRef?: MutableRefObject; } export interface CaseViewProps extends CaseViewComponentProps { onCaseDataSuccess?: (data: Case) => void; timelineIntegration?: CasesTimelineIntegration; } + export interface OnUpdateFields { key: keyof Case; value: Case[keyof Case]; @@ -78,13 +93,14 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` const MyEuiHorizontalRule = styled(EuiHorizontalRule)` margin-left: 48px; + &.euiHorizontalRule--full { width: calc(100% - 48px); } `; export interface CaseComponentProps extends CaseViewComponentProps { - fetchCase: () => void; + fetchCase: UseGetCase['fetchCase']; caseData: Case; updateCase: (newCase: Case) => void; } @@ -105,6 +121,7 @@ export const CaseComponent = React.memo( updateCase, useFetchAlertData, userCanCrud, + refreshRef, }) => { const [initLoadingData, setInitLoadingData] = useState(true); const init = useRef(true); @@ -124,6 +141,51 @@ export const CaseComponent = React.memo( subCaseId, }); + // Set `refreshRef` if needed + useEffect(() => { + let isStale = false; + + if (refreshRef) { + refreshRef.current = { + refreshCase: async () => { + // Do nothing if component (or instance of this render cycle) is stale + if (isStale) { + return; + } + + await fetchCase(); + }, + refreshUserActionsAndComments: async () => { + // Do nothing if component (or instance of this render cycle) is stale + // -- OR -- + // it is already loading + if (isStale || isLoadingUserActions) { + return; + } + + await Promise.all([ + fetchCase(true), + fetchCaseUserActions(caseId, caseData.connector.id, subCaseId), + ]); + }, + }; + + return () => { + isStale = true; + refreshRef.current = null; + }; + } + }, [ + caseData.connector.id, + caseId, + fetchCase, + fetchCaseUserActions, + isLoadingUserActions, + refreshRef, + subCaseId, + updateCase, + ]); + // Update Fields const onUpdateField = useCallback( ({ key, value, onSuccess, onError }: OnUpdateFields) => { @@ -491,6 +553,7 @@ export const CaseView = React.memo( timelineIntegration, useFetchAlertData, userCanCrud, + refreshRef, }: CaseViewProps) => { const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId); if (isError) { @@ -528,6 +591,7 @@ export const CaseView = React.memo( updateCase={updateCase} useFetchAlertData={useFetchAlertData} userCanCrud={userCanCrud} + refreshRef={refreshRef} /> diff --git a/x-pack/plugins/cases/public/containers/use_get_case.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx index 75d9ac74a8ccf..c88f530709c8a 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx @@ -89,6 +89,19 @@ describe('useGetCase', () => { }); }); + it('set isLoading to false when refetching case "silent"ly', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCase(true); + + expect(result.current.isLoading).toBe(false); + }); + }); + it('unhappy path', async () => { const spyOnGetCase = jest.spyOn(api, 'getCase'); spyOnGetCase.mockImplementation(() => { diff --git a/x-pack/plugins/cases/public/containers/use_get_case.tsx b/x-pack/plugins/cases/public/containers/use_get_case.tsx index 7b59f8e06b7af..b9326ad057c9e 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case.tsx @@ -19,7 +19,7 @@ interface CaseState { } type Action = - | { type: 'FETCH_INIT' } + | { type: 'FETCH_INIT'; payload: { silent: boolean } } | { type: 'FETCH_SUCCESS'; payload: Case } | { type: 'FETCH_FAILURE' } | { type: 'UPDATE_CASE'; payload: Case }; @@ -29,7 +29,10 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { case 'FETCH_INIT': return { ...state, - isLoading: true, + // If doing a silent fetch, then don't set `isLoading`. This helps + // with preventing screen flashing when wanting to refresh the actions + // and comments + isLoading: !action.payload?.silent, isError: false, }; case 'FETCH_SUCCESS': @@ -56,7 +59,11 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; export interface UseGetCase extends CaseState { - fetchCase: () => void; + /** + * @param [silent] When set to `true`, the `isLoading` property will not be set to `true` + * while doing the API call + */ + fetchCase: (silent?: boolean) => Promise; updateCase: (newCase: Case) => void; } @@ -74,33 +81,35 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { dispatch({ type: 'UPDATE_CASE', payload: newCase }); }, []); - const callFetch = useCallback(async () => { - try { - isCancelledRef.current = false; - abortCtrlRef.current.abort(); - abortCtrlRef.current = new AbortController(); - dispatch({ type: 'FETCH_INIT' }); + const callFetch = useCallback( + async (silent: boolean = false) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT', payload: { silent } }); - const response = await (subCaseId - ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) - : getCase(caseId, true, abortCtrlRef.current.signal)); + const response = await (subCaseId + ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) + : getCase(caseId, true, abortCtrlRef.current.signal)); - if (!isCancelledRef.current) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!isCancelledRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + dispatch({ type: 'FETCH_FAILURE' }); } - dispatch({ type: 'FETCH_FAILURE' }); } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [caseId, subCaseId]); + }, + [caseId, subCaseId, toasts] + ); useEffect(() => { callFetch(); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx index 66aa93154b318..edafa1b9a10a9 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx @@ -51,7 +51,11 @@ export const initialData: CaseUserActionsState = { }; export interface UseGetCaseUserActions extends CaseUserActionsState { - fetchCaseUserActions: (caseId: string, caseConnectorId: string, subCaseId?: string) => void; + fetchCaseUserActions: ( + caseId: string, + caseConnectorId: string, + subCaseId?: string + ) => Promise; } const getExternalService = (value: string): CaseExternalService | null => diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 71bc241f65e10..dec2d409b020d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { SearchResponse } from 'elasticsearch'; import { isEmpty } from 'lodash'; @@ -19,7 +19,7 @@ import { useFormatUrl, } from '../../../common/components/link_to'; import { Ecs } from '../../../../common/ecs'; -import { Case } from '../../../../../cases/common'; +import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common'; import { TimelineId } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; import { KibanaServices, useKibana } from '../../../common/lib/kibana'; @@ -38,6 +38,7 @@ import { SEND_ALERT_TO_TIMELINE } from './translations'; import { useInsertTimeline } from '../use_insert_timeline'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; +import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; interface Props { caseId: string; @@ -176,9 +177,13 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = }) ); }, [dispatch]); + + const refreshRef = useRef(null); + return ( - <> + {casesUi.getCaseView({ + refreshRef, allCasesNavigation: { href: formattedAllCasesLink, onClick: async (e) => { @@ -247,7 +252,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = userCanCrud, })} - + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx new file mode 100644 index 0000000000000..8ae9d98a31429 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context.tsx @@ -0,0 +1,36 @@ +/* + * 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, { MutableRefObject, useContext } from 'react'; +import { CaseViewRefreshPropInterface } from '../../../../../../cases/common'; + +/** + * React Context that can hold the `Ref` that is created an passed to `CaseViewProps['refreshRef`]`, enabling + * child components to trigger a refresh of a case. + */ +export const CaseDetailsRefreshContext = React.createContext | null>( + null +); + +/** + * Returns the closes CaseDetails Refresh interface if any. Used in conjuction with `CaseDetailsRefreshContext` component + * + * @example + * // Higher-order component + * const refreshRef = useRef(null); + * return .... + * + * // Now, use the hook from a hild component that was rendered inside of `` + * const caseDetailsRefresh = useWithCaseDetailsRefresh(); + * ... + * if (caseDetailsRefresh) { + * caseDetailsRefresh.refreshUserActionsAndComments(); + * } + */ +export const useWithCaseDetailsRefresh = (): Readonly | undefined => { + return useContext(CaseDetailsRefreshContext)?.current; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index ef311a7ca43b1..36443cc91f4e8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -20,10 +20,12 @@ export const HostIsolationPanel = React.memo( ({ details, cancelCallback, + successCallback, isolateAction, }: { details: Maybe; cancelCallback: () => void; + successCallback?: () => void; isolateAction: string; }) => { const endpointId = useMemo(() => { @@ -92,6 +94,7 @@ export const HostIsolationPanel = React.memo( cases={associatedCases} casesInfo={casesInfo} cancelCallback={cancelCallback} + successCallback={successCallback} /> ) : ( ); } diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx index b209c2f9c6e24..75dd850c30f43 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx @@ -24,12 +24,14 @@ export const IsolateHost = React.memo( cases, casesInfo, cancelCallback, + successCallback, }: { endpointId: string; hostName: string; cases: ReactNode; casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; + successCallback?: () => void; }) => { const [comment, setComment] = useState(''); const [isIsolated, setIsIsolated] = useState(false); @@ -43,7 +45,11 @@ export const IsolateHost = React.memo( const confirmHostIsolation = useCallback(async () => { const hostIsolated = await isolateHost(); setIsIsolated(hostIsolated); - }, [isolateHost]); + + if (hostIsolated && successCallback) { + successCallback(); + } + }, [isolateHost, successCallback]); const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index ad8e8eaddb39e..2b810dc16eec8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -24,12 +24,14 @@ export const UnisolateHost = React.memo( cases, casesInfo, cancelCallback, + successCallback, }: { endpointId: string; hostName: string; cases: ReactNode; casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; + successCallback?: () => void; }) => { const [comment, setComment] = useState(''); const [isUnIsolated, setIsUnIsolated] = useState(false); @@ -41,9 +43,13 @@ export const UnisolateHost = React.memo( const { loading, unIsolateHost } = useHostUnisolation({ endpointId, comment, caseIds }); const confirmHostUnIsolation = useCallback(async () => { - const hostIsolated = await unIsolateHost(); - setIsUnIsolated(hostIsolated); - }, [unIsolateHost]); + const hostUnIsolated = await unIsolateHost(); + setIsUnIsolated(hostUnIsolated); + + if (hostUnIsolated && successCallback) { + successCallback(); + } + }, [successCallback, unIsolateHost]); const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx index 12426e05ba528..70d1d5ab5e194 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx @@ -12,6 +12,7 @@ import { createHostIsolation } from './api'; interface HostIsolationStatus { loading: boolean; + /** Boolean return will indicate if isolation action was created successful */ isolateHost: () => Promise; } 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 395538610f567..c4b19863ce7fc 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 @@ -33,6 +33,7 @@ import { import { ALERT_DETAILS } from './translations'; import { useIsolationPrivileges } from '../../../../common/hooks/endpoint/use_isolate_privileges'; import { endpointAlertCheck } from '../../../../common/utils/endpoint_alert_check'; +import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -121,6 +122,15 @@ const EventDetailsPanelComponent: React.FC = ({ ); }, [showAlertDetails, isolateAction]); + const caseDetailsRefresh = useWithCaseDetailsRefresh(); + + const handleIsolationActionSuccess = useCallback(() => { + // If a case details refresh ref is defined, then refresh actions and comments + if (caseDetailsRefresh) { + caseDetailsRefresh.refreshUserActionsAndComments(); + } + }, [caseDetailsRefresh]); + if (!expandedEvent?.eventId) { return null; } @@ -139,6 +149,7 @@ const EventDetailsPanelComponent: React.FC = ({ ) : ( diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 50fe2ffe2cea9..785434aa17ec6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -61,6 +61,7 @@ export const isolationRequestHandler = function ( TypeOf, SecuritySolutionRequestHandlerContext > { + // eslint-disable-next-line complexity return async (context, req, res) => { if ( (!req.body.agent_ids || req.body.agent_ids.length === 0) && @@ -100,14 +101,14 @@ export const isolationRequestHandler = function ( } agentIDs = [...new Set(agentIDs)]; // dedupe + const casesClient = await endpointContext.service.getCasesClient(req); + // convert any alert IDs into cases let caseIDs: string[] = req.body.case_ids?.slice() || []; if (req.body.alert_ids && req.body.alert_ids.length > 0) { const newIDs: string[][] = await Promise.all( req.body.alert_ids.map(async (a: string) => { - const cases: CasesByAlertId = await ( - await endpointContext.service.getCasesClient(req) - ).cases.getCasesByAlertID({ + const cases: CasesByAlertId = await casesClient.cases.getCasesByAlertID({ alertID: a, options: { owner: APP_ID }, }); @@ -167,16 +168,21 @@ export const isolationRequestHandler = function ( commentLines.push(`\n\nWith Comment:\n> ${req.body.comment}`); } - caseIDs.forEach(async (caseId) => { - (await endpointContext.service.getCasesClient(req)).attachments.add({ - caseId, - comment: { - comment: commentLines.join('\n'), - type: CommentType.user, - owner: APP_ID, - }, - }); - }); + // Update all cases with a comment + if (caseIDs.length > 0) { + await Promise.all( + caseIDs.map((caseId) => + casesClient.attachments.add({ + caseId, + comment: { + comment: commentLines.join('\n'), + type: CommentType.user, + owner: APP_ID, + }, + }) + ) + ); + } return res.ok({ body: {