diff --git a/plugins/main/public/components/agents/stats/agent-stats.tsx b/plugins/main/public/components/agents/stats/agent-stats.tsx index a77b125b56..268a08a723 100644 --- a/plugins/main/public/components/agents/stats/agent-stats.tsx +++ b/plugins/main/public/components/agents/stats/agent-stats.tsx @@ -48,6 +48,7 @@ import { import { getErrorOrchestrator } from '../../../react-services/common-services'; import { endpointSummary } from '../../../utils/applications'; import { getCore } from '../../../kibana-services'; +import { useIsMounted } from '../../common/hooks/use-is-mounted'; const tableColumns = [ { @@ -145,46 +146,61 @@ export const MainAgentStats = compose( )(AgentStats); function AgentStats({ agent }) { - const [loading, setLoading] = useState(); + const [loading, setLoading] = useState(false); const [dataStatLogcollector, setDataStatLogcollector] = useState({}); const [dataStatAgent, setDataStatAgent] = useState(); + + const { isComponentMounted, getAbortController } = useIsMounted(); + useEffect(() => { - (async function () { + const fetchData = async () => { setLoading(true); try { + const signal = getAbortController().signal; + const responseDataStatLogcollector = await WzRequest.apiReq( 'GET', `/agents/${agent.id}/stats/logcollector`, - {}, + { signal }, ); const responseDataStatAgent = await WzRequest.apiReq( 'GET', `/agents/${agent.id}/stats/agent`, - {}, - ); - setDataStatLogcollector( - responseDataStatLogcollector?.data?.data?.affected_items?.[0] || {}, - ); - setDataStatAgent( - responseDataStatAgent?.data?.data?.affected_items?.[0] || undefined, + { signal }, ); + + if (isComponentMounted()) { + setDataStatLogcollector( + responseDataStatLogcollector?.data?.data?.affected_items?.[0] || {}, + ); + setDataStatAgent( + responseDataStatAgent?.data?.data?.affected_items?.[0] || undefined, + ); + } } catch (error) { - const options: UIErrorLog = { - context: `${AgentStats.name}.useEffect`, - level: UI_LOGGER_LEVELS.ERROR as UILogLevel, - severity: UI_ERROR_SEVERITIES.BUSINESS as UIErrorSeverity, - error: { - error: error, - message: error.message || error, - title: error.name || error, - }, - }; - getErrorOrchestrator().handleError(options); + if (isComponentMounted()) { + const options = { + context: `${AgentStats.name}.useEffect`, + level: UI_LOGGER_LEVELS.ERROR as UILogLevel, + severity: UI_ERROR_SEVERITIES.BUSINESS as UIErrorSeverity, + error: { + error: error, + message: error.message || error, + title: error.name || error, + }, + }; + getErrorOrchestrator().handleError(options); + } } finally { - setLoading(false); + if (isComponentMounted()) { + setLoading(false); + } } - })(); - }, []); + }; + + fetchData(); + }, [agent.id]); + return ( diff --git a/plugins/main/public/components/common/data-source/hooks/use-data-source.ts b/plugins/main/public/components/common/data-source/hooks/use-data-source.ts index ca8195f7ea..614f93e95d 100644 --- a/plugins/main/public/components/common/data-source/hooks/use-data-source.ts +++ b/plugins/main/public/components/common/data-source/hooks/use-data-source.ts @@ -12,6 +12,7 @@ import { tFilterManager, } from '../index'; import { PinnedAgentManager } from '../../../wz-agent-selector/wz-agent-selector-service'; +import { useIsMounted } from '../../../common/hooks/use-is-mounted'; type tUseDataSourceProps = { DataSource: IDataSourceFactoryConstructor; @@ -70,6 +71,8 @@ export function useDataSource< const pinnedAgentManager = new PinnedAgentManager(); const pinnedAgent = pinnedAgentManager.getPinnedAgent(); + const { isComponentMounted, getAbortController } = useIsMounted(); + const setFilters = (filters: tFilter[]) => { if (!dataSourceFilterManager) { return; @@ -83,7 +86,8 @@ export function useDataSource< if (!dataSourceFilterManager) { return; } - return await dataSourceFilterManager?.fetch(params); + const paramsWithSignal = { ...params, signal: getAbortController().signal }; + return await dataSourceFilterManager.fetch(paramsWithSignal); }; useEffect(() => { @@ -101,28 +105,30 @@ export function useDataSource< if (!dataSource) { throw new Error('No valid data source found'); } - setDataSource(dataSource); - const dataSourceFilterManager = new PatternDataSourceFilterManager( - dataSource, - initialFilters, - injectedFilterManager, - initialFetchFilters, - ); - // what the filters update - subscription = dataSourceFilterManager.getUpdates$().subscribe({ - next: () => { - // this is necessary to remove the hidden filters from the filter manager and not show them in the search bar - dataSourceFilterManager.setFilters( - dataSourceFilterManager.getFilters(), - ); - setAllFilters(dataSourceFilterManager.getFilters()); - setFetchFilters(dataSourceFilterManager.getFetchFilters()); - }, - }); - setAllFilters(dataSourceFilterManager.getFilters()); - setFetchFilters(dataSourceFilterManager.getFetchFilters()); - setDataSourceFilterManager(dataSourceFilterManager); - setIsLoading(false); + if (isComponentMounted()) { + setDataSource(dataSource); + const dataSourceFilterManager = new PatternDataSourceFilterManager( + dataSource, + initialFilters, + injectedFilterManager, + initialFetchFilters, + ); + subscription = dataSourceFilterManager.getUpdates$().subscribe({ + next: () => { + if (isComponentMounted()) { + dataSourceFilterManager.setFilters( + dataSourceFilterManager.getFilters(), + ); + setAllFilters(dataSourceFilterManager.getFilters()); + setFetchFilters(dataSourceFilterManager.getFetchFilters()); + } + }, + }); + setAllFilters(dataSourceFilterManager.getFilters()); + setFetchFilters(dataSourceFilterManager.getFetchFilters()); + setDataSourceFilterManager(dataSourceFilterManager); + setIsLoading(false); + } })(); return () => subscription && subscription.unsubscribe(); diff --git a/plugins/main/public/components/common/hooks/use-is-mounted.test.tsx b/plugins/main/public/components/common/hooks/use-is-mounted.test.tsx new file mode 100644 index 0000000000..a2cbae8827 --- /dev/null +++ b/plugins/main/public/components/common/hooks/use-is-mounted.test.tsx @@ -0,0 +1,19 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useIsMounted } from './use-is-mounted'; + +describe('useIsMounted()', () => { + it('should return true when component is mounted', () => { + const { result } = renderHook(() => useIsMounted()); + + expect(result.current.isComponentMounted()).toBe(true); + }); + + it('should return false when component is unmounted', () => { + const { result, unmount } = renderHook(() => useIsMounted()); + + unmount(); + + expect(result.current.isComponentMounted()).toBe(false); + }); +}); diff --git a/plugins/main/public/components/common/hooks/use-is-mounted.ts b/plugins/main/public/components/common/hooks/use-is-mounted.ts new file mode 100644 index 0000000000..4931b31754 --- /dev/null +++ b/plugins/main/public/components/common/hooks/use-is-mounted.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useIsMounted = () => { + const isMounted = useRef(false); + const abortControllerRef = useRef(new AbortController()); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + abortControllerRef.current.abort(); + }; + }, []); + + const getAbortController = useCallback(() => { + if (!isMounted.current) { + abortControllerRef.current = new AbortController(); + } + return abortControllerRef.current; + }, []); + + const isComponentMounted = useCallback(() => isMounted.current, []); + + return { isComponentMounted, getAbortController }; +}; diff --git a/plugins/main/public/components/common/hooks/useGenericRequest.ts b/plugins/main/public/components/common/hooks/useGenericRequest.ts index d8fd733cff..071cddeee7 100644 --- a/plugins/main/public/components/common/hooks/useGenericRequest.ts +++ b/plugins/main/public/components/common/hooks/useGenericRequest.ts @@ -12,33 +12,44 @@ import { export function useGenericRequest(method, path, params, formatFunction) { const [items, setItems] = useState({}); const [isLoading, setisLoading] = useState(true); - const [error, setError] = useState(""); + const [error, setError] = useState(''); - useEffect( () => { - try{ - setisLoading(true); - const fetchData = async() => { - const response = await GenericRequest.request(method, path, params); + useEffect(() => { + let isMounted = true; + + const fetchData = async () => { + try { + setisLoading(true); + const response = await GenericRequest.request(method, path, params); + if (isMounted) { setItems(response); setisLoading(false); } - fetchData(); - } catch(error) { - setError(error); - setisLoading(false); - const options: UIErrorLog = { - context: `${useGenericRequest.name}.fetchData`, - level: UI_LOGGER_LEVELS.ERROR as UILogLevel, - severity: UI_ERROR_SEVERITIES.UI as UIErrorSeverity, - error: { - error: error, - message: error.message || error, - title: error.name || error, - }, - }; - getErrorOrchestrator().handleError(options); - } - }, [params]); + } catch (error) { + if (isMounted) { + setError(error); + setisLoading(false); + const options: UIErrorLog = { + context: `${useGenericRequest.name}.fetchData`, + level: UI_LOGGER_LEVELS.ERROR as UILogLevel, + severity: UI_ERROR_SEVERITIES.UI as UIErrorSeverity, + error: { + error: error, + message: error.message || error, + title: error.name || error, + }, + }; + getErrorOrchestrator().handleError(options); + } + } + }; + + fetchData(); + + return () => { + isMounted = false; + }; + }, [method, path, params]); - return {isLoading, data: formatFunction(items), error}; + return { isLoading, data: formatFunction(items), error }; } diff --git a/plugins/main/public/components/common/modules/main-agent.tsx b/plugins/main/public/components/common/modules/main-agent.tsx index 3d27e26405..6225ccca90 100644 --- a/plugins/main/public/components/common/modules/main-agent.tsx +++ b/plugins/main/public/components/common/modules/main-agent.tsx @@ -39,20 +39,31 @@ import { } from '../data-source'; import { useAsyncAction } from '../hooks'; -export class MainModuleAgent extends Component { - props!: { - [key: string]: any; - }; - state: { - selectView: Boolean; - loadingReport: Boolean; - switchModule: Boolean; - showAgentInfo: Boolean; - }; +interface MainModuleAgentProps { + agent: any; + section: string; + selectView: boolean; + tabs?: any[]; + renderTabs: () => React.ReactNode; +} + +interface MainModuleAgentState { + selectView: boolean; + loadingReport: boolean; + switchModule: boolean; + showAgentInfo: boolean; +} + +export class MainModuleAgent extends Component< + MainModuleAgentProps, + MainModuleAgentState +> { + isMounted: boolean; reportingService: ReportingService; filterHandler: FilterHandler; + router: any; - constructor(props) { + constructor(props: MainModuleAgentProps) { super(props); this.reportingService = new ReportingService(); this.filterHandler = new FilterHandler(AppState.getCurrentPattern()); @@ -62,13 +73,19 @@ export class MainModuleAgent extends Component { switchModule: false, showAgentInfo: false, }; + this.isMounted = false; } async componentDidMount() { + this.isMounted = true; const $injector = getAngularModule().$injector; this.router = $injector.get('$route'); } + componentWillUnmount() { + this.isMounted = false; + } + renderTitle() { return ( diff --git a/plugins/main/public/components/common/search-bar/search-bar-service.ts b/plugins/main/public/components/common/search-bar/search-bar-service.ts index 40227d533b..2ac4da80f1 100644 --- a/plugins/main/public/components/common/search-bar/search-bar-service.ts +++ b/plugins/main/public/components/common/search-bar/search-bar-service.ts @@ -51,8 +51,13 @@ export function getForceNow() { //////////////////////////////////////////////////////////////////////////////////// +interface Options { + signal?: AbortSignal; +} + export const search = async ( params: SearchParams, + options: Options = {}, ): Promise => { const { indexPattern, @@ -123,7 +128,7 @@ export const search = async ( searchSource.setField('aggs', aggs); } try { - return await searchParams.fetch(); + return await searchParams.fetch({ signal: options.signal }); } catch (error) { if (error.body) { throw error.body; diff --git a/plugins/main/public/components/common/tables/table-default.tsx b/plugins/main/public/components/common/tables/table-default.tsx index ed068e3d23..9be741855a 100644 --- a/plugins/main/public/components/common/tables/table-default.tsx +++ b/plugins/main/public/components/common/tables/table-default.tsx @@ -15,6 +15,7 @@ import { EuiBasicTable } from '@elastic/eui'; import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/types'; import { UI_LOGGER_LEVELS } from '../../../../common/constants'; import { getErrorOrchestrator } from '../../../react-services/common-services'; +import { useIsMounted } from '../hooks/use-is-mounted'; export function TableDefault({ onSearch, @@ -43,7 +44,7 @@ export function TableDefault({ }, }); - const isMounted = useRef(false); + const { isComponentMounted, getAbortController } = useIsMounted(); function tableOnChange({ page = {}, sort = {} }) { const { index: pageIndex, size: pageSize } = page; @@ -61,58 +62,59 @@ export function TableDefault({ } useEffect(() => { - // This effect is triggered when the component is mounted because of how to the useEffect hook works. - // We don't want to set the pagination state because there is another effect that has this dependency - // and will cause the effect is triggered (redoing the onSearch function). - if (isMounted.current) { - // Reset the page index when the endpoint changes. - // This will cause that onSearch function is triggered because to changes in pagination in the another effect. + if (isComponentMounted()) { setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); } }, [endpoint]); useEffect(() => { (async function () { + const abortController = getAbortController(); try { setLoading(true); - const { items, totalItems } = await onSearch(endpoint, [], pagination, sorting); - setItems(items); - setTotalItems(totalItems); + const { items, totalItems } = await onSearch( + endpoint, + [], + pagination, + sorting, + ); + if (isComponentMounted()) { + setItems(items); + setTotalItems(totalItems); + } } catch (error) { - setItems([]); - setTotalItems(0); - const options = { - context: `${TableDefault.name}.useEffect`, - level: UI_LOGGER_LEVELS.ERROR, - severity: UI_ERROR_SEVERITIES.BUSINESS, - error: { - error: error, - message: error.message || error, - title: `${error.name}: Error fetching items`, - }, - }; - getErrorOrchestrator().handleError(options); + if (isComponentMounted()) { + setItems([]); + setTotalItems(0); + const options = { + context: `${TableDefault.name}.useEffect`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + error: { + error: error, + message: error.message || error, + title: `${error.name}: Error fetching items`, + }, + }; + getErrorOrchestrator().handleError(options); + } + } + if (isComponentMounted()) { + setLoading(false); } - setLoading(false); })(); }, [endpoint, pagination, sorting, reload]); - // It is required that this effect runs after other effects that use isMounted - // to avoid that these effects run when the component is mounted, only running - // when one of its dependencies changes. - useEffect(() => { - isMounted.current = true; - }, []); - const tablePagination = { ...pagination, totalItemCount: totalItems, pageSizeOptions: tablePageSizeOptions, - hidePerPageOptions + hidePerPageOptions, }; + return ( ({...rest}))} + columns={tableColumns.map(({ show, ...rest }) => ({ ...rest }))} items={items} loading={loading} pagination={tablePagination} diff --git a/plugins/main/public/components/common/tables/table-with-search-bar.tsx b/plugins/main/public/components/common/tables/table-with-search-bar.tsx index 2c551192aa..75f4dd34fd 100644 --- a/plugins/main/public/components/common/tables/table-with-search-bar.tsx +++ b/plugins/main/public/components/common/tables/table-with-search-bar.tsx @@ -17,7 +17,7 @@ import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/ import { UI_LOGGER_LEVELS } from '../../../../common/constants'; import { getErrorOrchestrator } from '../../../react-services/common-services'; import { SearchBar, SearchBarProps } from '../../search-bar'; - +import { useIsMounted } from '../hooks/use-is-mounted'; export interface ITableWithSearcHBarProps { /** * Function to fetch the data @@ -123,9 +123,22 @@ export function TableWithSearchBar({ }); const [refresh, setRefresh] = useState(Date.now()); - const isMounted = useRef(false); + const { isComponentMounted, getAbortController } = useIsMounted(); const tableRef = useRef(); + useEffect(() => { + if (isComponentMounted()) { + updateRefresh(); + } + }, [endpoint, reload]); + + useEffect(() => { + if (isComponentMounted() && !_.isEqual(rest.filters, filters)) { + setFilters(rest.filters || {}); + updateRefresh(); + } + }, [rest.filters]); + const searchBarWQLOptions = useMemo( () => ({ searchTermFields: tableColumns @@ -146,7 +159,7 @@ export function TableWithSearchBar({ } function tableOnChange({ page = {}, sort = {} }) { - if (isMounted.current) { + if (isComponentMounted()) { const { index: pageIndex, size: pageSize } = page; const { field, direction } = sort; setPagination({ @@ -163,34 +176,27 @@ export function TableWithSearchBar({ } useEffect(() => { - // This effect is triggered when the component is mounted because of how to the useEffect hook works. - // We don't want to set the pagination state because there is another effect that has this dependency - // and will cause the effect is triggered (redoing the onSearch function). - if (isMounted.current) { - // Reset the page index when the endpoint or reload changes. - // This will cause that onSearch function is triggered because to changes in pagination in the another effect. - updateRefresh(); - } - }, [endpoint, reload]); + (async () => { + try { + setLoading(true); - useEffect( - function () { - (async () => { - try { - setLoading(true); - - //Reset the table selection in case is enabled + // Reset the table selection in case is enabled + if (tableRef.current) { tableRef.current.setSelection([]); + } - const { items, totalItems } = await onSearch( - endpoint, - filters, - pagination, - sorting, - ); + const { items, totalItems } = await onSearch( + endpoint, + filters, + pagination, + sorting, + ); + if (isComponentMounted()) { setItems(items); setTotalItems(totalItems); - } catch (error) { + } + } catch (error) { + if (isComponentMounted()) { setItems([]); setTotalItems(0); const options = { @@ -205,34 +211,19 @@ export function TableWithSearchBar({ }; getErrorOrchestrator().handleError(options); } + } + if (isComponentMounted()) { setLoading(false); - })(); - }, - [filters, pagination, sorting, refresh], - ); - - useEffect(() => { - // This effect is triggered when the component is mounted because of how to the useEffect hook works. - // We don't want to set the filters state because there is another effect that has this dependency - // and will cause the effect is triggered (redoing the onSearch function). - if (isMounted.current && !_.isEqual(rest.filters, filters)) { - setFilters(rest.filters || {}); - updateRefresh(); - } - }, [rest.filters]); - - // It is required that this effect runs after other effects that use isMounted - // to avoid that these effects run when the component is mounted, only running - // when one of its dependencies changes. - useEffect(() => { - isMounted.current = true; - }, []); + } + })(); + }, [filters, pagination, sorting, refresh]); const tablePagination = { ...pagination, totalItemCount: totalItems, pageSizeOptions: tablePageSizeOptions, }; + return ( <> typeof rest.onFiltersChange === 'function' ? rest.onFiltersChange(filters) @@ -102,56 +104,66 @@ export function TableWzAPI({ ); const [isOpenFieldSelector, setIsOpenFieldSelector] = useState(false); - const onSearch = useCallback(async function ( - endpoint, - filters, - pagination, - sorting, - ) { - try { - const { pageIndex, pageSize } = pagination; - const { field, direction } = sorting.sort; - setIsLoading(true); - setFilters(filters); - onFiltersChange(filters); - const params = { - ...getFilters(filters), - offset: pageIndex * pageSize, - limit: pageSize, - sort: `${direction === 'asc' ? '+' : '-'}${field}`, - }; + const onSearch = useCallback( + async function (endpoint, filters, pagination, sorting) { + const abortController = getAbortController(); + try { + if (!isComponentMounted()) { + const error = new Error('Abort error onSearch callback'); + error.name = 'AbortError'; + throw error; + } + const { pageIndex, pageSize } = pagination; + const { field, direction } = sorting.sort; + setIsLoading(true); + setFilters(filters); + onFiltersChange(filters); + const params = { + ...getFilters(filters), + offset: pageIndex * pageSize, + limit: pageSize, + sort: `${direction === 'asc' ? '+' : '-'}${field}`, + signal: abortController.signal, + }; - const response = await WzRequest.apiReq('GET', endpoint, { params }); + const response = await WzRequest.apiReq('GET', endpoint, { params }); - const { affected_items: items, total_affected_items: totalItems } = ( - (response || {}).data || {} - ).data; - setIsLoading(false); - setTotalItems(totalItems); + const { affected_items: items, total_affected_items: totalItems } = ( + (response || {}).data || {} + ).data; + setIsLoading(false); + setTotalItems(totalItems); - const result = { - items: rest.mapResponseItem ? items.map(rest.mapResponseItem) : items, - totalItems, - }; + const result = { + items: rest.mapResponseItem ? items.map(rest.mapResponseItem) : items, + totalItems, + }; - onDataChange(result); + onDataChange(result); - return result; - } catch (error) { - setIsLoading(false); - setTotalItems(0); - if (error?.name) { - /* This replaces the error name. The intention is that an AxiosError + return result; + } catch (error) { + setIsLoading(false); + setTotalItems(0); + if (error.name !== 'AbortError') { + if (error?.name) { + /* This replaces the error name. The intention is that an AxiosError doesn't appear in the toast message. TODO: This should be managed by the service that does the request instead of only changing the name in this case. */ - error.name = 'RequestError'; + error.name = 'RequestError'; + } + throw error; + } + return { + items: [], + totalItems, + }; } - throw error; - } - }, - []); + }, + [isComponentMounted, getAbortController], + ); const renderActionButtons = (actionButtons, filters) => { if (Array.isArray(actionButtons)) { @@ -182,7 +194,11 @@ export function TableWzAPI({ }; useEffect(() => { - if (rest.reload) triggerReload(); + if (rest.reload && isComponentMounted()) triggerReload(); + + return () => { + getAbortController().abort(); + }; }, [rest.reload]); const ReloadButton = ( diff --git a/plugins/main/public/components/overview/mitre/framework/components/techniques/techniques.tsx b/plugins/main/public/components/overview/mitre/framework/components/techniques/techniques.tsx index 2533d1643d..4200b11620 100644 --- a/plugins/main/public/components/overview/mitre/framework/components/techniques/techniques.tsx +++ b/plugins/main/public/components/overview/mitre/framework/components/techniques/techniques.tsx @@ -40,6 +40,7 @@ import { getErrorOrchestrator } from '../../../../../../react-services/common-se import { tFilter, tSearchParams } from '../../../../../common/data-source'; import { tFilterParams } from '../../mitre'; import { getDataPlugin } from '../../../../../../kibana-services'; +import { useIsMounted } from '../../../../../common/hooks/use-is-mounted'; const MITRE_ATTACK = 'mitre-attack'; @@ -90,6 +91,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { const [loadingAlerts, setLoadingAlerts] = useState(false); const { isFlyoutVisible, techniquesCount, currentTechnique } = state; + const { isComponentMounted, getAbortController } = useIsMounted(); const getMitreRuleIdFilter = (value: string) => { const GROUP_KEY = 'rule.mitre.id'; @@ -130,12 +132,12 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { return; } buildMitreTechniquesFromApi(); - }, [isLoading]); + }, [isLoading, isComponentMounted]); useEffect(() => { - if (isLoading || isSearching) return; + if (isLoading || isSearching || !isComponentMounted()) return; getTechniquesCount(); - }, [tacticsObject, isLoading, filterParams, isSearching]); + }, [tacticsObject, isLoading, filterParams, isSearching, isComponentMounted]); const getTechniquesCount = async () => { try { @@ -183,7 +185,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { } }; - const buildPanel = (techniqueID) => { + const buildPanel = techniqueID => { return [ { id: 0, @@ -191,21 +193,21 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { items: [ { name: 'Filter for value', - icon: , + icon: , onClick: () => { closeActionsMenu(); }, }, { name: 'Filter out value', - icon: , + icon: , onClick: () => { closeActionsMenu(); }, }, { name: 'View technique details', - icon: , + icon: , onClick: () => { closeActionsMenu(); showFlyout(techniqueID); @@ -218,13 +220,17 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { const techniqueColumnsResponsive = () => { if (props && props?.windowSize) { - return props.windowSize.width < 930 ? 2 : props.windowSize.width < 1200 ? 3 : 4; + return props.windowSize.width < 930 + ? 2 + : props.windowSize.width < 1200 + ? 3 + : 4; } else { return 4; } }; - const getMitreTechniques = async (params) => { + const getMitreTechniques = async params => { try { return await WzRequest.apiReq('GET', '/mitre/techniques', { params }); } catch (error) { @@ -250,10 +256,16 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { const params = { limit: limitResults }; setIsSearching(true); const output = await getMitreTechniques(params); - const totalItems = (((output || {}).data || {}).data || {}).total_affected_items; + const totalItems = (((output || {}).data || {}).data || {}) + .total_affected_items; let mitreTechniques = []; mitreTechniques.push(...output.data.data.affected_items); - if (totalItems && output.data && output.data.data && totalItems > limitResults) { + if ( + totalItems && + output.data && + output.data.data && + totalItems > limitResults + ) { const extraResults = await Promise.all( Array(Math.ceil((totalItems - params.limit) / params.limit)) .fill() @@ -263,7 +275,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { offset: limitResults * (1 + index), }); return response.data.data.affected_items; - }) + }), ); mitreTechniques.push(...extraResults.flat()); } @@ -271,16 +283,17 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { setIsSearching(false); }; - const buildObjTechniques = (techniques) => { + const buildObjTechniques = techniques => { const techniquesObj = []; - techniques.forEach((element) => { - const mitreObj = state.mitreTechniques.find((item) => item.id === element); + techniques.forEach(element => { + const mitreObj = state.mitreTechniques.find(item => item.id === element); if (mitreObj) { const mitreTechniqueName = mitreObj.name; const mitreTechniqueID = mitreObj.source === MITRE_ATTACK ? mitreObj.external_id - : mitreObj.references.find((item) => item.source === MITRE_ATTACK).external_id; + : mitreObj.references.find(item => item.source === MITRE_ATTACK) + .external_id; mitreTechniqueID ? techniquesObj.push({ id: mitreTechniqueID, @@ -292,7 +305,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { return techniquesObj; }; - const addFilter = (filter) => { + const addFilter = filter => { const { filterManager } = getDataPlugin().query; const matchPhrase = {}; matchPhrase[filter.key] = filter.value; @@ -333,37 +346,43 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { let hash = {}; let tacticsToRender: Array = []; const currentTechniques = Object.keys(tacticsObject) - .map((tacticsKey) => ({ + .map(tacticsKey => ({ tactic: tacticsKey, techniques: buildObjTechniques(tacticsObject[tacticsKey].techniques), })) - .filter((tactic) => selectedTactics[tactic.tactic]) - .map((tactic) => tactic.techniques) + .filter(tactic => selectedTactics[tactic.tactic]) + .map(tactic => tactic.techniques) .flat() - .filter((techniqueID, index, array) => array.indexOf(techniqueID) === index); + .filter( + (techniqueID, index, array) => array.indexOf(techniqueID) === index, + ); tacticsToRender = currentTechniques - .filter((technique) => + .filter(technique => state.filteredTechniques ? state.filteredTechniques.includes(technique.id) : technique.id && hash[technique.id] ? false - : (hash[technique.id] = true) + : (hash[technique.id] = true), ) - .map((technique) => { + .map(technique => { return { id: technique.id, label: `${technique.id} - ${technique.name}`, quantity: - (techniquesCount.find((item) => item.key === technique.id) || {}).doc_count || 0, + (techniquesCount.find(item => item.key === technique.id) || {}) + .doc_count || 0, }; }) - .filter((technique) => (state.hideAlerts ? technique.quantity !== 0 : true)); + .filter(technique => + state.hideAlerts ? technique.quantity !== 0 : true, + ); const tacticsToRenderOrdered = tacticsToRender .sort((a, b) => b.quantity - a.quantity) .map((item, idx) => { const tooltipContent = `View details of ${item.label} (${item.id})`; const toolTipAnchorClass = - 'wz-display-inline-grid' + (state.hover === item.id ? ' wz-mitre-width' : ' '); + 'wz-display-inline-grid' + + (state.hover === item.id ? ' wz-mitre-width' : ' '); return ( setState({ ...state, hover: item.id })} @@ -376,8 +395,8 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { }} > { onClick={() => showFlyout(item.id)} > @@ -409,25 +428,31 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { {state.hover === item.id && ( - + { + onClick={e => { openDashboard(e, item.id); e.stopPropagation(); }} - color="primary" - type="visualizeApp" + color='primary' + type='visualizeApp' > {' '}   - + { + onClick={e => { openDiscover(e, item.id); e.stopPropagation(); }} - color="primary" - type="discoverApp" + color='primary' + type='discoverApp' > @@ -436,9 +461,9 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { } isOpen={state.actionsOpen === item.id} closePopover={() => closeActionsMenu()} - panelPaddingSize="none" + panelPaddingSize='none' style={{ width: '100%' }} - anchorPosition="downLeft" + anchorPosition='downLeft' > @@ -447,8 +472,10 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { }); if (isSearching || loadingAlerts || isLoading) { return ( - - + + ); } @@ -456,7 +483,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { return ( { ); } else { return ( - + ); } }; - const onChange = (searchValue) => { + const onChange = searchValue => { if (!searchValue) { setState({ ...state, filteredTechniques: false }); setIsSearching(false); } }; - const onSearch = async (searchValue) => { + const onSearch = async searchValue => { try { if (searchValue) { setIsSearching(true); @@ -490,8 +521,12 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { search: searchValue, }, }); - const filteredTechniques = (((response || {}).data || {}).data.affected_items || []).map( - (item) => [item].filter((reference) => reference.source === MITRE_ATTACK)[0].external_id + const filteredTechniques = ( + ((response || {}).data || {}).data.affected_items || [] + ).map( + item => + [item].filter(reference => reference.source === MITRE_ATTACK)[0] + .external_id, ); setState({ ...state, @@ -525,7 +560,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { setState({ ...state, actionsOpen: false }); }; - const showFlyout = (techniqueData) => { + const showFlyout = techniqueData => { setState({ ...state, isFlyoutVisible: true, @@ -545,7 +580,7 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => {
- +

Techniques

@@ -555,23 +590,27 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { Hide techniques with no alerts   - hideAlerts()} /> + hideAlerts()} + />
- + - +
{renderFacet()}
@@ -580,12 +619,15 @@ export const Techniques = withWindowSize((props: tTechniquesProps) => { onChangeFlyout={onChangeFlyout} currentTechnique={currentTechnique} filterParams={{ - filters: [...filterParams.filters, ...getMitreRuleIdFilter(currentTechnique)], // the flyout must receive the filters from the mitre global search bar + filters: [ + ...filterParams.filters, + ...getMitreRuleIdFilter(currentTechnique), + ], // the flyout must receive the filters from the mitre global search bar query: filterParams.query, time: filterParams.time, }} - openDashboard={(e) => openDashboard(e, currentTechnique)} - openDiscover={(e) => openDiscover(e, currentTechnique)} + openDashboard={e => openDashboard(e, currentTechnique)} + openDiscover={e => openDiscover(e, currentTechnique)} /> )}
diff --git a/plugins/main/public/components/search-bar/index.tsx b/plugins/main/public/components/search-bar/index.tsx index d0538739de..d7a3f80989 100644 --- a/plugins/main/public/components/search-bar/index.tsx +++ b/plugins/main/public/components/search-bar/index.tsx @@ -77,7 +77,6 @@ export const SearchBar = ({ const onChangeInput = (event: React.ChangeEvent) => setInput(event.target.value); - // Handler when pressing a key const onKeyPressHandler = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { _onSearch(queryLanguageOutputRun.output); @@ -89,6 +88,7 @@ export const SearchBar = ({ ); useEffect(() => { + let isMounted = true; // React to external changes and set the internal input text. Use the `transformInput` of // the query language in use rest.input && @@ -102,15 +102,14 @@ export const SearchBar = ({ }, ), ); - }, [rest.input]); - useEffect(() => { (async () => { // Set the query language output debounceUpdateSearchBarTimer.current && clearTimeout(debounceUpdateSearchBarTimer.current); // Debounce the updating of the search bar state debounceUpdateSearchBarTimer.current = setTimeout(async () => { + if (!isMounted) return; const queryLanguageOutput = await searchBarQueryLanguages[ queryLanguage.id ].run(input, { @@ -137,16 +136,26 @@ export const SearchBar = ({ setQueryLanguageOutputRun(queryLanguageOutput); }, SEARCH_BAR_DEBOUNCE_UPDATE_TIME); })(); + + return () => { + isMounted = false; + debounceUpdateSearchBarTimer.current && + clearTimeout(debounceUpdateSearchBarTimer.current); + }; }, [input, queryLanguage, selectedQueryLanguageParameters?.options]); useEffect(() => { - onChange && + if (!onChange) return; + + if ( // Ensure the previous output is different to the new one !_.isEqual( queryLanguageOutputRun.output, queryLanguageOutputRunPreviousOutput.current, - ) && + ) + ) { onChange(queryLanguageOutputRun.output); + } }, [queryLanguageOutputRun.output]); const onQueryLanguagePopoverSwitch = () => diff --git a/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-service.ts b/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-service.ts index 20e584130c..72dad838a6 100644 --- a/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-service.ts +++ b/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-service.ts @@ -12,12 +12,17 @@ interface Last24HoursAlerts { indexPatternName: string; } +interface Options { + signal?: AbortSignal; +} + /** * This fetch the last 24 hours alerts from the selected cluster * TODO: The search function should be moved to a common place */ export const getLast24HoursAlerts = async ( ruleLevelRange, + options: Options = {}, ): Promise => { try { const currentIndexPattern = await getDataPlugin().indexPatterns.get( @@ -36,7 +41,7 @@ export const getLast24HoursAlerts = async ( ruleLevelRange, ); - const result = await search(lastAlertsQuery); + const result = await search(lastAlertsQuery, options); const count = result?.hits?.total; return { count, diff --git a/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-stat.tsx b/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-stat.tsx index 9f9e5bb739..4c578586e5 100644 --- a/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-stat.tsx +++ b/plugins/main/public/controllers/overview/components/last-alerts-stat/last-alerts-stat.tsx @@ -15,10 +15,14 @@ import { ErrorFactory, HttpError, } from '../../../../react-services/error-management'; +import { useIsMounted } from '../../../../components/common/hooks/use-is-mounted'; export function LastAlertsStat({ severity }: { severity: string }) { const [countLastAlerts, setCountLastAlerts] = useState(null); const [discoverLocation, setDiscoverLocation] = useState(''); + + const { isComponentMounted, getAbortController } = useIsMounted(); + const severityLabel = { low: { label: 'Low', @@ -58,46 +62,57 @@ export function LastAlertsStat({ severity }: { severity: string }) { try { const { indexPatternName, cluster, count } = await getLast24HoursAlerts( severityLabel[severity].ruleLevelRange, + { signal }, ); - setCountLastAlerts(count); - const core = getCore(); + if (isComponentMounted()) { + setCountLastAlerts(count); + const core = getCore(); - let discoverLocation = { - app: 'data-explorer', - basePath: 'discover', - }; + let discoverLocation = { + app: 'data-explorer', + basePath: 'discover', + }; - // TODO: find a better way to get the query discover URL - const destURL = core.application.getUrlForApp(discoverLocation.app, { - path: `${ - discoverLocation.basePath - }#?_a=(discover:(columns:!(_source),isDirty:!f,sort:!()),metadata:(indexPattern:'${indexPatternName}',view:discover))&_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'${indexPatternName}',key:${ - cluster.field - },negate:!f,params:(query:${ - cluster.name - }),type:phrase),query:(match_phrase:(${cluster.field}:${ - cluster.name - }))),('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'${indexPatternName}',key:rule.level,negate:!f,params:(gte:${ - severityLabel[severity].ruleLevelRange.minRuleLevel - },lte:${ - severityLabel[severity].ruleLevelRange.maxRuleLevel || '!n' - }),type:range),range:(rule.level:(gte:${ - severityLabel[severity].ruleLevelRange.minRuleLevel - },lte:${ - severityLabel[severity].ruleLevelRange.maxRuleLevel || '!n' - })))),refreshInterval:(pause:!t,value:0),time:(from:now-24h,to:now))&_q=(filters:!(),query:(language:kuery,query:''))`, - }); - setDiscoverLocation(destURL); + // TODO: find a better way to get the query discover URL + const destURL = core.application.getUrlForApp(discoverLocation.app, { + path: `${ + discoverLocation.basePath + }#?_a=(discover:(columns:!(_source),isDirty:!f,sort:!()),metadata:(indexPattern:'${indexPatternName}',view:discover))&_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'${indexPatternName}',key:${ + cluster.field + },negate:!f,params:(query:${ + cluster.name + }),type:phrase),query:(match_phrase:(${cluster.field}:${ + cluster.name + }))),('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'${indexPatternName}',key:rule.level,negate:!f,params:(gte:${ + severityLabel[severity].ruleLevelRange.minRuleLevel + },lte:${ + severityLabel[severity].ruleLevelRange.maxRuleLevel || '!n' + }),type:range),range:(rule.level:(gte:${ + severityLabel[severity].ruleLevelRange.minRuleLevel + },lte:${ + severityLabel[severity].ruleLevelRange.maxRuleLevel || '!n' + })))),refreshInterval:(pause:!t,value:0),time:(from:now-24h,to:now))&_q=(filters:!(),query:(language:kuery,query:''))`, + }); + setDiscoverLocation(destURL); + } } catch (error) { - const searchError = ErrorFactory.create(HttpError, { - error, - message: 'Error fetching last alerts', - }); - ErrorHandler.handleError(searchError); + if (error.name !== 'AbortError') { + const searchError = ErrorFactory.create(HttpError, { + error, + message: 'Error fetching last alerts', + }); + ErrorHandler.handleError(searchError); + } } }; + getCountLastAlerts(); - }, []); + + return () => { + const abortController = getAbortController(); + abortController.abort(); + }; + }, [severity, isComponentMounted, getAbortController]); return (