diff --git a/x-pack/platform/plugins/shared/fleet/common/index.ts b/x-pack/platform/plugins/shared/fleet/common/index.ts
index 62e91d12e0a47..9c1e843faf74a 100644
--- a/x-pack/platform/plugins/shared/fleet/common/index.ts
+++ b/x-pack/platform/plugins/shared/fleet/common/index.ts
@@ -122,6 +122,7 @@ export type {
// Models
Agent,
AgentStatus,
+ DataStream,
FleetServerAgentMetadata,
AgentMetadata,
NewAgentPolicy,
diff --git a/x-pack/platform/plugins/shared/fleet/public/index.ts b/x-pack/platform/plugins/shared/fleet/public/index.ts
index 1c9313c794534..f5d95f1325aba 100644
--- a/x-pack/platform/plugins/shared/fleet/public/index.ts
+++ b/x-pack/platform/plugins/shared/fleet/public/index.ts
@@ -91,6 +91,7 @@ export const AvailablePackagesHook = () => {
);
};
+export { useGetDataStreams } from './hooks/use_request/data_stream';
export { useGetPackagesQuery } from './hooks/use_request/epm';
export { useGetSettingsQuery } from './hooks/use_request/settings';
export { useLink } from './hooks/use_link';
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
new file mode 100644
index 0000000000000..273838542a97f
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx
@@ -0,0 +1,73 @@
+/*
+ * 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 React from 'react';
+import { render } from '@testing-library/react';
+import type { PackageListItem } from '@kbn/fleet-plugin/common';
+import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
+import {
+ IntegrationCard,
+ LAST_ACTIVITY_LOADING_SKELETON_TEST_ID,
+ LAST_ACTIVITY_VALUE_TEST_ID,
+} from './integration_card';
+import { useKibana } from '@kbn/kibana-react-plugin/public';
+
+jest.mock('@kbn/kibana-react-plugin/public');
+
+const dataTestSubj = 'test-id';
+const integration: PackageListItem = {
+ id: 'splunk',
+ icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
+ name: 'splunk',
+ status: installationStatuses.NotInstalled,
+ title: 'Splunk',
+ version: '0.1.0',
+};
+
+describe('', () => {
+ beforeEach(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: { http: { basePath: { prepend: jest.fn() } } },
+ });
+ });
+
+ it('should render the card with skeleton while loading last activity', () => {
+ const { getByTestId, queryByTestId } = render(
+
+ );
+
+ expect(getByTestId(dataTestSubj)).toHaveTextContent('Splunk');
+ expect(
+ getByTestId(`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`)
+ ).toBeInTheDocument();
+ expect(queryByTestId(`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`)).not.toBeInTheDocument();
+ });
+
+ it('should render the card with last activity value', () => {
+ const lastActivity = 1735711200000; // Wed Jan 01 2025 00:00:00 GMT-0600 (Central Standard Time)
+ const { getByTestId, queryByTestId } = render(
+
+ );
+
+ expect(
+ queryByTestId(`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`)
+ ).not.toBeInTheDocument();
+ expect(getByTestId(`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`)).toHaveTextContent(
+ 'Last synced: 2025-01-01T06:00:00Z'
+ );
+ });
+});
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
new file mode 100644
index 0000000000000..4e72e0bbad89d
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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 React, { memo } from 'react';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSkeletonText,
+ EuiText,
+ useEuiTheme,
+} from '@elastic/eui';
+import { css } from '@emotion/react';
+import { i18n } from '@kbn/i18n';
+import type { PackageListItem } from '@kbn/fleet-plugin/common';
+import { CardIcon } from '@kbn/fleet-plugin/public';
+import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
+
+const LAST_SYNCED = i18n.translate(
+ 'xpack.securitySolution.alertSummary.integrations.lastSyncedLabel',
+ {
+ defaultMessage: 'Last synced: ',
+ }
+);
+
+const MIN_WIDTH = 200;
+
+export const LAST_ACTIVITY_LOADING_SKELETON_TEST_ID = '-last-activity-loading-skeleton';
+export const LAST_ACTIVITY_VALUE_TEST_ID = '-last-activity-value';
+
+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
+ */
+ ['data-test-subj']?: string;
+}
+
+/**
+ * Rendered on the alert summary page. The card displays the icon, name and last sync value.
+ */
+export const IntegrationCard = memo(
+ ({ 'data-test-subj': dataTestSubj, integration, isLoading, lastActivity }: IntegrationProps) => {
+ const { euiTheme } = useEuiTheme();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {integration.title}
+
+
+
+
+
+ {LAST_SYNCED}
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+IntegrationCard.displayName = 'IntegrationCard';
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
new file mode 100644
index 0000000000000..fbf9a01b37518
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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 React from 'react';
+import { render } from '@testing-library/react';
+import type { PackageListItem } from '@kbn/fleet-plugin/common';
+import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
+import {
+ ADD_INTEGRATIONS_BUTTON_TEST_ID,
+ CARD_TEST_ID,
+ IntegrationSection,
+} from './integration_section';
+import { useAddIntegrationsUrl } from '../../../../common/hooks/use_add_integrations_url';
+import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity';
+import { useKibana } from '@kbn/kibana-react-plugin/public';
+
+jest.mock('../../../../common/hooks/use_add_integrations_url');
+jest.mock('../../../hooks/alert_summary/use_integrations_last_activity');
+jest.mock('@kbn/kibana-react-plugin/public');
+
+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('', () => {
+ beforeEach(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: { navigateToApp: jest.fn() },
+ http: {
+ basePath: {
+ prepend: jest.fn().mockReturnValue('/app/integrations/detail/splunk-0.1.0/overview'),
+ },
+ },
+ },
+ });
+ });
+
+ it('should render a card for each integration ', () => {
+ (useAddIntegrationsUrl as jest.Mock).mockReturnValue({ onClick: jest.fn() });
+ (useIntegrationsLastActivity as jest.Mock).mockReturnValue({
+ isLoading: true,
+ lastActivities: {},
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId(`${CARD_TEST_ID}splunk`)).toHaveTextContent('Splunk');
+ expect(getByTestId(`${CARD_TEST_ID}google_secops`)).toHaveTextContent('Google SecOps');
+ });
+
+ it('should navigate to the fleet page when clicking on the add integrations button', () => {
+ const addIntegration = jest.fn();
+ (useAddIntegrationsUrl as jest.Mock).mockReturnValue({
+ onClick: addIntegration,
+ });
+ (useIntegrationsLastActivity as jest.Mock).mockReturnValue([]);
+
+ const { getByTestId } = render();
+
+ getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID).click();
+
+ expect(addIntegration).toHaveBeenCalled();
+ });
+});
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
new file mode 100644
index 0000000000000..465550be3994d
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx
@@ -0,0 +1,71 @@
+/*
+ * 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 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 { useAddIntegrationsUrl } from '../../../../common/hooks/use_add_integrations_url';
+
+const ADD_INTEGRATION = i18n.translate(
+ 'xpack.securitySolution.alertSummary.integrations.addIntegrationButtonLabel',
+ {
+ defaultMessage: 'Add integration',
+ }
+);
+
+export const CARD_TEST_ID = 'alert-summary-integration-card-';
+export const ADD_INTEGRATIONS_BUTTON_TEST_ID = 'alert-summary-add-integrations-button';
+
+export interface IntegrationSectionProps {
+ /**
+ * List of installed AI for SOC integrations
+ */
+ packages: PackageListItem[];
+}
+
+/**
+ * Section rendered at the top of the alert summary page. It displays all the AI for SOC installed integrations
+ * and allow the user to add more integrations by clicking on a button that links to a Fleet page.
+ * Each integration card is also displaying the last time the sync happened (using streams).
+ */
+export const IntegrationSection = memo(({ packages }: IntegrationSectionProps) => {
+ const { onClick: addIntegration } = useAddIntegrationsUrl(); // TODO this link might have to be revisited once the integration work is done
+ const { isLoading, lastActivities } = useIntegrationsLastActivity({ packages });
+
+ return (
+
+
+
+ {packages.map((pkg) => (
+
+
+
+ ))}
+
+
+
+
+ {ADD_INTEGRATION}
+
+
+
+ );
+});
+
+IntegrationSection.displayName = 'IntegrationSection';
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 1b7c36634453b..5d010a04c64b9 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
@@ -18,6 +18,9 @@ import {
} from './wrapper';
import { useKibana } from '../../../common/lib/kibana';
import { TestProviders } from '../../../common/mock';
+import { useAddIntegrationsUrl } from '../../../common/hooks/use_add_integrations_url';
+import { useIntegrationsLastActivity } from '../../hooks/alert_summary/use_integrations_last_activity';
+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';
@@ -26,6 +29,8 @@ jest.mock('../../../common/components/search_bar', () => ({
SiemSearchBar: () =>
,
}));
jest.mock('../../../common/lib/kibana');
+jest.mock('../../../common/hooks/use_add_integrations_url');
+jest.mock('../../hooks/alert_summary/use_integrations_last_activity');
const packages: PackageListItem[] = [
{
@@ -47,6 +52,7 @@ describe('', () => {
clearInstanceCache: jest.fn(),
},
},
+ http: { basePath: { prepend: jest.fn() } },
},
});
@@ -86,6 +92,11 @@ describe('', () => {
});
it('should render the content if the dataView is created correctly', async () => {
+ (useAddIntegrationsUrl as jest.Mock).mockReturnValue({ onClick: jest.fn() });
+ (useIntegrationsLastActivity as jest.Mock).mockReturnValue({
+ isLoading: true,
+ lastActivities: {},
+ });
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
@@ -116,6 +127,7 @@ describe('', () => {
expect(getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(CONTENT_TEST_ID)).toBeInTheDocument();
+ expect(getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(getByTestId(KPIS_SECTION)).toBeInTheDocument();
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx
index 102314a2d79c0..39804a806817a 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx
@@ -18,6 +18,7 @@ import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { useKibana } from '../../../common/lib/kibana';
import { KPIsSection } from './kpis/kpis_section';
+import { IntegrationSection } from './integrations/integration_section';
import { SearchBarSection } from './search_bar/search_bar_section';
const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.alertSummary.dataViewError', {
@@ -92,6 +93,8 @@ export const Wrapper = memo(({ packages }: WrapperProps) => {
/>
) : (
+
+
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
new file mode 100644
index 0000000000000..187a0e2e97013
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts
@@ -0,0 +1,86 @@
+/*
+ * 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
new file mode 100644
index 0000000000000..58ebefb1865d7
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts
@@ -0,0 +1,63 @@
+/*
+ * 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]
+ );
+};