diff --git a/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.test.tsx index 03f319b9cc97b..55811743e4d45 100644 --- a/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.test.tsx @@ -31,16 +31,15 @@ const DASHBOARD_TABLE_ITEMS = [ }, ]; -const mockUseSecurityDashboardsTableItems = jest.fn(() => DASHBOARD_TABLE_ITEMS); +const mockUseSecurityDashboardsTableItems = jest.fn(() => ({ + items: DASHBOARD_TABLE_ITEMS, + isLoading: false, +})); jest.mock('../../containers/dashboards/use_security_dashboards_table', () => { const actual = jest.requireActual('../../containers/dashboards/use_security_dashboards_table'); return { ...actual, - useSecurityDashboardsTable: () => { - const columns = actual.useSecurityDashboardsTableColumns(); - const items = mockUseSecurityDashboardsTableItems(); - return { columns, items }; - }, + useSecurityDashboardsTableItems: () => mockUseSecurityDashboardsTableItems(), }; }); diff --git a/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.tsx b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.tsx index ee99a9966ab8a..ba933becda53a 100644 --- a/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboards_table.tsx @@ -9,7 +9,19 @@ import React, { useEffect, useMemo, useState } from 'react'; import { debounce } from 'lodash'; import type { Search } from '@elastic/eui'; import { EuiInMemoryTable } from '@elastic/eui'; -import { useSecurityDashboardsTable } from '../../containers/dashboards/use_security_dashboards_table'; +import { i18n } from '@kbn/i18n'; +import { + useSecurityDashboardsTableItems, + useSecurityDashboardsTableColumns, +} from '../../containers/dashboards/use_security_dashboards_table'; +import { useAppToasts } from '../../hooks/use_app_toasts'; + +export const DASHBOARDS_QUERY_ERROR = i18n.translate( + 'xpack.securitySolution.dashboards.queryError', + { + defaultMessage: 'Error retrieving security dashboards', + } +); /** wait this many ms after the user completes typing before applying the filter input */ const INPUT_TIMEOUT = 250; @@ -22,7 +34,10 @@ const DASHBOARDS_TABLE_SORTING = { } as const; export const DashboardsTable: React.FC = () => { - const { items, columns } = useSecurityDashboardsTable(); + const { items, isLoading, error } = useSecurityDashboardsTableItems(); + const columns = useSecurityDashboardsTableColumns(); + const { addError } = useAppToasts(); + const [filteredItems, setFilteredItems] = useState(items); const [searchQuery, setSearchQuery] = useState(''); @@ -52,6 +67,12 @@ export const DashboardsTable: React.FC = () => { } }, [items, searchQuery]); + useEffect(() => { + if (error) { + addError(error, { title: DASHBOARDS_QUERY_ERROR }); + } + }, [error, addError]); + return ( { search={search} pagination={true} sorting={DASHBOARDS_TABLE_SORTING} + loading={isLoading} /> ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts index 06732f83f2af7..5fa33c8becfca 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useState, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants'; import { anomaliesTableData } from '../api/anomalies_table_data'; @@ -14,6 +14,7 @@ import type { InfluencerInput, Anomalies, CriteriaFields } from '../types'; import * as i18n from './translations'; import { useTimeZone, useUiSetting$ } from '../../../lib/kibana'; import { useAppToasts } from '../../../hooks/use_app_toasts'; +import { useFetch, REQUEST_NAMES } from '../../../hooks/use_fetch'; import { useMlCapabilities } from '../hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; @@ -63,11 +64,9 @@ export const useAnomaliesTableData = ({ jobIds, aggregationInterval, }: Args): Return => { - const [tableData, setTableData] = useState(null); const mlCapabilities = useMlCapabilities(); const isMlUser = hasMlUserPermissions(mlCapabilities); - const [loading, setLoading] = useState(true); const { addError } = useAppToasts(); const timeZone = useTimeZone(); const [anomalyScore] = useUiSetting$(DEFAULT_ANOMALY_SCORE); @@ -75,62 +74,35 @@ export const useAnomaliesTableData = ({ const startDateMs = useMemo(() => new Date(startDate).getTime(), [startDate]); const endDateMs = useMemo(() => new Date(endDate).getTime(), [endDate]); - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - setLoading(true); + const { + fetch, + data = null, + isLoading, + error, + } = useFetch(REQUEST_NAMES.ANOMALIES_TABLE, anomaliesTableData, { disabled: skip }); - async function fetchAnomaliesTableData( - influencersInput: InfluencerInput[], - criteriaFieldsInput: CriteriaFields[], - earliestMs: number, - latestMs: number - ) { - if (skip) { - setLoading(false); - } else if (isMlUser && !skip && jobIds.length > 0) { - try { - const data = await anomaliesTableData( - { - jobIds, - criteriaFields: criteriaFieldsInput, - influencersFilterQuery: filterQuery, - aggregationInterval, - threshold: getThreshold(anomalyScore, threshold), - earliestMs, - latestMs, - influencers: influencersInput, - dateFormatTz: timeZone, - maxRecords: 500, - maxExamples: 10, - }, - abortCtrl.signal - ); - if (isSubscribed) { - setTableData(data); - setLoading(false); - } - } catch (error) { - if (isSubscribed) { - addError(error, { title: i18n.SIEM_TABLE_FETCH_FAILURE }); - setLoading(false); - } - } - } else if (!isMlUser && isSubscribed) { - setLoading(false); - } else if (jobIds.length === 0 && isSubscribed) { - setLoading(false); - } else if (isSubscribed) { - setTableData(null); - setLoading(true); - } + useEffect(() => { + if (error) { + addError(error, { title: i18n.SIEM_TABLE_FETCH_FAILURE }); } + }, [error, addError]); - fetchAnomaliesTableData(influencers, criteriaFields, startDateMs, endDateMs); - return () => { - isSubscribed = false; - abortCtrl.abort(); - }; + useEffect(() => { + if (isMlUser && jobIds.length > 0) { + fetch({ + jobIds, + criteriaFields, + influencersFilterQuery: filterQuery, + aggregationInterval, + threshold: getThreshold(anomalyScore, threshold), + earliestMs: startDateMs, + latestMs: endDateMs, + influencers, + dateFormatTz: timeZone, + maxRecords: 500, + maxExamples: 10, + }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ // eslint-disable-next-line react-hooks/exhaustive-deps @@ -139,12 +111,11 @@ export const useAnomaliesTableData = ({ influencersOrCriteriaToString(criteriaFields), startDateMs, endDateMs, - skip, isMlUser, aggregationInterval, // eslint-disable-next-line react-hooks/exhaustive-deps jobIds.sort().join(), ]); - return [loading, tableData]; + return [isLoading, data]; }; diff --git a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx index 43da2bd760b3b..bc02fb3b205d6 100644 --- a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx @@ -99,9 +99,9 @@ describe('Security Dashboards Table hooks', () => { it('should return a memoized value when rerendered', async () => { const { result, rerender } = await renderUseSecurityDashboardsTableItems(); - const result1 = result.current; + const result1 = result.current.items; act(() => rerender()); - const result2 = result.current; + const result2 = result.current.items; expect(result1).toBe(result2); }); @@ -110,7 +110,7 @@ describe('Security Dashboards Table hooks', () => { const { result } = await renderUseSecurityDashboardsTableItems(); const [dashboard1, dashboard2] = DASHBOARDS_RESPONSE; - expect(result.current).toStrictEqual([ + expect(result.current.items).toStrictEqual([ { ...dashboard1, title: dashboard1.attributes.title, @@ -148,7 +148,7 @@ describe('Security Dashboards Table hooks', () => { }); it('returns a memoized value', async () => { - const { result, rerender } = await renderUseSecurityDashboardsTableItems(); + const { result, rerender } = renderUseDashboardsTableColumns(); const result1 = result.current; act(() => rerender()); @@ -163,7 +163,7 @@ describe('Security Dashboards Table hooks', () => { const { result: columnsResult } = renderUseDashboardsTableColumns(); const result = render( - , + , { wrapper: TestProviders, } diff --git a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx index 0fe9bbede4153..71fad3639dc5c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useEffect, useMemo, useCallback } from 'react'; import type { MouseEventHandler } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; import type { SavedObjectAttributes } from '@kbn/securitysolution-io-ts-alerting-types'; @@ -14,6 +14,7 @@ import { getSecurityDashboards } from './utils'; import { LinkAnchor } from '../../components/links'; import { useKibana, useNavigateTo } from '../../lib/kibana'; import * as i18n from './translations'; +import { useFetch, REQUEST_NAMES } from '../../hooks/use_fetch'; export interface DashboardTableItem extends SavedObject { title?: string; @@ -23,37 +24,33 @@ export interface DashboardTableItem extends SavedObject { const EMPTY_DESCRIPTION = '-' as const; export const useSecurityDashboardsTableItems = () => { - const [dashboardItems, setDashboardItems] = useState([]); const { savedObjects: { client: savedObjectsClient }, } = useKibana().services; - useEffect(() => { - let ignore = false; - const fetchDashboards = async () => { - if (savedObjectsClient) { - const securityDashboards = await getSecurityDashboards(savedObjectsClient); - - if (!ignore) { - setDashboardItems( - securityDashboards.map((securityDashboard) => ({ - ...securityDashboard, - title: securityDashboard.attributes.title?.toString() ?? undefined, - description: securityDashboard.attributes.description?.toString() ?? undefined, - })) - ); - } - } - }; + const { fetch, data, isLoading, error } = useFetch( + REQUEST_NAMES.SECURITY_DASHBOARDS, + getSecurityDashboards + ); - fetchDashboards(); + useEffect(() => { + if (savedObjectsClient) { + fetch(savedObjectsClient); + } + }, [fetch, savedObjectsClient]); - return () => { - ignore = true; - }; - }, [savedObjectsClient]); + const items = useMemo(() => { + if (!data) { + return []; + } + return data.map((securityDashboard) => ({ + ...securityDashboard, + title: securityDashboard.attributes.title?.toString() ?? undefined, + description: securityDashboard.attributes.description?.toString() ?? undefined, + })); + }, [data]); - return dashboardItems; + return { items, isLoading, error }; }; export const useSecurityDashboardsTableColumns = (): Array< @@ -104,9 +101,3 @@ export const useSecurityDashboardsTableColumns = (): Array< return columns; }; - -export const useSecurityDashboardsTable = () => { - const items = useSecurityDashboardsTableItems(); - const columns = useSecurityDashboardsTableColumns(); - return { items, columns }; -}; diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx index 40a473de30687..41383d4a0eb72 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx @@ -24,6 +24,7 @@ jest.mock('react-redux', () => { }; }); jest.mock('../../lib/kibana'); +jest.mock('../../lib/apm/use_track_http_request'); describe('source/index.tsx', () => { describe('getAllBrowserFields', () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx index 51ad895b56f0c..c259f8843263a 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx @@ -25,6 +25,8 @@ import { sourcererActions } from '../../store/sourcerer'; import * as i18n from './translations'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { getSourcererDataView } from '../sourcerer/api'; +import { useTrackHttpRequest } from '../../lib/apm/use_track_http_request'; +import { APP_UI_ID } from '../../../../common/constants'; export type IndexFieldSearch = (param: { dataViewId: string; @@ -86,6 +88,7 @@ export const useDataView = (): { const searchSubscription$ = useRef>({}); const dispatch = useDispatch(); const { addError, addWarning } = useAppToasts(); + const { startTracking } = useTrackHttpRequest(); const setLoading = useCallback( ({ id, loading }: { id: string; loading: boolean }) => { @@ -112,6 +115,9 @@ export const useDataView = (): { [dataViewId]: new AbortController(), }; setLoading({ id: dataViewId, loading: true }); + + const { endTracking } = startTracking({ name: `${APP_UI_ID} indexFieldsSearch` }); + if (needToBeInit) { const dataViewToUpdate = await getSourcererDataView( dataViewId, @@ -139,6 +145,8 @@ export const useDataView = (): { .subscribe({ next: async (response) => { if (isCompleteResponse(response)) { + endTracking('success'); + const patternString = response.indicesExist.sort().join(); if (needToBeInit && scopeId) { dispatch( @@ -167,6 +175,7 @@ export const useDataView = (): { }) ); } else if (isErrorResponse(response)) { + endTracking('invalid'); setLoading({ id: dataViewId, loading: false }); addWarning(i18n.ERROR_BEAT_FIELDS); } @@ -174,6 +183,7 @@ export const useDataView = (): { resolve(); }, error: (msg) => { + endTracking('error'); if (msg.message === DELETED_SECURITY_SOLUTION_DATA_VIEW) { // reload app if security solution data view is deleted return location.reload(); @@ -200,7 +210,7 @@ export const useDataView = (): { } return asyncSearch(); }, - [addError, addWarning, data.search, dispatch, setLoading] + [addError, addWarning, data.search, dispatch, setLoading, startTracking] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx index b99547db88ba4..9be9c1266c1c9 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -46,6 +46,7 @@ const mockRouteSpy: RouteSpyState = { }; const mockDispatch = jest.fn(); const mockUseUserInfo = useUserInfo as jest.Mock; +jest.mock('../../lib/apm/use_track_http_request'); jest.mock('../../../detections/components/user_info'); jest.mock('./api'); jest.mock('../../utils/global_query_string'); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_fetch/index.ts b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/index.ts new file mode 100644 index 0000000000000..fae594e8414ce --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { useFetch } from './use_fetch'; +export { REQUEST_NAMES } from './request_names'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_fetch/request_names.ts b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/request_names.ts new file mode 100644 index 0000000000000..cadfd2a68fa32 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/request_names.ts @@ -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 { APP_UI_ID } from '../../../../common/constants'; + +export const REQUEST_NAMES = { + SECURITY_DASHBOARDS: `${APP_UI_ID} fetch security dashboards`, + ANOMALIES_TABLE: `${APP_UI_ID} fetch anomalies table data`, +} as const; + +export type RequestName = typeof REQUEST_NAMES[keyof typeof REQUEST_NAMES]; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_fetch/use_fetch.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/use_fetch.test.tsx new file mode 100644 index 0000000000000..13d758ed7a596 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/use_fetch.test.tsx @@ -0,0 +1,295 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import type { RequestName } from './request_names'; +import type { OptionsParam, RequestFnParam, Result } from './use_fetch'; +import { useFetch } from './use_fetch'; + +export const mockEndTracking = jest.fn(); +export const mockStartTracking = jest.fn(() => ({ endTracking: mockEndTracking })); +jest.mock('../../lib/apm/use_track_http_request', () => ({ + useTrackHttpRequest: jest.fn(() => ({ + startTracking: mockStartTracking, + })), +})); + +const requestName = 'test name' as RequestName; + +const parameters = { + some: 'fakeParam', +}; +type Parameters = typeof parameters; + +const response = 'someData'; +const mockFetchFn = jest.fn(async (_: Parameters) => response); + +type UseFetchParams = [RequestName, RequestFnParam, OptionsParam]; + +const abortController = new AbortController(); + +const renderUseFetch = (options?: OptionsParam) => + renderHook>(() => + useFetch(requestName, mockFetchFn, options) + ); + +describe('useFetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result } = renderUseFetch(); + + const { data, isLoading, error } = result.current; + + expect(data).toEqual(undefined); + expect(isLoading).toEqual(false); + expect(error).toEqual(undefined); + + expect(mockFetchFn).not.toHaveBeenCalled(); + }); + + it('should call fetch', async () => { + const { result, waitForNextUpdate } = renderUseFetch(); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toEqual(false); + expect(result.current.error).toEqual(undefined); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(result.current.data).toEqual(response); + expect(result.current.isLoading).toEqual(false); + expect(result.current.error).toEqual(undefined); + expect(mockFetchFn).toHaveBeenCalledWith(parameters, abortController.signal); + }); + + it('should call fetch if initialParameters option defined', async () => { + const { result, waitForNextUpdate } = renderUseFetch({ initialParameters: parameters }); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toEqual(true); + expect(result.current.error).toEqual(undefined); + + expect(mockFetchFn).toHaveBeenCalledWith(parameters, abortController.signal); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(result.current.data).toEqual(response); + expect(result.current.isLoading).toEqual(false); + expect(result.current.error).toEqual(undefined); + }); + + it('should refetch with same parameters', async () => { + const { result, waitForNextUpdate } = renderUseFetch({ initialParameters: parameters }); + + expect(mockFetchFn).toHaveBeenCalledTimes(1); + expect(mockFetchFn).toHaveBeenCalledWith(parameters, abortController.signal); + + await act(async () => { + result.current.refetch(); + await waitForNextUpdate(); + }); + + expect(mockFetchFn).toHaveBeenCalledTimes(1); + expect(mockFetchFn).toHaveBeenCalledWith(parameters, abortController.signal); + }); + + it('should not call fetch if disabled option defined', async () => { + const { result, waitForNextUpdate } = renderUseFetch({ + initialParameters: parameters, + disabled: true, + }); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toEqual(true); + expect(result.current.error).toEqual(undefined); + + expect(mockFetchFn).not.toHaveBeenCalled(); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toEqual(true); + expect(result.current.error).toEqual(undefined); + expect(mockFetchFn).not.toHaveBeenCalled(); + }); + + it('should ignore state change if component is unmounted', async () => { + mockFetchFn.mockImplementationOnce(async () => { + unmount(); + return response; + }); + + const { result, waitForNextUpdate, unmount } = renderUseFetch(); + + expect(result.current.data).toEqual(undefined); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(result.current.data).toEqual(undefined); + }); + + it('should ignore state change if error but component is unmounted', async () => { + mockFetchFn.mockImplementationOnce(async () => { + unmount(); + throw new Error(); + }); + + const { result, waitForNextUpdate, unmount } = renderUseFetch(); + + expect(result.current.error).toEqual(undefined); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(result.current.error).toEqual(undefined); + }); + + it('should abort initial request if fetch is called', async () => { + const firstAbortCtrl = new AbortController(); + const abortSpy = jest.spyOn(window, 'AbortController').mockReturnValueOnce(firstAbortCtrl); + + const { result, waitForNextUpdate } = renderUseFetch({ initialParameters: parameters }); + + mockFetchFn.mockImplementationOnce(async () => { + result.current.fetch(parameters); + return response; + }); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(firstAbortCtrl.signal.aborted).toEqual(true); + + abortSpy.mockRestore(); + }); + + it('should abort first request if fetch is called twice', async () => { + const firstAbortCtrl = new AbortController(); + const abortSpy = jest.spyOn(window, 'AbortController').mockReturnValueOnce(firstAbortCtrl); + + const { result, waitForNextUpdate } = renderUseFetch(); + + mockFetchFn.mockImplementationOnce(async () => { + result.current.fetch(parameters); + return response; + }); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(firstAbortCtrl.signal.aborted).toEqual(true); + + abortSpy.mockRestore(); + }); + + describe('APM tracking', () => { + it('should track with request name', async () => { + const { result, waitForNextUpdate } = renderUseFetch(); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(mockStartTracking).toHaveBeenCalledTimes(1); + expect(mockStartTracking).toHaveBeenCalledWith({ name: requestName }); + }); + + it('should track each request', async () => { + const { result, waitForNextUpdate } = renderUseFetch({ + initialParameters: parameters, + }); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(mockFetchFn).toHaveBeenCalledTimes(1); + expect(mockStartTracking).toHaveBeenCalledTimes(1); + expect(mockEndTracking).toHaveBeenCalledTimes(1); + expect(mockStartTracking).toHaveBeenCalledWith({ name: requestName }); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(mockFetchFn).toHaveBeenCalledTimes(2); + expect(mockStartTracking).toHaveBeenCalledTimes(2); + expect(mockEndTracking).toHaveBeenCalledTimes(2); + }); + + it('should end success', async () => { + const { result, waitForNextUpdate } = renderUseFetch(); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(mockEndTracking).toHaveBeenCalledTimes(1); + expect(mockEndTracking).toHaveBeenCalledWith('success'); + }); + + it('should end aborted', async () => { + const abortCtrl = new AbortController(); + const abortSpy = jest.spyOn(window, 'AbortController').mockReturnValue(abortCtrl); + + mockFetchFn.mockImplementationOnce(async () => { + abortCtrl.abort(); + throw Error('request aborted'); + }); + + const { result, waitForNextUpdate } = renderUseFetch(); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(mockEndTracking).toHaveBeenCalledTimes(1); + expect(mockEndTracking).toHaveBeenCalledWith('aborted'); + + abortSpy.mockRestore(); + }); + + it('should end error', async () => { + mockFetchFn.mockImplementationOnce(async () => { + throw Error('request error'); + }); + + const { result, waitForNextUpdate } = renderUseFetch(); + + await act(async () => { + result.current.fetch(parameters); + await waitForNextUpdate(); + }); + + expect(mockEndTracking).toHaveBeenCalledTimes(1); + expect(mockEndTracking).toHaveBeenCalledWith('error'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_fetch/use_fetch.ts b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/use_fetch.ts new file mode 100644 index 0000000000000..330b47569b3e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_fetch/use_fetch.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useReducer } from 'react'; +import type { Reducer } from 'react'; +import { useTrackHttpRequest } from '../../lib/apm/use_track_http_request'; +import type { RequestName } from './request_names'; + +interface ResultState { + /** + * The `data` will contain the raw response of the latest request executed. + * It is initialized to `undefined`. + */ + data?: Response; + isLoading: boolean; + /** + * The `error` will contain the error of the latest request executed. + * It is reset when a success response is completed. + */ + error?: Error; +} + +export interface Result extends ResultState { + /** + * The `fetch` function starts a request with the parameters. + * It aborts any previous pending request and starts a new request, every time it is called. + * Optimizations are delegated to the consumer of the hook. + */ + fetch: (parameters: Parameters) => void; + /** + * The `refetch` function restarts a request with the latest parameters used. + * It aborts any previous pending request + */ + refetch: () => void; +} + +export type RequestFnParam = ( + /** + * The parameters that will be passed to the fetch function provided. + */ + parameters: Parameters, + /** + * The abort signal. Call `signal.abort()` to abort the request. + */ + signal: AbortController['signal'] +) => Promise; + +export interface OptionsParam { + /** + * Disables the fetching and aborts any pending request when is set to `true`. + */ + disabled?: boolean; + /** + * Set `initialParameters` to start fetching immediately when the hook is called, without having to call the `fetch` function. + */ + initialParameters?: Parameters; +} + +interface State extends ResultState { + parameters?: Parameters; +} + +type Action = + | { type: 'FETCH_INIT'; payload: Parameters } + | { type: 'FETCH_SUCCESS'; payload: Response } + | { type: 'FETCH_FAILURE'; payload?: Error } + | { type: 'FETCH_REPEAT' }; + +const requestReducer = ( + state: State, + action: Action +): State => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + parameters: action.payload, + isLoading: true, + }; + case 'FETCH_SUCCESS': + return { + ...state, + data: action.payload, + isLoading: false, + error: undefined, + }; + case 'FETCH_FAILURE': + return { + ...state, + error: action.payload, + isLoading: false, + }; + case 'FETCH_REPEAT': + return { + ...state, + isLoading: true, + }; + default: + return state; + } +}; + +/** + * `useFetch` is a generic hook that simplifies the async request queries implementation. + * It provides: APM monitoring, abort control, error handling and refetching. + * @param requestName The unique name of the request. It is used for APM tracking, it should be descriptive. + * @param fetchFn The function provided to execute the fetch request. It should accept the request `parameters` and the abort `signal`. + * @param options Additional options. + */ +export const useFetch = ( + requestName: RequestName, + fetchFn: RequestFnParam, + { disabled = false, initialParameters }: OptionsParam = {} +): Result => { + const { startTracking } = useTrackHttpRequest(); + + const [{ parameters, data, isLoading, error }, dispatch] = useReducer< + Reducer, Action> + >(requestReducer, { + data: undefined, + isLoading: initialParameters !== undefined, // isLoading state is used internally to control fetch executions + error: undefined, + parameters: initialParameters, + }); + + const fetch = useCallback( + (param: Parameters) => dispatch({ type: 'FETCH_INIT', payload: param }), + [] + ); + const refetch = useCallback(() => dispatch({ type: 'FETCH_REPEAT' }), []); + + useEffect(() => { + if (isLoading === false || parameters === undefined || disabled) { + return; + } + + let ignore = false; + const abortController = new AbortController(); + + const executeFetch = async () => { + const { endTracking } = startTracking({ name: requestName }); + try { + const response = await fetchFn(parameters, abortController.signal); + endTracking('success'); + if (!ignore) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (err) { + endTracking(abortController.signal.aborted ? 'aborted' : 'error'); + if (!ignore) { + dispatch({ type: 'FETCH_FAILURE', payload: err }); + } + } + }; + + executeFetch(); + + return () => { + ignore = true; + abortController.abort(); + }; + }, [isLoading, parameters, disabled, fetchFn, startTracking, requestName]); + + return { fetch, refetch, data, isLoading, error }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx index 00197a8719a6d..9ef5fbfd0a203 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -28,6 +28,7 @@ const mockEvents = mockTimelineData.filter((i, index) => index <= 11); const mockSearch = jest.fn(); +jest.mock('../../common/lib/apm/use_track_http_request'); jest.mock('../../common/hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 6024c4613f265..700d5d9d1255e 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -43,6 +43,8 @@ import type { TimelineEqlResponse, } from '../../../common/search_strategy/timeline/events/eql'; import { useAppToasts } from '../../common/hooks/use_app_toasts'; +import { useTrackHttpRequest } from '../../common/lib/apm/use_track_http_request'; +import { APP_UI_ID } from '../../../common/constants'; export interface TimelineArgs { events: TimelineItem[]; @@ -156,6 +158,7 @@ export const useTimelineEvents = ({ null ); const prevTimelineRequest = useRef | null>(null); + const { startTracking } = useTrackHttpRequest(); const clearSignalsState = useCallback(() => { if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) { @@ -219,6 +222,8 @@ export const useTimelineEvents = ({ prevTimelineRequest.current = request; abortCtrl.current = new AbortController(); setLoading(true); + const { endTracking } = startTracking({ name: `${APP_UI_ID} timeline events search` }); + searchSubscription$.current = data.search .search, TimelineResponse>(request, { strategy: @@ -230,6 +235,7 @@ export const useTimelineEvents = ({ .subscribe({ next: (response) => { if (isCompleteResponse(response)) { + endTracking('success'); setLoading(false); setTimelineResponse((prevResponse) => { const newTimelineResponse = { @@ -257,12 +263,14 @@ export const useTimelineEvents = ({ }); searchSubscription$.current.unsubscribe(); } else if (isErrorResponse(response)) { + endTracking('invalid'); setLoading(false); addWarning(i18n.ERROR_TIMELINE_EVENTS); searchSubscription$.current.unsubscribe(); } }, error: (msg) => { + endTracking(abortCtrl.current.signal.aborted ? 'aborted' : 'error'); setLoading(false); data.search.showError(msg); searchSubscription$.current.unsubscribe(); @@ -317,6 +325,7 @@ export const useTimelineEvents = ({ pageName, skip, id, + startTracking, data.search, dataViewId, setUpdated,