diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/esql.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/esql.test.ts new file mode 100644 index 0000000000000..a7c675af4803a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/esql.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { esqlResponseToRecords } from './esql'; + +describe('esqlResponseToRecords', () => { + it('returns an empty array if the response is undefined', () => { + const result = esqlResponseToRecords(undefined); + expect(result).toEqual([]); + }); + + it('converts ESQL response to records', () => { + const response = { + columns: [ + { name: 'field1', type: '' }, + { name: 'field2', type: '' }, + ], + values: [ + ['value1', 'value2'], + ['value3', 'value4'], + ], + }; + const result = esqlResponseToRecords(response); + expect(result).toEqual([ + { field1: 'value1', field2: 'value2' }, + { field1: 'value3', field2: 'value4' }, + ]); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/esql.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/esql.ts new file mode 100644 index 0000000000000..6faceee151b48 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/esql.ts @@ -0,0 +1,24 @@ +/* + * 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 { ESQLSearchResponse } from '@kbn/es-types'; + +// Function copied from elasticsearch-8.x/lib/helpers +export function esqlResponseToRecords>( + response: ESQLSearchResponse | undefined +): TDocument[] { + if (!response) return []; + const { columns, values } = response; + return values.map((row) => { + const doc: Record = {}; + row.forEach((cell, index) => { + const { name } = columns[index]; + doc[name] = cell; + }); + return doc as TDocument; + }); +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx index 49ae302d3782b..a67caa72dc1ab 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx @@ -15,8 +15,10 @@ import { LAST_ACTIVITY_VALUE_TEST_ID, } from './integration_card'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useIntegrationLastAlertIngested } from '../../../hooks/alert_summary/use_integration_last_alert_ingested'; jest.mock('@kbn/kibana-react-plugin/public'); +jest.mock('../../../hooks/alert_summary/use_integration_last_alert_ingested'); const dataTestSubj = 'test-id'; const integration: PackageListItem = { @@ -39,13 +41,14 @@ describe('', () => { }); it('should render the card with skeleton while loading last activity', () => { + (useIntegrationLastAlertIngested as jest.Mock).mockReturnValue({ + isLoading: true, + lastAlertIngested: null, + refetch: jest.fn(), + }); + const { getByTestId, queryByTestId } = render( - + ); expect(getByTestId(dataTestSubj)).toHaveTextContent('Splunk'); @@ -56,14 +59,14 @@ describe('', () => { }); it('should render the card with last activity value', () => { - const lastActivity = 1735711200000; // Wed Jan 01 2025 00:00:00 GMT-0600 (Central Standard Time) + (useIntegrationLastAlertIngested as jest.Mock).mockReturnValue({ + isLoading: false, + lastAlertIngested: 1735711200000, // Wed Jan 01 2025 00:00:00 GMT-0600 (Central Standard Time) + refetch: jest.fn(), + }); + const { getByTestId, queryByTestId } = render( - + ); expect( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx index 4e62ab1b52ef0..02ae9f4fde695 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -17,6 +17,7 @@ import { import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { useIntegrationLastAlertIngested } from '../../../hooks/alert_summary/use_integration_last_alert_ingested'; import { IntegrationIcon } from '../common/integration_icon'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; @@ -28,6 +29,7 @@ const LAST_SYNCED = i18n.translate( ); const MIN_WIDTH = 200; +const REFRESH_INTERVAL = 30000; // 30 seconds export const LAST_ACTIVITY_LOADING_SKELETON_TEST_ID = '-last-activity-loading-skeleton'; export const LAST_ACTIVITY_VALUE_TEST_ID = '-last-activity-value'; @@ -37,14 +39,6 @@ export interface IntegrationProps { * Installed AI for SOC integration */ integration: PackageListItem; - /** - * True while retrieving data streams to provide the last activity value - */ - isLoading: boolean; - /** - * Timestamp of the last time the integration synced (via data streams) - */ - lastActivity: number | undefined; /** * Data test subject string for testing */ @@ -52,12 +46,23 @@ export interface IntegrationProps { } /** - * Rendered on the alert summary page. The card displays the icon, name and last sync value. + * Rendered on the alert summary page. The card displays the icon, name and last time the integration received alert data. */ export const IntegrationCard = memo( - ({ 'data-test-subj': dataTestSubj, integration, isLoading, lastActivity }: IntegrationProps) => { + ({ 'data-test-subj': dataTestSubj, integration }: IntegrationProps) => { const { euiTheme } = useEuiTheme(); + const { isLoading, lastAlertIngested, refetch } = useIntegrationLastAlertIngested({ + integrationName: integration.name, + }); + + // force a refresh every 30 seconds to update the last activity time + useEffect(() => { + const interval = setInterval(() => refetch(), REFRESH_INTERVAL); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( {LAST_SYNCED} - + diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx index f17f738bf8cbb..ea566e3fc7b8a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx @@ -14,12 +14,12 @@ import { CARD_TEST_ID, IntegrationSection, } from './integration_section'; -import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity'; +import { useIntegrationLastAlertIngested } from '../../../hooks/alert_summary/use_integration_last_alert_ingested'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useNavigateToIntegrationsPage } from '../../../hooks/alert_summary/use_navigate_to_integrations_page'; jest.mock('../../../hooks/alert_summary/use_navigate_to_integrations_page'); -jest.mock('../../../hooks/alert_summary/use_integrations_last_activity'); +jest.mock('../../../hooks/alert_summary/use_integration_last_alert_ingested'); jest.mock('@kbn/kibana-react-plugin/public'); const packages: PackageListItem[] = [ @@ -62,9 +62,9 @@ describe('', () => { it('should render a card for each integration ', () => { (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(jest.fn()); - (useIntegrationsLastActivity as jest.Mock).mockReturnValue({ + (useIntegrationLastAlertIngested as jest.Mock).mockReturnValue({ isLoading: true, - lastActivities: {}, + lastAlertIngested: {}, }); const { getByTestId } = render(); @@ -76,7 +76,7 @@ describe('', () => { it('should navigate to the fleet page when clicking on the add integrations button', () => { const navigateToIntegrationsPage = jest.fn(); (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(navigateToIntegrationsPage); - (useIntegrationsLastActivity as jest.Mock).mockReturnValue([]); + (useIntegrationLastAlertIngested as jest.Mock).mockReturnValue([]); const { getByTestId } = render(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx index 3854b8c961b69..c69fbf655dfff 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx @@ -9,7 +9,6 @@ import React, { memo } from 'react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { PackageListItem } from '@kbn/fleet-plugin/common'; -import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity'; import { IntegrationCard } from './integration_card'; import { useNavigateToIntegrationsPage } from '../../../hooks/alert_summary/use_navigate_to_integrations_page'; @@ -37,7 +36,6 @@ export interface IntegrationSectionProps { */ export const IntegrationSection = memo(({ packages }: IntegrationSectionProps) => { const navigateToIntegrationsPage = useNavigateToIntegrationsPage(); - const { isLoading, lastActivities } = useIntegrationsLastActivity({ packages }); return ( @@ -45,12 +43,7 @@ export const IntegrationSection = memo(({ packages }: IntegrationSectionProps) = {packages.map((pkg) => ( - + ))} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx index 7eb899629f3eb..51899cce383d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx @@ -17,7 +17,7 @@ import { Wrapper, } from './wrapper'; import { TestProviders } from '../../../common/mock'; -import { useIntegrationsLastActivity } from '../../hooks/alert_summary/use_integrations_last_activity'; +import { useIntegrationLastAlertIngested } from '../../hooks/alert_summary/use_integration_last_alert_ingested'; import { ADD_INTEGRATIONS_BUTTON_TEST_ID } from './integrations/integration_section'; import { SEARCH_BAR_TEST_ID } from './search_bar/search_bar_section'; import { KPIS_SECTION } from './kpis/kpis_section'; @@ -35,7 +35,7 @@ jest.mock('../alerts_table/alerts_grouping', () => ({ GroupedAlertsTable: () =>
, })); jest.mock('../../hooks/alert_summary/use_navigate_to_integrations_page'); -jest.mock('../../hooks/alert_summary/use_integrations_last_activity'); +jest.mock('../../hooks/alert_summary/use_integration_last_alert_ingested'); jest.mock('../../../common/hooks/use_experimental_features'); jest.mock('../../../common/hooks/use_create_data_view'); jest.mock('../../../data_view_manager/hooks/use_data_view'); @@ -98,9 +98,9 @@ describe('', () => { it('should render the content if the dataView is created correctly', async () => { (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(jest.fn()); - (useIntegrationsLastActivity as jest.Mock).mockReturnValue({ + (useIntegrationLastAlertIngested as jest.Mock).mockReturnValue({ isLoading: true, - lastActivities: {}, + lastAlertIngested: {}, }); (useCreateDataView as jest.Mock).mockReturnValue({ dataView: { getIndexPattern: jest.fn(), id: 'id', toSpec: jest.fn() }, @@ -167,9 +167,9 @@ describe('', () => { it('should render the content if the dataView is created correctly', async () => { (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(jest.fn()); - (useIntegrationsLastActivity as jest.Mock).mockReturnValue({ + (useIntegrationLastAlertIngested as jest.Mock).mockReturnValue({ isLoading: true, - lastActivities: {}, + lastAlertIngested: {}, }); (useDataView as jest.Mock).mockReturnValue({ dataView: { getIndexPattern: jest.fn(), id: 'id', toSpec: jest.fn() }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.test.ts new file mode 100644 index 0000000000000..17b47b7da7dbd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.test.ts @@ -0,0 +1,64 @@ +/* + * 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 } from '@testing-library/react'; +import { useIntegrationLastAlertIngested } from './use_integration_last_alert_ingested'; +import { useQuery } from '@tanstack/react-query'; + +jest.mock('@tanstack/react-query'); + +const integrationName = 'splunk'; + +describe('useIntegrationLastAlertIngested', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return isLoading true', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: true, + data: undefined, + refetch: jest.fn(), + }); + + const { result } = renderHook(() => useIntegrationLastAlertIngested({ integrationName })); + + expect(result.current.isLoading).toBe(true); + expect(result.current.lastAlertIngested).toEqual(null); + }); + + it('should return last AlertIngested', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: { + response: { + columns: [{ name: 'event.ingested' }], + values: [['2025-01-01T00:00:000Z']], + }, + }, + refetch: jest.fn(), + }); + + const { result } = renderHook(() => useIntegrationLastAlertIngested({ integrationName })); + + expect(result.current.isLoading).toBe(false); + expect(result.current.lastAlertIngested).toEqual('2025-01-01T00:00:000Z'); + }); + + it('should return refetch function', () => { + const refetch = jest.fn(); + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: {}, + refetch, + }); + + const { result } = renderHook(() => useIntegrationLastAlertIngested({ integrationName })); + + expect(result.current.refetch).toBe(refetch); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.ts new file mode 100644 index 0000000000000..9c4ad90db9744 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.ts @@ -0,0 +1,89 @@ +/* + * 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 { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getESQLResults } from '@kbn/esql-utils'; +import { esqlResponseToRecords } from '../../../common/utils/esql'; +import { useKibana } from '../../../common/lib/kibana'; + +const FIELD = 'event.ingested'; + +export interface UseIntegrationsLastAlertIngestedParams { + /** + * List of installed AI for SOC integrations + */ + integrationName: string; +} + +export interface UseIntegrationsLastAlertIngestedResult { + /** + * Is true while the data is loading + */ + isLoading: boolean; + /** + * The time at which the last alert was ingested in the integration. + */ + lastAlertIngested: string | null; + /** + * Refetch function from useQuery to manually refetching. + */ + refetch: () => void; +} + +/** + * Hook that fetches the last alert ingested time for a specific integration. + * We use the index pattern `logs-{integrationName}.alert-default` to query. + */ +export const useIntegrationLastAlertIngested = ({ + integrationName, +}: UseIntegrationsLastAlertIngestedParams): UseIntegrationsLastAlertIngestedResult => { + const { data } = useKibana().services; + + // ESQL query to get the last alert ingested in the index + // We only keep the event.ingested field as it contains the time we want to display on the Integration card. + const query = useMemo( + () => `FROM ${`logs-${integrationName}.alert-default`} + | WHERE event.kind == "alert" + | SORT ${FIELD} DESC + | KEEP ${FIELD} + | LIMIT 1`, + [integrationName] + ); + + const { + isLoading, + data: result, + refetch, + } = useQuery( + [integrationName], + async ({ signal }) => + getESQLResults({ + esqlQuery: query, + search: data.search.search, + signal, + }), + { + refetchOnWindowFocus: false, + keepPreviousData: true, + } + ); + + const lastAlertIngested: string | null = useMemo(() => { + const records = esqlResponseToRecords<{ [FIELD]: string }>(result?.response); + return records.length > 0 ? records[0][FIELD] : null; + }, [result?.response]); + + return useMemo( + () => ({ + isLoading, + lastAlertIngested, + refetch, + }), + [isLoading, lastAlertIngested, refetch] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts deleted file mode 100644 index 0cb4ddcfda05c..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts +++ /dev/null @@ -1,92 +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 { renderHook } from '@testing-library/react'; -import { installationStatuses, useGetDataStreams } from '@kbn/fleet-plugin/public'; -import { useIntegrationsLastActivity } from './use_integrations_last_activity'; -import type { PackageListItem } from '@kbn/fleet-plugin/common'; - -jest.mock('@kbn/fleet-plugin/public'); - -const oldestLastActivity = 1735711200000; -const newestLastActivity = oldestLastActivity + 1000; - -const packages: PackageListItem[] = [ - { - description: '', - download: '', - id: 'splunk', - name: 'splunk', - icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], - path: '', - status: installationStatuses.Installed, - title: 'Splunk', - version: '', - }, - { - description: '', - download: '', - id: 'google_secops', - name: 'google_secops', - icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], - path: '', - status: installationStatuses.Installed, - title: 'Google SecOps', - version: '', - }, -]; - -describe('useIntegrationsLastActivity', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return isLoading true', () => { - (useGetDataStreams as jest.Mock).mockReturnValue({ - data: undefined, - isLoading: true, - }); - - const { result } = renderHook(() => useIntegrationsLastActivity({ packages })); - - expect(result.current.isLoading).toBe(true); - expect(result.current.lastActivities).toEqual({}); - }); - - it('should return an object with package name and last sync values', () => { - (useGetDataStreams as jest.Mock).mockReturnValue({ - data: { - data_streams: [{ package: 'splunk', last_activity_ms: oldestLastActivity }], - }, - isLoading: false, - }); - - const { result } = renderHook(() => useIntegrationsLastActivity({ packages })); - - expect(result.current.isLoading).toBe(false); - expect(result.current.lastActivities.splunk).toBe(oldestLastActivity); - }); - - it('should return most recent value for integration matching multiple dataStreams', () => { - (useGetDataStreams as jest.Mock).mockReturnValue({ - data: { - data_streams: [ - { package: 'splunk', last_activity_ms: oldestLastActivity }, - { package: 'splunk', last_activity_ms: newestLastActivity }, - { package: 'google_secops', last_activity_ms: oldestLastActivity }, - ], - }, - isLoading: false, - }); - - const { result } = renderHook(() => useIntegrationsLastActivity({ packages })); - - expect(result.current.isLoading).toBe(false); - expect(result.current.lastActivities.splunk).toBe(newestLastActivity); - expect(result.current.lastActivities.google_secops).toBe(oldestLastActivity); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts deleted file mode 100644 index 58ebefb1865d7..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts +++ /dev/null @@ -1,63 +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 { useMemo } from 'react'; -import type { DataStream, PackageListItem } from '@kbn/fleet-plugin/common'; -import { useGetDataStreams } from '@kbn/fleet-plugin/public'; - -export interface UseIntegrationsLastActivityParams { - /** - * List of installed AI for SOC integrations - */ - packages: PackageListItem[]; -} - -export interface UseIntegrationsLastActivityResult { - /** - * Is true while the data is loading - */ - isLoading: boolean; - /** - * Object that stores each integration name/last activity values - */ - lastActivities: { [id: string]: number }; -} - -/** - * Fetches dataStreams, finds all the dataStreams for each integration, takes the value of the latest updated stream. - * Returns an object with the package name as the key and the last time it was synced (using data streams) as the value. - */ -export const useIntegrationsLastActivity = ({ - packages, -}: UseIntegrationsLastActivityParams): UseIntegrationsLastActivityResult => { - const { data, isLoading } = useGetDataStreams(); - - // Find all the matching dataStreams for our packages, take the most recently updated one for each package. - const lastActivities: { [id: string]: number } = useMemo(() => { - const la: { [id: string]: number } = {}; - packages.forEach((p: PackageListItem) => { - const dataStreams = (data?.data_streams || []).filter( - (d: DataStream) => d.package === p.name - ); - dataStreams.sort((a, b) => b.last_activity_ms - a.last_activity_ms); - const lastActivity = dataStreams.shift(); - - if (lastActivity) { - la[p.name] = lastActivity.last_activity_ms; - } - }); - return la; - }, [data, packages]); - - return useMemo( - () => ({ - isLoading, - lastActivities, - }), - [isLoading, lastActivities] - ); -};