From a29d98fb3e5dae22e79cc5a466e3d3949c72e7d4 Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Tue, 29 Jul 2025 00:05:38 +0200 Subject: [PATCH] [AI4DSOC] Change the logic to query the last alert ingested in an integration (#228602) ## Summary This PR changes the logic used in the AI4DSOC alert summary page integration section to display the last sync value for each integration card displayed at the top of the page In the previous logic we were fetching all dataStreams and use the `last_activity_ms` value to display as the last sync for the integration. We [realized](https://github.com/elastic/kibana/issues/220617) that some dataStreams have event not related to ingested alert. For example it could be event mentioning that an error is happening... This meant that the alert summary UI was showing an incorrect last sync value. The new logic leverage a call using ESQL that works as follow: ``` FROM logs-integrationName.alert-default | WHERE event.kind == "alert" | SORT event.ingested DESC | KEEP event.ingested | LIMIT 1 ``` We fetch the information for each integration. We retrieve the last document with `event.kind == 'alert'` then we sort to get the most recent first, select only the `event.ingested` field/value pair and keep only the first document. The value from the `event.ingested` field is then passed to the integration card and displayed for the last sync. The PR also sets up a 30 second interval, to make sure that the values in the integration card last sync section are updated periodically. This will give the user an indication that the rest of the page below (charts and table) might be out of date. https://github.com/user-attachments/assets/b3524777-f994-40dd-90d1-0c757d1ec892 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios https://github.com/elastic/kibana/issues/220617 (cherry picked from commit 57292335e3cb4011c71e62afa7b3aaea4c5ce034) --- .../integrations/integration_card.test.tsx | 29 +++--- .../integrations/integration_card.tsx | 33 ++++--- .../integrations/integration_section.test.tsx | 10 +-- .../integrations/integration_section.tsx | 9 +- .../components/alert_summary/wrapper.test.tsx | 12 +-- ...se_integration_last_alert_ingested.test.ts | 64 +++++++++++++ .../use_integration_last_alert_ingested.ts | 89 +++++++++++++++++++ .../use_integrations_last_activity.test.ts | 86 ------------------ .../use_integrations_last_activity.ts | 63 ------------- 9 files changed, 202 insertions(+), 193 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integration_last_alert_ingested.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts 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 273838542a97f..20bf07905e5c6 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 = { @@ -36,13 +38,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'); @@ -53,14 +56,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 a112599a7a1ea..4276536c61ec7 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[] = [ @@ -56,9 +56,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(); @@ -70,7 +70,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 2013ce3dfb586..e3c1320841c7a 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'); @@ -95,9 +95,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() }, @@ -164,9 +164,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 187a0e2e97013..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts +++ /dev/null @@ -1,86 +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[] = [ - { - id: 'splunk', - name: 'splunk', - icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], - status: installationStatuses.Installed, - title: 'Splunk', - version: '', - }, - { - id: 'google_secops', - name: 'google_secops', - icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], - 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] - ); -};