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] + ); +};