diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 86e81d78f69fd..2895d7d376666 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -74,7 +74,7 @@ Arguments: | userCanCrud | `boolean;` user permissions to crud | | owner | `string[];` owner ids of the cases | | basePath | `string;` path to mount the Cases router on top of | -| useFetchAlertData | `(alertIds: string[]) => [boolean, Record];` fetch alerts | +| useFetchAlertData | `(alertIds: string[]) => [boolean, Record];` fetch alerts | | disableAlerts? | `boolean` (default: false) flag to not show alerts information | | actionsNavigation? | CasesNavigation | | ruleDetailsNavigation? | CasesNavigation | diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 008d4b9245f63..f6bfb510cab81 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -253,3 +253,5 @@ export interface Ecs { } export type CaseActionConnector = ActionConnector; + +export type UseFetchAlertData = (alertIds: string[]) => [boolean, Record]; diff --git a/x-pack/plugins/cases/public/components/app/types.ts b/x-pack/plugins/cases/public/components/app/types.ts index ebe174c095fa7..1df4d54188ec0 100644 --- a/x-pack/plugins/cases/public/components/app/types.ts +++ b/x-pack/plugins/cases/public/components/app/types.ts @@ -6,7 +6,7 @@ */ import { MutableRefObject } from 'react'; -import { Ecs, CaseViewRefreshPropInterface } from '../../../common/ui/types'; +import { CaseViewRefreshPropInterface, UseFetchAlertData } from '../../../common/ui/types'; import { CasesNavigation } from '../links'; import { CasesTimelineIntegration } from '../timeline_context'; @@ -15,7 +15,7 @@ export interface CasesRoutesProps { actionsNavigation?: CasesNavigation; ruleDetailsNavigation?: CasesNavigation; showAlertDetails?: (alertId: string, index: string) => void; - useFetchAlertData: (alertIds: string[]) => [boolean, Record]; + useFetchAlertData: UseFetchAlertData; /** * A React `Ref` that Exposes data refresh callbacks. * **NOTE**: Do not hold on to the `.current` object, as it could become stale diff --git a/x-pack/plugins/cases/public/components/case_view/types.ts b/x-pack/plugins/cases/public/components/case_view/types.ts index 69d05918e182f..3d436a7db3186 100644 --- a/x-pack/plugins/cases/public/components/case_view/types.ts +++ b/x-pack/plugins/cases/public/components/case_view/types.ts @@ -7,15 +7,16 @@ import { MutableRefObject } from 'react'; import { CasesTimelineIntegration } from '../timeline_context'; import { CasesNavigation } from '../links'; -import { CaseViewRefreshPropInterface, Ecs, Case } from '../../../common'; +import { CaseViewRefreshPropInterface, Case } from '../../../common'; import { UseGetCase } from '../../containers/use_get_case'; +import { UseFetchAlertData } from '../../../common/ui'; export interface CaseViewBaseProps { onComponentInitialized?: () => void; actionsNavigation?: CasesNavigation; ruleDetailsNavigation?: CasesNavigation; showAlertDetails?: (alertId: string, index: string) => void; - useFetchAlertData: (alertIds: string[]) => [boolean, Record]; + useFetchAlertData: UseFetchAlertData; /** * A React `Ref` that Exposes data refresh callbacks. * **NOTE**: Do not hold on to the `.current` object, as it could become stale diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx index f0533f62de234..95e996498554a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx @@ -18,7 +18,6 @@ import { UserActionUsernameWithAvatar } from '../avatar_username'; import { AlertCommentEvent } from './alert_event'; import { UserActionCopyLink } from '../copy_link'; import { UserActionShowAlert } from './show_alert'; -import { Ecs } from '../../../containers/types'; type BuilderArgs = Pick< UserActionBuilderArgs, @@ -49,7 +48,7 @@ export const createAlertAttachmentUserActionBuilder = ({ return []; } - const alertField: Ecs | undefined = alertData[alertId]; + const alertField: unknown | undefined = alertData[alertId]; const ruleId = getRuleId(comment, alertField); const ruleName = getRuleName(comment, alertField); @@ -101,7 +100,7 @@ const getFirstItem = (items?: string | string[] | null): string | null => { return Array.isArray(items) ? items[0] : items ?? null; }; -export const getRuleId = (comment: BuilderArgs['comment'], alertData?: Ecs): string | null => +export const getRuleId = (comment: BuilderArgs['comment'], alertData?: unknown): string | null => getRuleField({ commentRuleField: comment?.rule?.id, alertData, @@ -109,7 +108,7 @@ export const getRuleId = (comment: BuilderArgs['comment'], alertData?: Ecs): str kibanaAlertFieldPath: ALERT_RULE_UUID, }); -export const getRuleName = (comment: BuilderArgs['comment'], alertData?: Ecs): string | null => +export const getRuleName = (comment: BuilderArgs['comment'], alertData?: unknown): string | null => getRuleField({ commentRuleField: comment?.rule?.name, alertData, @@ -124,7 +123,7 @@ const getRuleField = ({ kibanaAlertFieldPath, }: { commentRuleField: string | string[] | null | undefined; - alertData: Ecs | undefined; + alertData: unknown | undefined; signalRuleFieldPath: string; kibanaAlertFieldPath: string; }): string | null => { diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 2426e74f3e7b6..d05f378cd205a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -22,7 +22,6 @@ import { } from '../../containers/mock'; import { UserActions } from '.'; import { TestProviders } from '../../common/mock'; -import { Ecs } from '../../../common/ui/types'; import { Actions } from '../../../common/api'; const fetchUserActions = jest.fn(); @@ -46,7 +45,7 @@ const defaultProps = { statusActionButton: null, updateCase, userCanCrud: true, - useFetchAlertData: (): [boolean, Record] => [ + useFetchAlertData: (): [boolean, Record] => [ false, { 'some-id': { _id: 'some-id' } }, ], diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index 33394017a4698..d980ef029e447 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -98,10 +98,13 @@ export const UserActions = React.memo( const [initLoading, setInitLoading] = useState(true); const currentUser = useCurrentUser(); - const [loadingAlertData, manualAlertsData] = useFetchAlertData( - getManualAlertIdsWithNoRuleId(caseData.comments) + const alertIdsWithoutRuleInfo = useMemo( + () => getManualAlertIdsWithNoRuleId(caseData.comments), + [caseData.comments] ); + const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo); + const { loadingCommentIds, commentRefs, diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index 80657cc90cba9..ceab17fc18ef6 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -8,7 +8,7 @@ import { EuiCommentProps } from '@elastic/eui'; import { SnakeToCamelCase } from '../../../common/types'; import { ActionTypes, UserActionWithResponse } from '../../../common/api'; -import { Case, CaseUserActions, Ecs, Comment } from '../../containers/types'; +import { Case, CaseUserActions, Comment, UseFetchAlertData } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { AddCommentRefObject } from '../add_comment'; import { UserActionMarkdownRefObject } from './markdown_form'; @@ -31,7 +31,7 @@ export interface UserActionTreeProps { renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; statusActionButton: JSX.Element | null; updateCase: (newCase: Case) => void; - useFetchAlertData: (alertIds: string[]) => [boolean, Record]; + useFetchAlertData: UseFetchAlertData; userCanCrud: boolean; } @@ -52,7 +52,7 @@ export interface UserActionBuilderArgs { selectedOutlineCommentId: string; loadingCommentIds: string[]; loadingAlertData: boolean; - alertData: Record; + alertData: Record; handleOutlineComment: (id: string) => void; handleManageMarkdownEditId: (id: string) => void; handleSaveComment: ({ id, version }: { id: string; version: string }, content: string) => void; diff --git a/x-pack/plugins/observability/public/pages/cases/cases.tsx b/x-pack/plugins/observability/public/pages/cases/cases.tsx index 19eb16a3bd52b..4b9810421ba5f 100644 --- a/x-pack/plugins/observability/public/pages/cases/cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/cases.tsx @@ -8,10 +8,11 @@ import React, { Suspense, useCallback, useState } from 'react'; import { useKibana } from '../../utils/kibana_react'; -import { useFetchAlertData, useFetchAlertDetail } from './helpers'; import { CASES_OWNER, CASES_PATH } from './constants'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { LazyAlertsFlyout } from '../..'; +import { useFetchAlertDetail } from './use_fetch_alert_detail'; +import { useFetchAlertData } from './use_fetch_alert_data'; interface CasesProps { userCanCrud: boolean; diff --git a/x-pack/plugins/observability/public/pages/cases/helpers.ts b/x-pack/plugins/observability/public/pages/cases/helpers.ts deleted file mode 100644 index f4bc5af7f604d..0000000000000 --- a/x-pack/plugins/observability/public/pages/cases/helpers.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { useEffect, useState } from 'react'; -import { isEmpty } from 'lodash'; -import { usePluginContext } from '../../hooks/use_plugin_context'; -import { TopAlert, parseAlert } from '../../pages/alerts/'; -import { useKibana } from '../../utils/kibana_react'; -import { Ecs } from '../../../../cases/common'; - -// no alerts in observability so far -// dummy hook for now as hooks cannot be called conditionally -export const useFetchAlertData = (): [boolean, Record] => [false, {}]; - -export const useFetchAlertDetail = (alertId: string): [boolean, TopAlert | null] => { - const { http } = useKibana().services; - const [loading, setLoading] = useState(false); - const { observabilityRuleTypeRegistry } = usePluginContext(); - const [alert, setAlert] = useState(null); - - useEffect(() => { - const abortCtrl = new AbortController(); - const fetchData = async () => { - try { - setLoading(true); - const response = await http.get>('/internal/rac/alerts', { - query: { - id: alertId, - }, - }); - if (response) { - const parsedAlert = parseAlert(observabilityRuleTypeRegistry)(response); - setAlert(parsedAlert); - setLoading(false); - } - } catch (error) { - setAlert(null); - } - }; - - if (!isEmpty(alertId) && loading === false && alert === null) { - fetchData(); - } - return () => { - abortCtrl.abort(); - }; - }, [http, alertId, alert, loading, observabilityRuleTypeRegistry]); - - return [loading, alert]; -}; diff --git a/x-pack/plugins/observability/public/pages/cases/use_data_fetcher.ts b/x-pack/plugins/observability/public/pages/cases/use_data_fetcher.ts new file mode 100644 index 0000000000000..2ee585b4f81ad --- /dev/null +++ b/x-pack/plugins/observability/public/pages/cases/use_data_fetcher.ts @@ -0,0 +1,65 @@ +/* + * 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 { useState, useMemo, useEffect } from 'react'; + +import { HttpSetup } from 'kibana/public'; +import { useKibana } from '../../utils/kibana_react'; + +type DataFetcher = (params: T, ctrl: AbortController, http: HttpSetup) => Promise; + +export const useDataFetcher = ({ + paramsForApiCall, + initialDataState, + executeApiCall, + shouldExecuteApiCall, +}: { + paramsForApiCall: ApiCallParams; + initialDataState: AlertDataType; + executeApiCall: DataFetcher; + shouldExecuteApiCall: (params: ApiCallParams) => boolean; +}) => { + const { http } = useKibana().services; + const [loading, setLoading] = useState(false); + const [data, setData] = useState(initialDataState); + + const { fetch, cancel } = useMemo(() => { + const abortController = new AbortController(); + let isCanceled = false; + + return { + fetch: async () => { + if (shouldExecuteApiCall(paramsForApiCall)) { + setLoading(true); + + const results = await executeApiCall(paramsForApiCall, abortController, http); + if (!isCanceled) { + setLoading(false); + setData(results); + } + } + }, + cancel: () => { + isCanceled = true; + abortController.abort(); + }, + }; + }, [executeApiCall, http, paramsForApiCall, shouldExecuteApiCall]); + + useEffect(() => { + fetch(); + + return () => { + cancel(); + }; + }, [fetch, cancel]); + + return { + loading, + data, + }; +}; diff --git a/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_data.test.ts b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_data.test.ts new file mode 100644 index 0000000000000..815bafc45c97c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_data.test.ts @@ -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 { act, renderHook } from '@testing-library/react-hooks'; +import { kibanaStartMock } from '../../utils/kibana_react.mock'; +import { useFetchAlertData } from './use_fetch_alert_data'; + +const mockUseKibanaReturnValue = kibanaStartMock.startContract(); + +jest.mock('../../utils/kibana_react', () => ({ + __esModule: true, + useKibana: jest.fn(() => mockUseKibanaReturnValue), +})); + +describe('useFetchAlertData', () => { + const testIds = ['123']; + + beforeEach(() => { + mockUseKibanaReturnValue.services.http.post.mockImplementation(async () => ({ + hits: { + hits: [ + { + _id: '123', + _index: 'index', + _source: { + testField: 'test', + }, + }, + ], + }, + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('initially is not loading and does not have data', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook]>( + () => useFetchAlertData(testIds) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, {}]); + }); + }); + + it('returns no data when an error occurs', async () => { + mockUseKibanaReturnValue.services.http.post.mockImplementation(async () => { + throw new Error('an http error'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook]>( + () => useFetchAlertData(testIds) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, {}]); + }); + }); + + it('retrieves the alert data', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook]>( + () => useFetchAlertData(testIds) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual([ + false, + { '123': { _id: '123', _index: 'index', testField: 'test' } }, + ]); + }); + }); + + it('does not populate the results when the request is canceled', async () => { + await act(async () => { + const { result, waitForNextUpdate, unmount } = renderHook< + string, + [boolean, Record] + >(() => useFetchAlertData(testIds)); + + await waitForNextUpdate(); + unmount(); + + expect(result.current).toEqual([false, {}]); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_data.ts b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_data.ts new file mode 100644 index 0000000000000..1e47094be866c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_data.ts @@ -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 { useCallback, useMemo } from 'react'; +import { isEmpty } from 'lodash'; + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { HttpSetup } from 'kibana/public'; +import { BASE_RAC_ALERTS_API_PATH } from '../../../../rule_registry/common/constants'; +import { useDataFetcher } from './use_data_fetcher'; + +export const useFetchAlertData = (alertIds: string[]): [boolean, Record] => { + const validIds = useMemo(() => getValidValues(alertIds), [alertIds]); + const shouldExecuteApiCall = useCallback((ids: string[]) => ids.length > 0, []); + + const { loading, data: alerts } = useDataFetcher | undefined>({ + paramsForApiCall: validIds, + initialDataState: undefined, + executeApiCall: fetchAlerts, + shouldExecuteApiCall, + }); + + return [loading, alerts ?? {}]; +}; + +const fetchAlerts = async ( + ids: string[], + abortCtrl: AbortController, + http: HttpSetup +): Promise | undefined> => { + try { + const response = await http.post>>( + `${BASE_RAC_ALERTS_API_PATH}/find`, + { + body: JSON.stringify({ + query: { + ids: { + values: ids, + }, + }, + track_total_hits: false, + size: 10000, + }), + signal: abortCtrl.signal, + } + ); + + if (response) { + return getAlertsGroupedById(response); + } + } catch (error) { + // ignore the failure + } +}; + +const getAlertsGroupedById = ( + data: estypes.SearchResponse> +): Record => { + return data.hits.hits.reduce( + (acc, { _id, _index, _source }) => ({ + ...acc, + [_id]: { + _id, + _index, + ..._source, + }, + }), + {} + ); +}; + +const getValidValues = (ids: string[]): string[] => { + return ids.filter((id) => !isEmpty(id)); +}; diff --git a/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_detail.test.ts b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_detail.test.ts new file mode 100644 index 0000000000000..55c154919cb10 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_detail.test.ts @@ -0,0 +1,161 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { kibanaStartMock } from '../../utils/kibana_react.mock'; +import { TopAlert } from '../alerts'; +import * as pluginContext from '../../hooks/use_plugin_context'; +import { createObservabilityRuleTypeRegistryMock } from '../..'; +import { PluginContextValue } from '../../context/plugin_context'; +import { useFetchAlertDetail } from './use_fetch_alert_detail'; + +const mockUseKibanaReturnValue = kibanaStartMock.startContract(); + +jest.mock('../../utils/kibana_react', () => ({ + __esModule: true, + useKibana: jest.fn(() => mockUseKibanaReturnValue), +})); + +describe('useFetchAlertDetail', () => { + const getResult = { + 'kibana.alert.rule.category': 'Metric threshold', + 'kibana.alert.rule.consumer': 'infrastructure', + 'kibana.alert.rule.execution.uuid': 'e62c418d-734d-47e7-bbeb-e6f182f5fb45', + 'kibana.alert.rule.name': 'A super rule', + 'kibana.alert.rule.producer': 'infrastructure', + 'kibana.alert.rule.rule_type_id': 'metrics.alert.threshold', + 'kibana.alert.rule.uuid': '69411af0-82a2-11ec-8139-c1568734434e', + 'kibana.space_ids': ['default'], + 'kibana.alert.rule.tags': [], + '@timestamp': '2022-01-31T18:20:57.204Z', + 'kibana.alert.reason': 'Document count reported no data in the last 1 hour for all hosts', + 'kibana.alert.duration.us': 13793555000, + 'kibana.alert.instance.id': '*', + 'kibana.alert.start': '2022-01-31T14:31:03.649Z', + 'kibana.alert.uuid': '73c0d0cd-2df4-4550-862c-1d447e9c1db2', + 'kibana.alert.status': 'active', + 'kibana.alert.workflow_status': 'open', + 'event.kind': 'signal', + 'event.action': 'active', + 'kibana.version': '8.1.0', + tags: [], + }; + + const id = '123'; + const ruleType = createObservabilityRuleTypeRegistryMock(); + + beforeEach(() => { + mockUseKibanaReturnValue.services.http.get.mockImplementation(async () => getResult); + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation( + () => + ({ + observabilityRuleTypeRegistry: ruleType, + } as unknown as PluginContextValue) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('initially is not loading and does not have data', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useFetchAlertDetail(id) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, null]); + }); + }); + + it('returns no data when an error occurs', async () => { + mockUseKibanaReturnValue.services.http.get.mockImplementation(async () => { + throw new Error('an http error'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useFetchAlertDetail('123') + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, null]); + }); + }); + + it('retrieves the alert data', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useFetchAlertDetail(id) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toMatchInlineSnapshot(` + Array [ + false, + Object { + "0": "a", + "1": " ", + "2": "r", + "3": "e", + "4": "a", + "5": "s", + "6": "o", + "7": "n", + "active": true, + "fields": Object { + "@timestamp": "2022-01-31T18:20:57.204Z", + "event.action": "active", + "event.kind": "signal", + "kibana.alert.duration.us": 13793555000, + "kibana.alert.instance.id": "*", + "kibana.alert.reason": "Document count reported no data in the last 1 hour for all hosts", + "kibana.alert.rule.category": "Metric threshold", + "kibana.alert.rule.consumer": "infrastructure", + "kibana.alert.rule.execution.uuid": "e62c418d-734d-47e7-bbeb-e6f182f5fb45", + "kibana.alert.rule.name": "A super rule", + "kibana.alert.rule.producer": "infrastructure", + "kibana.alert.rule.rule_type_id": "metrics.alert.threshold", + "kibana.alert.rule.tags": Array [], + "kibana.alert.rule.uuid": "69411af0-82a2-11ec-8139-c1568734434e", + "kibana.alert.start": "2022-01-31T14:31:03.649Z", + "kibana.alert.status": "active", + "kibana.alert.uuid": "73c0d0cd-2df4-4550-862c-1d447e9c1db2", + "kibana.alert.workflow_status": "open", + "kibana.space_ids": Array [ + "default", + ], + "kibana.version": "8.1.0", + "tags": Array [], + }, + "link": undefined, + "reason": "Document count reported no data in the last 1 hour for all hosts", + "start": 1643639463649, + }, + ] + `); + }); + }); + + it('does not populate the results when the request is canceled', async () => { + await act(async () => { + const { result, waitForNextUpdate, unmount } = renderHook( + () => useFetchAlertDetail('123') + ); + + await waitForNextUpdate(); + unmount(); + + expect(result.current).toEqual([false, null]); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_detail.ts b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_detail.ts new file mode 100644 index 0000000000000..4fe66a754056b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/cases/use_fetch_alert_detail.ts @@ -0,0 +1,66 @@ +/* + * 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 { isEmpty } from 'lodash'; + +import { HttpSetup } from 'kibana/public'; +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { TopAlert, parseAlert } from '../../pages/alerts/'; +import { BASE_RAC_ALERTS_API_PATH } from '../../../../rule_registry/common/constants'; +import { ObservabilityRuleTypeRegistry } from '../..'; +import { useDataFetcher } from './use_data_fetcher'; + +interface AlertDetailParams { + id: string; + ruleType: ObservabilityRuleTypeRegistry; +} + +export const useFetchAlertDetail = (id: string): [boolean, TopAlert | null] => { + const { observabilityRuleTypeRegistry } = usePluginContext(); + + const params = useMemo( + () => ({ id, ruleType: observabilityRuleTypeRegistry }), + [id, observabilityRuleTypeRegistry] + ); + + const shouldExecuteApiCall = useCallback( + (apiCallParams: AlertDetailParams) => !isEmpty(apiCallParams.id), + [] + ); + + const { loading, data: alert } = useDataFetcher({ + paramsForApiCall: params, + initialDataState: null, + executeApiCall: fetchAlert, + shouldExecuteApiCall, + }); + + return [loading, alert]; +}; + +const fetchAlert = async ( + params: AlertDetailParams, + abortController: AbortController, + http: HttpSetup +): Promise => { + const { id, ruleType } = params; + try { + const response = await http.get>(BASE_RAC_ALERTS_API_PATH, { + query: { + id, + }, + signal: abortController.signal, + }); + if (response !== undefined) { + return parseAlert(ruleType)(response); + } + } catch (error) { + // ignore error for retrieving alert + } + + return null; +}; diff --git a/x-pack/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts b/x-pack/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts index d75e7324c9afe..d39549a5bbac8 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts +++ b/x-pack/plugins/security_solution/public/cases/pages/use_fetch_alert_data.ts @@ -12,7 +12,7 @@ import { useQueryAlerts } from '../../detections/containers/detection_engine/ale import { Ecs } from '../../../../cases/common'; import { buildAlertsQuery, formatAlertToEcsSignal, SignalHit } from '../../common/utils/alerts'; -export const useFetchAlertData = (alertIds: string[]): [boolean, Record] => { +export const useFetchAlertData = (alertIds: string[]): [boolean, Record] => { const { selectedPatterns } = useSourcererDataView(SourcererScopeName.detections); const alertsQuery = useMemo(() => buildAlertsQuery(alertIds), [alertIds]);