diff --git a/x-pack/plugins/security_solution/public/common/components/ml/api/get_jobs_summary.ts b/x-pack/plugins/security_solution/public/common/components/ml/api/get_jobs_summary.ts index 2327ded166110..19c7e12b85c69 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/api/get_jobs_summary.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/api/get_jobs_summary.ts @@ -5,30 +5,27 @@ * 2.0. */ -import type { HttpSetup } from '@kbn/core/public'; import type { MlSummaryJob } from '@kbn/ml-plugin/public'; +import { KibanaServices } from '../../../lib/kibana'; export interface GetJobsSummaryArgs { - http: HttpSetup; jobIds?: string[]; - signal: AbortSignal; + signal?: AbortSignal; } /** * Fetches a summary of all ML jobs currently installed * - * @param http HTTP Service * @param jobIds Array of job IDs to filter against * @param signal to cancel request * * @throws An error if response is not OK */ export const getJobsSummary = async ({ - http, jobIds, signal, }: GetJobsSummaryArgs): Promise => - http.fetch('/api/ml/jobs/jobs_summary', { + KibanaServices.get().http.fetch('/api/ml/jobs/jobs_summary', { method: 'POST', body: JSON.stringify({ jobIds: jobIds ?? [] }), asSystemRequest: true, diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_fetch_jobs_summary_query.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_fetch_jobs_summary_query.ts new file mode 100644 index 0000000000000..f2958de5a9ac6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_fetch_jobs_summary_query.ts @@ -0,0 +1,42 @@ +/* + * 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 { MlSummaryJob } from '@kbn/ml-plugin/public'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import type { GetJobsSummaryArgs } from '../api/get_jobs_summary'; +import { getJobsSummary } from '../api/get_jobs_summary'; + +const ONE_MINUTE = 60000; +export const GET_JOBS_SUMMARY_QUERY_KEY = ['POST', '/api/ml/jobs/jobs_summary']; + +export const useFetchJobsSummaryQuery = ( + queryArgs: Omit, + options?: UseQueryOptions +) => { + return useQuery( + [GET_JOBS_SUMMARY_QUERY_KEY, queryArgs], + async ({ signal }) => getJobsSummary({ signal, ...queryArgs }), + { + refetchIntervalInBackground: false, + staleTime: ONE_MINUTE * 5, + retry: false, + ...options, + } + ); +}; + +export const useInvalidateFetchJobsSummaryQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + queryClient.invalidateQueries(GET_JOBS_SUMMARY_QUERY_KEY, { + refetchType: 'active', + }); + }, [queryClient]); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_get_jobs_summary.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_get_jobs_summary.ts deleted file mode 100644 index 9d0a3870aca13..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_get_jobs_summary.ts +++ /dev/null @@ -1,14 +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 { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; -import { getJobsSummary } from '../api/get_jobs_summary'; - -const _getJobsSummary = withOptionalSignal(getJobsSummary); - -// TODO rewrite to react-query -export const useGetJobsSummary = () => useAsync(_getJobsSummary); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts index fb37f37ec5c95..0ee5217babb1d 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts @@ -15,6 +15,7 @@ import { useAppToastsMock } from '../../../hooks/use_app_toasts.mock'; import { mockJobsSummaryResponse } from '../../ml_popover/api.mock'; import { getJobsSummary } from '../api/get_jobs_summary'; import { useInstalledSecurityJobs } from './use_installed_security_jobs'; +import { TestProviders } from '../../../mock'; jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); jest.mock('../../../../../common/machine_learning/has_ml_license'); @@ -37,7 +38,9 @@ describe('useInstalledSecurityJobs', () => { }); it('returns jobs and permissions', async () => { - const { result, waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs()); + const { result, waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(result.current.jobs).toHaveLength(3); @@ -68,7 +71,9 @@ describe('useInstalledSecurityJobs', () => { }); it('filters out non-security jobs', async () => { - const { result, waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs()); + const { result, waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(result.current.jobs.length).toBeGreaterThan(0); @@ -77,7 +82,9 @@ describe('useInstalledSecurityJobs', () => { it('renders a toast error if the ML call fails', async () => { (getJobsSummary as jest.Mock).mockRejectedValue('whoops'); - const { waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs()); + const { waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(appToastsMock.addError).toHaveBeenCalledWith('whoops', { @@ -93,7 +100,9 @@ describe('useInstalledSecurityJobs', () => { }); it('returns empty jobs and false predicates', () => { - const { result } = renderHook(() => useInstalledSecurityJobs()); + const { result } = renderHook(() => useInstalledSecurityJobs(), { + wrapper: TestProviders, + }); expect(result.current.jobs).toEqual([]); expect(result.current.isMlUser).toEqual(false); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts index fbc1a02fb53d1..14c1bff979f2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.ts @@ -5,17 +5,15 @@ * 2.0. */ -import { useEffect, useMemo, useState } from 'react'; - import type { MlSummaryJob } from '@kbn/ml-plugin/public'; -import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; +import { useMemo } from 'react'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; import { isSecurityJob } from '../../../../../common/machine_learning/is_security_job'; import { useAppToasts } from '../../../hooks/use_app_toasts'; -import { useHttp } from '../../../lib/kibana'; -import { useMlCapabilities } from './use_ml_capabilities'; import * as i18n from '../translations'; -import { useGetJobsSummary } from './use_get_jobs_summary'; +import { useFetchJobsSummaryQuery } from './use_fetch_jobs_summary_query'; +import { useMlCapabilities } from './use_ml_capabilities'; export interface UseInstalledSecurityJobsReturn { loading: boolean; @@ -35,35 +33,24 @@ export interface UseInstalledSecurityJobsReturn { * */ export const useInstalledSecurityJobs = (): UseInstalledSecurityJobsReturn => { - const [jobs, setJobs] = useState([]); const { addError } = useAppToasts(); const mlCapabilities = useMlCapabilities(); - const http = useHttp(); - const { error, loading, result, start } = useGetJobsSummary(); - const isMlUser = hasMlUserPermissions(mlCapabilities); const isLicensed = hasMlLicense(mlCapabilities); - useEffect(() => { - if (isMlUser && isLicensed) { - start({ http }); - } - }, [http, isMlUser, isLicensed, start]); - - useEffect(() => { - if (result) { - const securityJobs = result.filter(isSecurityJob); - setJobs(securityJobs); + const { isFetching, data: jobs = [] } = useFetchJobsSummaryQuery( + {}, + { + enabled: isMlUser && isLicensed, + onError: (error) => { + addError(error, { title: i18n.SIEM_JOB_FETCH_FAILURE }); + }, } - }, [result]); + ); - useEffect(() => { - if (error) { - addError(error, { title: i18n.SIEM_JOB_FETCH_FAILURE }); - } - }, [addError, error]); + const securityJobs = jobs.filter(isSecurityJob); - return { isLicensed, isMlUser, jobs, loading }; + return { isLicensed, isMlUser, jobs: securityJobs, loading: isFetching }; }; export const useInstalledSecurityJobsIds = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_fetch_modules_query.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_fetch_modules_query.ts new file mode 100644 index 0000000000000..806bbd413f667 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_fetch_modules_query.ts @@ -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 type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { getModules } from '../api'; +import type { GetModulesProps, Module } from '../types'; + +const ONE_MINUTE = 60000; +export const GET_MODULES_QUERY_KEY = ['GET', '/api/ml/modules/get_module/:moduleId']; + +export const useFetchModulesQuery = ( + queryArgs: Omit, + options?: UseQueryOptions +) => { + return useQuery( + [GET_MODULES_QUERY_KEY, queryArgs], + async ({ signal }) => getModules({ signal, ...queryArgs }), + { + refetchIntervalInBackground: false, + staleTime: ONE_MINUTE * 5, + retry: false, + ...options, + } + ); +}; + +export const useInvalidateFetchModulesQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + queryClient.invalidateQueries(GET_MODULES_QUERY_KEY, { refetchType: 'active' }); + }, [queryClient]); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_fetch_recognizer_query.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_fetch_recognizer_query.ts new file mode 100644 index 0000000000000..9bcd327451ce8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_fetch_recognizer_query.ts @@ -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 type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { checkRecognizer } from '../api'; +import type { CheckRecognizerProps, RecognizerModule } from '../types'; + +const ONE_MINUTE = 60000; +export const GET_RECOGNIZER_QUERY_KEY = ['GET', '/api/ml/modules/recognize/:indexPatterns']; + +export const useFetchRecognizerQuery = ( + queryArgs: Omit, + options?: UseQueryOptions +) => { + return useQuery( + [GET_RECOGNIZER_QUERY_KEY, queryArgs], + async ({ signal }) => checkRecognizer({ signal, ...queryArgs }), + { + refetchIntervalInBackground: false, + staleTime: ONE_MINUTE * 5, + retry: false, + ...options, + } + ); +}; + +export const useInvalidateFetchRecognizerQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + queryClient.invalidateQueries(GET_RECOGNIZER_QUERY_KEY, { refetchType: 'active' }); + }, [queryClient]); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts index 23b284264387b..c5db33b592ca8 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts @@ -20,6 +20,7 @@ import { checkRecognizerSuccess, } from '../api.mock'; import { useSecurityJobs } from './use_security_jobs'; +import { TestProviders } from '../../../mock'; jest.mock('../../../../../common/machine_learning/has_ml_admin_permissions'); jest.mock('../../../../../common/machine_learning/has_ml_license'); @@ -71,7 +72,9 @@ describe('useSecurityJobs', () => { bucketSpanSeconds: 900, }; - const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs()); + const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(result.current.jobs).toHaveLength(6); @@ -79,7 +82,9 @@ describe('useSecurityJobs', () => { }); it('returns those permissions', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs()); + const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(result.current.isMlAdmin).toEqual(true); @@ -88,7 +93,9 @@ describe('useSecurityJobs', () => { it('renders a toast error if an ML call fails', async () => { (getModules as jest.Mock).mockRejectedValue('whoops'); - const { waitForNextUpdate } = renderHook(() => useSecurityJobs()); + const { waitForNextUpdate } = renderHook(() => useSecurityJobs(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(appToastsMock.addError).toHaveBeenCalledWith('whoops', { @@ -104,7 +111,9 @@ describe('useSecurityJobs', () => { }); it('returns empty jobs and false predicates', () => { - const { result } = renderHook(() => useSecurityJobs()); + const { result } = renderHook(() => useSecurityJobs(), { + wrapper: TestProviders, + }); expect(result.current.jobs).toEqual([]); expect(result.current.isMlAdmin).toEqual(false); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts index 4f39fd1a746c8..e7a7571c6e516 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.ts @@ -5,21 +5,20 @@ * 2.0. */ -import { useEffect, useRef, useState } from 'react'; - -import { noop } from 'lodash/fp'; +import { useCallback, useMemo } from 'react'; import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; import { useAppToasts } from '../../../hooks/use_app_toasts'; -import { useUiSetting$, useHttp } from '../../../lib/kibana'; -import { checkRecognizer, getModules } from '../api'; -import type { SecurityJob } from '../types'; -import { createSecurityJobs } from './use_security_jobs_helpers'; +import { useUiSetting$ } from '../../../lib/kibana'; +import type { inputsModel } from '../../../store'; +import { useFetchJobsSummaryQuery } from '../../ml/hooks/use_fetch_jobs_summary_query'; import { useMlCapabilities } from '../../ml/hooks/use_ml_capabilities'; import * as i18n from '../../ml/translations'; -import { getJobsSummary } from '../../ml/api/get_jobs_summary'; -import type { inputsModel } from '../../../store'; +import type { SecurityJob } from '../types'; +import { useFetchModulesQuery } from './use_fetch_modules_query'; +import { useFetchRecognizerQuery } from './use_fetch_recognizer_query'; +import { createSecurityJobs } from './use_security_jobs_helpers'; export interface UseSecurityJobsReturn { loading: boolean; @@ -40,62 +39,59 @@ export interface UseSecurityJobsReturn { * */ export const useSecurityJobs = (): UseSecurityJobsReturn => { - const [jobs, setJobs] = useState([]); - const [loading, setLoading] = useState(true); const mlCapabilities = useMlCapabilities(); const [securitySolutionDefaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); - const http = useHttp(); const { addError } = useAppToasts(); - const refetch = useRef(noop); const isMlAdmin = hasMlAdminPermissions(mlCapabilities); const isLicensed = hasMlLicense(mlCapabilities); + const isMlEnabled = isMlAdmin && isLicensed; - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); + const onError = useCallback( + (error) => { + addError(error, { title: i18n.SIEM_JOB_FETCH_FAILURE }); + }, + [addError] + ); - async function fetchSecurityJobIdsFromGroupsData() { - setLoading(true); - if (isMlAdmin && isLicensed) { - try { - // Batch fetch all installed jobs, ML modules, and check which modules are compatible with securitySolutionDefaultIndex - const [jobSummaryData, modulesData, compatibleModules] = await Promise.all([ - getJobsSummary({ http, signal: abortCtrl.signal }), - getModules({ signal: abortCtrl.signal }), - checkRecognizer({ - indexPatternName: securitySolutionDefaultIndex, - signal: abortCtrl.signal, - }), - ]); + const { + data: jobSummaryData, + isFetching: isJobSummaryFetching, + refetch: refetchJobsSummary, + } = useFetchJobsSummaryQuery({}, { enabled: isMlEnabled, onError }); - const compositeSecurityJobs = createSecurityJobs( - jobSummaryData, - modulesData, - compatibleModules - ); + const { + data: modulesData, + isFetching: isModulesFetching, + refetch: refetchModules, + } = useFetchModulesQuery({}, { enabled: isMlEnabled, onError }); - if (isSubscribed) { - setJobs(compositeSecurityJobs); - } - } catch (error) { - if (isSubscribed) { - addError(error, { title: i18n.SIEM_JOB_FETCH_FAILURE }); - } - } - } - if (isSubscribed) { - setLoading(false); - } - } + const { + data: compatibleModules, + isFetching: isRecognizerFetching, + refetch: refetchRecognizer, + } = useFetchRecognizerQuery( + { indexPatternName: securitySolutionDefaultIndex }, + { enabled: isMlEnabled, onError } + ); - fetchSecurityJobIdsFromGroupsData(); + const refetch = useCallback(() => { + refetchJobsSummary(); + refetchModules(); + refetchRecognizer(); + }, [refetchJobsSummary, refetchModules, refetchRecognizer]); - refetch.current = fetchSecurityJobIdsFromGroupsData; - return () => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [isMlAdmin, isLicensed, securitySolutionDefaultIndex, addError, http]); + const jobs = useMemo(() => { + if (jobSummaryData && modulesData && compatibleModules) { + return createSecurityJobs(jobSummaryData, modulesData, compatibleModules); + } + return []; + }, [compatibleModules, jobSummaryData, modulesData]); - return { isLicensed, isMlAdmin, jobs, loading, refetch: refetch.current }; + return { + isLicensed, + isMlAdmin, + jobs, + loading: isJobSummaryFetching || isModulesFetching || isRecognizerFetching, + refetch, + }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.test.tsx index ebd1c0c4df109..41f2a216244e9 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.test.tsx @@ -6,19 +6,23 @@ */ import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { render, act } from '@testing-library/react'; import { MlPopover } from './ml_popover'; +import { TestProviders } from '../../mock'; jest.mock('../../lib/kibana'); describe('MlPopover', () => { - test('shows upgrade popover on mouse click', () => { - const wrapper = mountWithIntl(); + test('shows upgrade popover on mouse click', async () => { + const { getByTestId } = render(, { + wrapper: TestProviders, + }); - // TODO: Update to use act() https://fb.me/react-wrap-tests-with-act - wrapper.find('[data-test-subj="integrations-button"]').first().simulate('click'); - wrapper.update(); - expect(wrapper.find('[data-test-subj="ml-popover-upgrade-contents"]').exists()).toEqual(true); + await act(async () => { + getByTestId('integrations-button').click(); + }); + + expect(getByTestId('ml-popover-upgrade-contents')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts index 7800ac03169c9..18e9913268efb 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/types.ts @@ -16,7 +16,7 @@ export interface Group { export interface CheckRecognizerProps { indexPatternName: string[]; - signal: AbortSignal; + signal?: AbortSignal; } export interface RecognizerModule { @@ -31,7 +31,7 @@ export interface RecognizerModule { export interface GetModulesProps { moduleId?: string; - signal: AbortSignal; + signal?: AbortSignal; } export interface Module {