;
+export const RuleMigrationAllIntegrationsStats = z.array(RuleMigrationIntegrationStats);
+
/**
* The type of the rule migration resource.
*/
diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml
index 19810ba94b641..aaefd13041465 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml
+++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml
@@ -381,6 +381,26 @@ components:
- failed
- not_fully_translated
+ RuleMigrationAllIntegrationsStats:
+ type: array
+ description: The integrations stats objects of all the rule of all the migrations.
+ items:
+ description: The migration rules integration stats object.
+ $ref: '#/components/schemas/RuleMigrationIntegrationStats'
+ RuleMigrationIntegrationStats:
+ type: object
+ description: The migration rules integration stats object.
+ required:
+ - id
+ - total_rules
+ properties:
+ id:
+ description: The integration id
+ $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
+ total_rules:
+ type: integer
+ description: The number of rules that are associated with the integration.
+
## Rule migration resources
RuleMigrationResourceType:
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx
index 265eb7f1e5f65..6516bd5f5bce9 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx
@@ -6,6 +6,15 @@
*/
import React, { useEffect, useState } from 'react';
+/**
+ * HOC to wrap a component with a lazy-loaded hook.
+ * This allows the component to use a hook that is imported dynamically,
+ * which can be useful for reducing the initial bundle size.
+ *
+ * @param Component - The component to wrap, it have to accept the hook as a prop (e.g. { useSomeHook: UseSomeHook }).
+ * @param hookImport - A function that returns a promise resolving to an object with the hook's prop (e.g. { useSomeHook: () => {} }).
+ * @param fallback - A fallback React node to render while the hook is being loaded.
+ */
export const withLazyHook = (
Component: React.ComponentType
,
hookImport: () => Promise>,
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/integrations/use_integration_link_state.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/integrations/use_integration_link_state.ts
new file mode 100644
index 0000000000000..9c22eade4b822
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/integrations/use_integration_link_state.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { GetAppUrl } from '@kbn/security-solution-navigation/src/navigation';
+import { useNavigation } from '@kbn/security-solution-navigation/src/navigation';
+import { APP_UI_ID } from '../../../../common';
+
+export const useIntegrationLinkState = (path: string) => {
+ const { getAppUrl } = useNavigation();
+
+ return useMemo(() => getIntegrationLinkState(path, getAppUrl), [getAppUrl, path]);
+};
+
+export const getIntegrationLinkState = (path: string, getAppUrl: GetAppUrl) => {
+ const url = getAppUrl({
+ appId: APP_UI_ID,
+ path,
+ });
+
+ return {
+ onCancelNavigateTo: [APP_UI_ID, { path }],
+ onCancelUrl: url,
+ onSaveNavigateTo: [APP_UI_ID, { path }],
+ };
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/integration_card_grid_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/security_integrations.tsx
similarity index 74%
rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/integration_card_grid_tabs.tsx
rename to x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/security_integrations.tsx
index 660d7b881e397..4b420465a90c6 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/integration_card_grid_tabs.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/security_integrations.tsx
@@ -7,4 +7,4 @@
import React from 'react';
-export const IntegrationsCardGridTabs = () => ;
+export const SecurityIntegrations = jest.fn(() => );
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/security_integrations_grid_tabs.tsx
similarity index 70%
rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/index.tsx
rename to x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/security_integrations_grid_tabs.tsx
index 39e4652fe6dfe..dfa6562062767 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/index.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/security_integrations_grid_tabs.tsx
@@ -6,4 +6,6 @@
*/
import React from 'react';
-export const SecurityIntegrations = () => ;
+export const SecurityIntegrationsGridTabs = jest.fn(() => (
+
+));
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/with_available_packages.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/with_available_packages.tsx
new file mode 100644
index 0000000000000..ca8f40de97d51
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/with_available_packages.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
+
+export const getDefaultAvailablePackages = () => ({
+ initialSelectedCategory: '',
+ selectedCategory: '',
+ setCategory: jest.fn(),
+ allCategories: [],
+ mainCategories: [],
+ availableSubCategories: [],
+ selectedSubCategory: '',
+ setSelectedSubCategory: jest.fn(),
+ searchTerm: '',
+ setSearchTerm: jest.fn(),
+ setUrlandPushHistory: jest.fn(),
+ setUrlandReplaceHistory: jest.fn(),
+ preference: '',
+ setPreference: jest.fn(),
+ isLoading: false,
+ isLoadingCategories: false,
+ isLoadingAllPackages: false,
+ isLoadingAppendCustomIntegrations: false,
+ eprPackageLoadingError: null,
+ eprCategoryLoadingError: null,
+ filteredCards: [] as IntegrationCardItem[],
+});
+
+export const mockAvailablePackages = jest.fn(() => getDefaultAvailablePackages());
+
+export const withAvailablePackages = jest.fn(
+ (Component: React.ComponentType<{ availablePackages: unknown }>) =>
+ function WithAvailablePackages(props: object) {
+ return (
+
+
+
+ );
+ }
+);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/with_filtered_integrations.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/with_filtered_integrations.tsx
deleted file mode 100644
index bc3f25f0ada64..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/with_filtered_integrations.tsx
+++ /dev/null
@@ -1,9 +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 React from 'react';
-
-export const WithFilteredIntegrations = () => ;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/available_integrations.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/available_integrations.tsx
deleted file mode 100644
index f12a242175214..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/available_integrations.tsx
+++ /dev/null
@@ -1,47 +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 type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public';
-import { useSelectedTab } from '../hooks/use_selected_tab';
-import type { IntegrationCardMetadata, RenderChildrenType, TopCalloutRenderer } from '../types';
-import { useFilterCards } from '../hooks/use_filter_cards';
-import { useIntegrationContext } from '../hooks/integration_context';
-
-export const AvailableIntegrationsComponent: React.FC<{
- useAvailablePackages: AvailablePackagesHookType;
- renderChildren: RenderChildrenType;
- prereleaseIntegrationsEnabled: boolean;
- checkCompleteMetadata?: IntegrationCardMetadata;
- topCalloutRenderer?: TopCalloutRenderer;
-}> = ({
- useAvailablePackages,
- renderChildren,
- prereleaseIntegrationsEnabled,
- checkCompleteMetadata,
- topCalloutRenderer,
-}) => {
- const { spaceId, integrationTabs } = useIntegrationContext();
-
- const selectedTabResult = useSelectedTab({
- spaceId,
- integrationTabs,
- });
-
- const { availablePackagesResult, allowedIntegrations } = useFilterCards({
- featuredCardIds: selectedTabResult.selectedTab?.featuredCardIds,
- useAvailablePackages,
- prereleaseIntegrationsEnabled,
- });
-
- return renderChildren({
- allowedIntegrations,
- availablePackagesResult,
- checkCompleteMetadata,
- selectedTabResult,
- topCalloutRenderer,
- });
-};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/package_list_grid.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/index.ts
similarity index 70%
rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/package_list_grid.tsx
rename to x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/index.ts
index 759dbf78bfb88..8b8c83d880545 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/__mocks__/package_list_grid.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/index.ts
@@ -4,6 +4,4 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import React from 'react';
-
-export const PackageListGrid = () => ;
+export { SecurityIntegrations } from './security_integrations';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/index.tsx
deleted file mode 100644
index 76fa123f8a42d..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/index.tsx
+++ /dev/null
@@ -1,24 +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 React from 'react';
-import { IntegrationsCardGridTabs } from './integration_card_grid_tabs';
-import { WithFilteredIntegrations } from './with_filtered_integrations';
-import type { IntegrationCardMetadata, TopCalloutRenderer } from '../types';
-
-export const SecurityIntegrations: React.FC<{
- checkCompleteMetadata?: IntegrationCardMetadata;
- topCalloutRenderer?: TopCalloutRenderer;
-}> = ({ checkCompleteMetadata, topCalloutRenderer }) => {
- return (
-
- );
-};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs.tsx
deleted file mode 100644
index 26b47f4b48df2..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs.tsx
+++ /dev/null
@@ -1,43 +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 React from 'react';
-import type { IntegrationCardMetadata, RenderChildrenType } from '../types';
-import { useIntegrationCardList } from '../hooks/use_integration_card_list';
-import { IntegrationsCardGridTabsComponent } from './integration_card_grid_tabs_component';
-
-export const DEFAULT_CHECK_COMPLETE_METADATA: IntegrationCardMetadata = {
- activeIntegrations: [],
- isAgentRequired: false,
-};
-
-export const IntegrationsCardGridTabs: RenderChildrenType = ({
- topCalloutRenderer,
- allowedIntegrations,
- availablePackagesResult,
- checkCompleteMetadata = DEFAULT_CHECK_COMPLETE_METADATA,
- selectedTabResult,
-}) => {
- const { isAgentRequired, activeIntegrations } = checkCompleteMetadata;
-
- const list = useIntegrationCardList({
- activeIntegrations,
- integrationsList: allowedIntegrations,
- featuredCardIds: selectedTabResult.selectedTab?.featuredCardIds,
- });
- const activeIntegrationsCount = activeIntegrations?.length ?? 0;
-
- return (
-
- );
-};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations.tsx
new file mode 100644
index 0000000000000..064c23a25aaf1
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 type { IntegrationCardMetadata, TopCalloutRenderer } from '../types';
+import { useIntegrationCardList } from '../hooks/use_integration_card_list';
+import { SecurityIntegrationsGridTabs } from './security_integrations_grid_tabs';
+import { withAvailablePackages, type AvailablePackages } from './with_available_packages';
+
+export const DEFAULT_CHECK_COMPLETE_METADATA: IntegrationCardMetadata = {
+ activeIntegrations: [],
+ isAgentRequired: false,
+};
+
+interface SecurityIntegrationsProps {
+ availablePackages: AvailablePackages;
+ checkCompleteMetadata?: IntegrationCardMetadata;
+ topCalloutRenderer?: TopCalloutRenderer;
+}
+
+export const SecurityIntegrations = withAvailablePackages(
+ ({
+ availablePackages,
+ topCalloutRenderer,
+ checkCompleteMetadata = DEFAULT_CHECK_COMPLETE_METADATA,
+ }) => {
+ const { isAgentRequired, activeIntegrations } = checkCompleteMetadata;
+
+ const list = useIntegrationCardList({
+ integrationsList: availablePackages.filteredCards,
+ activeIntegrations,
+ });
+ const activeIntegrationsCount = activeIntegrations?.length ?? 0;
+
+ return (
+
+ );
+ }
+);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs_component.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations_grid_tabs.test.tsx
similarity index 58%
rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs_component.test.tsx
rename to x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations_grid_tabs.test.tsx
index 8fb04ce6af5a5..af12c80d5c27e 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs_component.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations_grid_tabs.test.tsx
@@ -7,18 +7,21 @@
import React from 'react';
import { render, fireEvent, waitFor, act } from '@testing-library/react';
-import { IntegrationsCardGridTabsComponent } from './integration_card_grid_tabs_component';
+import { SecurityIntegrationsGridTabs } from './security_integrations_grid_tabs';
import * as module from '@kbn/fleet-plugin/public';
-
+import { TestProviders } from '../../../mock';
import {
useStoredIntegrationSearchTerm,
useStoredIntegrationTabId,
} from '../hooks/use_stored_state';
import { INTEGRATION_TABS } from '../configs/integration_tabs_configs';
+import { useSelectedTab } from '../hooks/use_selected_tab';
import { mockReportLinkClick } from '../hooks/__mocks__/mocks';
+import type { AvailablePackages } from './with_available_packages';
jest.mock('../hooks/integration_context');
jest.mock('../hooks/use_stored_state');
+jest.mock('../hooks/use_selected_tab');
jest.mock('../../kibana', () => ({
...jest.requireActual('../../kibana'),
useNavigation: jest.fn().mockReturnValue({
@@ -29,7 +32,7 @@ jest.mock('../../kibana', () => ({
const mockPackageList = jest.fn<
React.JSX.Element,
- Array<{ showSearchTools?: boolean; searchTerm: string }>
+ Array<{ showSearchTools?: boolean; searchTerm: string; list: unknown[] }>
>(() => );
jest.mock('@kbn/fleet-plugin/public');
@@ -37,6 +40,14 @@ jest
.spyOn(module, 'PackageList')
.mockImplementation(() => Promise.resolve({ PackageListGrid: mockPackageList }));
+const mockUseSelectedTab = useSelectedTab as jest.MockedFunction;
+const mockUseStoredIntegrationTabId = useStoredIntegrationTabId as jest.MockedFunction<
+ typeof useStoredIntegrationTabId
+>;
+const mockUseStoredIntegrationSearchTerm = useStoredIntegrationSearchTerm as jest.MockedFunction<
+ typeof useStoredIntegrationSearchTerm
+>;
+
describe('IntegrationsCardGridTabsComponent', () => {
const mockSetTabId = jest.fn();
const mockSetCategory = jest.fn();
@@ -45,43 +56,47 @@ describe('IntegrationsCardGridTabsComponent', () => {
const props = {
activeIntegrationsCount: 1,
isAgentRequired: false,
- availablePackagesResult: {
+ availablePackages: {
isLoading: false,
setCategory: mockSetCategory,
setSelectedSubCategory: mockSetSelectedSubCategory,
setSearchTerm: mockSetSearchTerm,
searchTerm: 'new search term',
- },
+ } as unknown as AvailablePackages,
integrationList: [],
- selectedTabResult: {
- selectedTab: INTEGRATION_TABS[0],
- toggleIdSelected: INTEGRATION_TABS[0].id,
- setSelectedTabIdToStorage: mockSetTabId,
- integrationTabs: INTEGRATION_TABS,
- },
};
beforeEach(() => {
jest.clearAllMocks();
- (useStoredIntegrationTabId as jest.Mock).mockReturnValue([INTEGRATION_TABS[0].id, jest.fn()]);
- (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue(['', jest.fn()]);
+ mockUseStoredIntegrationTabId.mockReturnValue([INTEGRATION_TABS[0].id, jest.fn()]);
+ mockUseStoredIntegrationSearchTerm.mockReturnValue(['', jest.fn()]);
+ mockUseSelectedTab.mockReturnValue({
+ selectedTab: INTEGRATION_TABS[0],
+ toggleIdSelected: INTEGRATION_TABS[0].id,
+ setSelectedTabIdToStorage: mockSetTabId,
+ integrationTabs: INTEGRATION_TABS,
+ });
});
it('renders loading skeleton when data is loading', () => {
const testProps = {
...props,
- availablePackagesResult: {
- ...props.availablePackagesResult,
+ availablePackages: {
+ ...props.availablePackages,
isLoading: true,
},
};
- const { getByTestId } = render();
+ const { getByTestId } = render(, {
+ wrapper: TestProviders,
+ });
expect(getByTestId('loadingPackages')).toBeInTheDocument();
});
it('renders the package list when data is available', async () => {
- const { getByTestId } = render();
+ const { getByTestId } = render(, {
+ wrapper: TestProviders,
+ });
await waitFor(() => {
expect(getByTestId('packageList')).toBeInTheDocument();
@@ -91,9 +106,11 @@ describe('IntegrationsCardGridTabsComponent', () => {
it('saves the selected tab to storage', () => {
(useStoredIntegrationTabId as jest.Mock).mockReturnValue(['recommended', mockSetTabId]);
- const { getByTestId } = render();
+ const { getByTestId } = render(, {
+ wrapper: TestProviders,
+ });
- const tabButton = getByTestId('user');
+ const tabButton = getByTestId('securitySolutionIntegrationsTab-user');
act(() => {
fireEvent.click(tabButton);
@@ -104,9 +121,11 @@ describe('IntegrationsCardGridTabsComponent', () => {
it('tracks the tab clicks', () => {
(useStoredIntegrationTabId as jest.Mock).mockReturnValue(['recommended', mockSetTabId]);
- const { getByTestId } = render();
+ const { getByTestId } = render(, {
+ wrapper: TestProviders,
+ });
- const tabButton = getByTestId('user');
+ const tabButton = getByTestId('securitySolutionIntegrationsTab-user');
act(() => {
fireEvent.click(tabButton);
@@ -116,7 +135,7 @@ describe('IntegrationsCardGridTabsComponent', () => {
});
it('renders no search tools when showSearchTools is false', async () => {
- render();
+ render(, { wrapper: TestProviders });
await waitFor(() => {
expect(mockPackageList.mock.calls[0][0].showSearchTools).toEqual(false);
@@ -130,10 +149,29 @@ describe('IntegrationsCardGridTabsComponent', () => {
mockSetSearchTermToStorage,
]);
- render();
+ render(, { wrapper: TestProviders });
await waitFor(() => {
expect(mockPackageList.mock.calls[0][0].searchTerm).toEqual('new search term');
});
});
+
+ it('renders auto-import card if appendAutoImport is true', async () => {
+ mockUseSelectedTab.mockReturnValue({
+ selectedTab: { ...INTEGRATION_TABS[0], appendAutoImportCard: true },
+ toggleIdSelected: INTEGRATION_TABS[0].id,
+ setSelectedTabIdToStorage: mockSetTabId,
+ integrationTabs: INTEGRATION_TABS,
+ });
+
+ render(, {
+ wrapper: TestProviders,
+ });
+
+ await waitFor(() => {
+ expect(mockPackageList.mock.calls[0][0].list).toEqual([
+ expect.objectContaining({ id: 'placeholder:auto_import' }),
+ ]);
+ });
+ });
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs_component.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations_grid_tabs.tsx
similarity index 78%
rename from x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs_component.tsx
rename to x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations_grid_tabs.tsx
index 0a82443f5ada4..a8033c55691ea 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/integration_card_grid_tabs_component.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/security_integrations_grid_tabs.tsx
@@ -4,15 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import React, { lazy, Suspense, useCallback, useEffect, useRef } from 'react';
-import {
- COLOR_MODES_STANDARD,
- EuiButtonGroup,
- EuiFlexGroup,
- EuiFlexItem,
- EuiSkeletonText,
- useEuiTheme,
-} from '@elastic/eui';
+import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react';
+import type { EuiButtonGroupOptionProps } from '@elastic/eui';
+import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiSkeletonText } from '@elastic/eui';
import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
import { noop } from 'lodash';
@@ -25,19 +19,20 @@ import {
SEARCH_FILTER_CATEGORIES,
TELEMETRY_INTEGRATION_TAB,
} from '../constants';
-import type { AvailablePackagesResult, TopCalloutRenderer } from '../types';
+import type { TopCalloutRenderer } from '../types';
import { IntegrationTabId } from '../types';
-import type { UseSelectedTabReturn } from '../hooks/use_selected_tab';
+import { useSelectedTab } from '../hooks/use_selected_tab';
import { useStoredIntegrationSearchTerm } from '../hooks/use_stored_state';
import { useIntegrationContext } from '../hooks/integration_context';
+import type { AvailablePackages } from './with_available_packages';
+import { useCreateAutoImportCard } from '../hooks/use_create_auto_import_card';
-export interface IntegrationsCardGridTabsProps {
+export interface SecurityIntegrationsGridTabsProps {
activeIntegrationsCount: number;
isAgentRequired?: boolean;
- availablePackagesResult: AvailablePackagesResult;
+ availablePackages: AvailablePackages;
topCalloutRenderer?: TopCalloutRenderer;
integrationList: IntegrationCardItem[];
- selectedTabResult: UseSelectedTabReturn;
packageListGridOptions?: {
showCardLabels?: boolean;
};
@@ -52,14 +47,13 @@ export const PackageListGrid = lazy(async () => ({
}));
// beware if local storage, need to add project id to the key
-export const IntegrationsCardGridTabsComponent = React.memo(
+export const SecurityIntegrationsGridTabs = React.memo(
({
isAgentRequired,
activeIntegrationsCount,
topCalloutRenderer: TopCallout,
integrationList,
- availablePackagesResult,
- selectedTabResult,
+ availablePackages,
packageListGridOptions,
}) => {
const {
@@ -67,10 +61,28 @@ export const IntegrationsCardGridTabsComponent = React.memo(null);
- const { colorMode } = useEuiTheme();
- const isDark = colorMode === COLOR_MODES_STANDARD.dark;
const { selectedTab, toggleIdSelected, setSelectedTabIdToStorage, integrationTabs } =
- selectedTabResult;
+ useSelectedTab();
+ const createAutoImportCard = useCreateAutoImportCard();
+
+ const integrationTabOptions = useMemo(
+ () =>
+ integrationTabs.map((tab) => ({
+ id: tab.id,
+ label: tab.label,
+ iconType: tab.iconType,
+ 'data-test-subj': `securitySolutionIntegrationsTab-${tab.id}`,
+ })),
+ [integrationTabs]
+ );
+
+ const list = useMemo(() => {
+ if (!selectedTab.appendAutoImportCard) {
+ return integrationList;
+ }
+ return [...integrationList, createAutoImportCard()];
+ }, [integrationList, createAutoImportCard, selectedTab.appendAutoImportCard]);
+
const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId);
const onTabChange = useCallback(
(stringId: string) => {
@@ -84,7 +96,7 @@ export const IntegrationsCardGridTabsComponent = React.memo
- {integrationTabs.length > 1 && (
+ {integrationTabOptions.length > 1 && (
@@ -184,7 +196,7 @@ export const IntegrationsCardGridTabsComponent = React.memo;
+
+export interface WithAvailablePackagesProps {
+ prereleaseIntegrationsEnabled?: boolean;
+}
+
+/**
+ * HOC to wrap a component with the `availablePackages` from Fleet.
+ */
+export const withAvailablePackages = (
+ Component: React.ComponentType
+): React.FC & WithAvailablePackagesProps> => {
+ return withLazyHook(
+ React.memo(
+ function WithAvailablePackages({
+ useAvailablePackages,
+ prereleaseIntegrationsEnabled = false,
+ ...props
+ }) {
+ const availablePackages = useAvailablePackages({
+ prereleaseIntegrationsEnabled,
+ });
+ return ;
+ }
+ ),
+ () => import('@kbn/fleet-plugin/public').then((module) => module.AvailablePackagesHook()),
+
+ );
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/with_filtered_integrations.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/with_filtered_integrations.tsx
deleted file mode 100644
index a5303823d102d..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/components/with_filtered_integrations.tsx
+++ /dev/null
@@ -1,21 +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 React from 'react';
-import { EuiSkeletonText } from '@elastic/eui';
-import { withLazyHook } from '../../../components/with_lazy_hook';
-import { LOADING_SKELETON_TEXT_LINES } from '../constants';
-import { AvailableIntegrationsComponent } from './available_integrations';
-
-export const WithFilteredIntegrations = withLazyHook(
- AvailableIntegrationsComponent,
- () => import('@kbn/fleet-plugin/public').then((module) => module.AvailablePackagesHook()),
-
-);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/integration_card_grid_tabs.styles.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/integration_card_grid_tabs.styles.ts
index 82eba378f8405..456acacea630c 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/integration_card_grid_tabs.styles.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/integration_card_grid_tabs.styles.ts
@@ -6,10 +6,14 @@
*/
import { css } from '@emotion/react';
-import { useEuiTheme } from '@elastic/eui';
+import { COLOR_MODES_STANDARD, useEuiTheme } from '@elastic/eui';
export const useIntegrationCardGridTabsStyles = () => {
- const { euiTheme } = useEuiTheme();
+ const { euiTheme, colorMode } = useEuiTheme();
+ if (colorMode !== COLOR_MODES_STANDARD.dark) {
+ return undefined;
+ }
+ // only apply styles in dark mode
return css`
button {
position: relative;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_create_auto_import_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_create_auto_import_card.tsx
new file mode 100644
index 0000000000000..ea85c2891231d
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_create_auto_import_card.tsx
@@ -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 React, { useCallback } from 'react';
+import { useNavigation } from '@kbn/security-solution-navigation';
+import AssistantIconSVG from '@kbn/ai-assistant-icon/svg/assistant.svg';
+import { i18n } from '@kbn/i18n';
+import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
+import { EuiBadge } from '@elastic/eui';
+import { useIntegrationContext } from './integration_context';
+import {
+ CARD_DESCRIPTION_LINE_CLAMP,
+ CARD_TITLE_LINE_CLAMP,
+ MAX_CARD_HEIGHT_IN_PX,
+ TELEMETRY_INTEGRATION_CARD,
+} from '../constants';
+
+const TITLE = i18n.translate('xpack.securitySolution.integrations.createAutoImportCard.title', {
+ defaultMessage: 'Custom integration',
+});
+const DESCRIPTION = i18n.translate(
+ 'xpack.securitySolution.integrations.createAutoImportCard.description',
+ {
+ defaultMessage:
+ 'AI-driven process to build the integration step-by-step, or upload a pre-made .zip package integration.',
+ }
+);
+const BADGE = i18n.translate('xpack.securitySolution.integrations.createAutoImportCard.badge', {
+ defaultMessage: 'New',
+});
+
+const navigation = { appId: 'integrations', path: 'create' };
+const ID = 'placeholder:auto_import';
+
+export const useCreateAutoImportCard = () => {
+ const { getAppUrl, navigateTo } = useNavigation();
+ const { reportLinkClick } = useIntegrationContext().telemetry;
+
+ return useCallback((): IntegrationCardItem => {
+ return {
+ id: ID,
+ title: TITLE,
+ name: TITLE,
+ titleBadge: {BADGE},
+ titleLineClamp: CARD_TITLE_LINE_CLAMP,
+ descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP,
+ maxCardHeight: MAX_CARD_HEIGHT_IN_PX,
+ description: DESCRIPTION,
+ icons: [{ src: AssistantIconSVG, type: 'svg' }],
+ url: getAppUrl(navigation),
+ onCardClick: () => {
+ reportLinkClick?.(`${TELEMETRY_INTEGRATION_CARD}_${ID}`);
+ navigateTo(navigation);
+ },
+ categories: [],
+ integration: '',
+ version: '0.0.0',
+ };
+ }, [getAppUrl, navigateTo, reportLinkClick]);
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_filter_cards.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_filter_cards.tsx
deleted file mode 100644
index a48560b65fac8..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_filter_cards.tsx
+++ /dev/null
@@ -1,54 +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 type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public';
-import { useMemo } from 'react';
-
-export const useFilterCards = ({
- useAvailablePackages,
- featuredCardIds,
- prereleaseIntegrationsEnabled,
-}: {
- useAvailablePackages: AvailablePackagesHookType;
- featuredCardIds?: string[];
- prereleaseIntegrationsEnabled: boolean;
-}) => {
- const {
- isLoading,
- searchTerm,
- setCategory,
- setSearchTerm,
- setSelectedSubCategory,
- filteredCards,
- } = useAvailablePackages({
- prereleaseIntegrationsEnabled,
- });
-
- return useMemo(
- () => ({
- availablePackagesResult: {
- isLoading,
- searchTerm,
- setCategory,
- setSearchTerm,
- setSelectedSubCategory,
- },
- allowedIntegrations: filteredCards.filter(
- (card) => featuredCardIds?.includes(card.id) ?? true
- ),
- }),
- [
- featuredCardIds,
- filteredCards,
- isLoading,
- searchTerm,
- setCategory,
- setSearchTerm,
- setSelectedSubCategory,
- ]
- );
-};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.test.ts
index e906eb226d578..0d6ec7ff3cdfb 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.test.ts
@@ -20,6 +20,13 @@ jest.mock('../../kibana', () => ({
}),
}));
+const mockUseSelectedTab = jest.fn().mockReturnValue({
+ selectedTab: { id: 'test', featuredCardIds: [] },
+});
+jest.mock('./use_selected_tab', () => ({
+ useSelectedTab: () => mockUseSelectedTab(),
+}));
+
describe('useIntegrationCardList', () => {
const mockIntegrationsList = [
{
@@ -36,7 +43,7 @@ describe('useIntegrationCardList', () => {
descriptionLineClamp: 3,
showInstallationStatus: true,
title: 'Security Integration',
- url: '/app/integrations/security',
+ url: '/app/integrations/security?returnAppId=securitySolutionUI&returnPath=%2Fget_started',
version: '1.0.0',
},
{
@@ -53,7 +60,7 @@ describe('useIntegrationCardList', () => {
descriptionLineClamp: 3,
showInstallationStatus: true,
title: 'Security Integration',
- url: '/app/integrations/security',
+ url: '/app/integrations/security?returnAppId=securitySolutionUI&returnPath=%2Fget_started',
version: '1.0.0',
},
];
@@ -95,13 +102,14 @@ describe('useIntegrationCardList', () => {
});
it('returns featured cards when featuredCardIds are provided', () => {
- const featuredCardIds = ['epr:endpoint'];
+ mockUseSelectedTab.mockReturnValue({
+ selectedTab: { id: 'test', featuredCardIds: ['epr:endpoint'] },
+ });
const { result } = renderHook(() =>
useIntegrationCardList({
integrationsList: mockIntegrationsList,
activeIntegrations: mockActiveIntegrations,
- featuredCardIds,
})
);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.ts
index f3495204b0992..1ceb69d465c3a 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_integration_card_list.ts
@@ -4,159 +4,88 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import { useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
-import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation';
import type { GetInstalledPackagesResponse } from '@kbn/fleet-plugin/common/types';
import { useNavigation } from '../../kibana';
-import { APP_INTEGRATIONS_PATH, APP_UI_ID, ONBOARDING_PATH } from '../../../../../common/constants';
+import { APP_INTEGRATIONS_PATH, ONBOARDING_PATH } from '../../../../../common/constants';
import {
CARD_DESCRIPTION_LINE_CLAMP,
CARD_TITLE_LINE_CLAMP,
INTEGRATION_APP_ID,
MAX_CARD_HEIGHT_IN_PX,
- RETURN_APP_ID,
- RETURN_PATH,
TELEMETRY_INTEGRATION_CARD,
} from '../constants';
-import type { GetAppUrl, NavigateTo } from '../../kibana';
-import type { ReportLinkClick } from './integration_context';
import { useIntegrationContext } from './integration_context';
+import { getIntegrationLinkState } from '../../../hooks/integrations/use_integration_link_state';
+import { addPathParamToUrl } from '../../../utils/integrations';
+import { useSelectedTab } from './use_selected_tab';
-const addPathParamToUrl = (url: string, onboardingLink: string) => {
- const encoded = encodeURIComponent(onboardingLink);
- const paramsString = `${RETURN_PATH}=${encoded}&${RETURN_APP_ID}=${APP_UI_ID}`;
+export type GetCardItemExtraProps = (card: IntegrationCardItem) => Partial;
- if (url.indexOf('?') >= 0) {
- return `${url}&${paramsString}`;
- }
- return `${url}?${paramsString}`;
-};
+const useAddSecurityProps = (activeIntegrations: GetInstalledPackagesResponse['items']) => {
+ const { navigateTo, getAppUrl } = useNavigation();
+ const { telemetry } = useIntegrationContext();
-const extractFeaturedCards = (filteredCards: IntegrationCardItem[], featuredCardIds: string[]) => {
- return filteredCards.reduce((acc, card) => {
- if (featuredCardIds.includes(card.id)) {
- acc.push(card);
- }
- return acc;
- }, []);
-};
+ return useCallback(
+ (card: IntegrationCardItem): IntegrationCardItem => {
+ const integrationRootUrl = getAppUrl({ appId: INTEGRATION_APP_ID });
+ const state = getIntegrationLinkState(ONBOARDING_PATH, getAppUrl);
+ const url = card.url.includes(APP_INTEGRATIONS_PATH)
+ ? addPathParamToUrl(card.url, ONBOARDING_PATH)
+ : card.url;
+ const isActive = activeIntegrations.some((integration) => integration.name === card.name);
-const getFilteredCards = ({
- activeIntegrations,
- featuredCardIds,
- getAppUrl,
- integrationsList,
- navigateTo,
- reportLinkClick,
-}: {
- activeIntegrations: GetInstalledPackagesResponse['items'];
- featuredCardIds?: string[];
- getAppUrl: GetAppUrl;
- integrationsList: IntegrationCardItem[];
- navigateTo: NavigateTo;
- reportLinkClick?: ReportLinkClick;
-}) => {
- const securityIntegrationsList = integrationsList.map((card) =>
- addSecuritySpecificProps({
- activeIntegrations,
- navigateTo,
- getAppUrl,
- card,
- reportLinkClick,
- })
+ return {
+ ...card,
+ titleLineClamp: CARD_TITLE_LINE_CLAMP,
+ descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP,
+ maxCardHeight: MAX_CARD_HEIGHT_IN_PX,
+ showInstallationStatus: true,
+ url,
+ hasDataStreams: isActive,
+ onCardClick: () => {
+ const trackId = `${TELEMETRY_INTEGRATION_CARD}_${card.id}`;
+ telemetry.reportLinkClick?.(trackId);
+ if (url.startsWith(APP_INTEGRATIONS_PATH)) {
+ navigateTo({
+ appId: INTEGRATION_APP_ID,
+ path: url.slice(integrationRootUrl.length),
+ state,
+ });
+ } else if (url.startsWith('http') || url.startsWith('https')) {
+ window.open(url, '_blank');
+ } else {
+ navigateTo({ url, state });
+ }
+ },
+ };
+ },
+ [activeIntegrations, navigateTo, getAppUrl, telemetry]
);
- if (!featuredCardIds) {
- return { featuredCards: [], integrationCards: securityIntegrationsList };
- }
- const featuredCards = extractFeaturedCards(securityIntegrationsList, featuredCardIds);
- return {
- featuredCards,
- integrationCards: securityIntegrationsList,
- };
};
-export const addSecuritySpecificProps = ({
- activeIntegrations,
- navigateTo,
- getAppUrl,
- card,
- reportLinkClick,
-}: {
+interface UseIntegrationCardListProps {
+ integrationsList: IntegrationCardItem[];
activeIntegrations: GetInstalledPackagesResponse['items'];
- navigateTo: NavigateTo;
- getAppUrl: GetAppUrl;
- card: IntegrationCardItem;
- reportLinkClick?: ReportLinkClick;
-}): IntegrationCardItem => {
- const onboardingLink = getAppUrl({ appId: SECURITY_UI_APP_ID, path: ONBOARDING_PATH });
- const integrationRootUrl = getAppUrl({ appId: INTEGRATION_APP_ID });
- const state = {
- onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }],
- onCancelUrl: onboardingLink,
- onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }],
- };
- const url =
- card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink
- ? addPathParamToUrl(card.url, ONBOARDING_PATH)
- : card.url;
- const isActive = activeIntegrations.some((integration) => integration.name === card.name);
- return {
- ...card,
- titleLineClamp: CARD_TITLE_LINE_CLAMP,
- descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP,
- maxCardHeight: MAX_CARD_HEIGHT_IN_PX,
- showInstallationStatus: true,
- url,
- hasDataStreams: isActive,
- onCardClick: () => {
- const trackId = `${TELEMETRY_INTEGRATION_CARD}_${card.id}`;
- reportLinkClick?.(trackId);
- if (url.startsWith(APP_INTEGRATIONS_PATH)) {
- navigateTo({
- appId: INTEGRATION_APP_ID,
- path: url.slice(integrationRootUrl.length),
- state,
- });
- } else if (url.startsWith('http') || url.startsWith('https')) {
- window.open(url, '_blank');
- } else {
- navigateTo({ url, state });
- }
- },
- };
-};
-
+}
export const useIntegrationCardList = ({
integrationsList,
activeIntegrations,
- featuredCardIds,
-}: {
- integrationsList: IntegrationCardItem[];
- activeIntegrations: GetInstalledPackagesResponse['items'];
- featuredCardIds?: string[] | undefined;
-}): IntegrationCardItem[] => {
- const { navigateTo, getAppUrl } = useNavigation();
+}: UseIntegrationCardListProps): IntegrationCardItem[] => {
+ const { selectedTab } = useSelectedTab();
+ const featuredCardIds = selectedTab?.featuredCardIds;
+
+ const addSecurityProps = useAddSecurityProps(activeIntegrations);
- const {
- telemetry: { reportLinkClick },
- } = useIntegrationContext();
- const { featuredCards, integrationCards } = useMemo(
- () =>
- getFilteredCards({
- activeIntegrations,
- navigateTo,
- getAppUrl,
- integrationsList,
- featuredCardIds,
- reportLinkClick,
- }),
- [activeIntegrations, navigateTo, getAppUrl, integrationsList, featuredCardIds, reportLinkClick]
+ const integrationCards = useMemo(
+ () => integrationsList.map((card) => addSecurityProps(card)),
+ [integrationsList, addSecurityProps]
);
if (featuredCardIds && featuredCardIds.length > 0) {
- return featuredCards;
+ return integrationCards.filter((card) => featuredCardIds.includes(card.id));
}
return integrationCards ?? [];
};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_selected_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_selected_tab.tsx
index 32420203a4f96..addeb15074449 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_selected_tab.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/hooks/use_selected_tab.tsx
@@ -8,16 +8,12 @@
import { useMemo } from 'react';
import { useStoredIntegrationTabId } from './use_stored_state';
import type { Tab } from '../types';
+import { useIntegrationContext } from './integration_context';
export type UseSelectedTabReturn = ReturnType;
-export const useSelectedTab = ({
- spaceId,
- integrationTabs,
-}: {
- spaceId: string;
- integrationTabs: Tab[];
-}) => {
+export const useSelectedTab = () => {
+ const { spaceId, integrationTabs } = useIntegrationContext();
const [toggleIdSelected, setSelectedTabIdToStorage] = useStoredIntegrationTabId(
spaceId,
integrationTabs[0].id
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/types/index.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/types/index.ts
index 9506812519bb6..ef37509a431d8 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/types/index.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/integrations/types/index.ts
@@ -4,9 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public';
+import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
import type { GetInstalledPackagesResponse } from '@kbn/fleet-plugin/common/types';
-import type { UseSelectedTabReturn } from '../hooks/use_selected_tab';
export interface IntegrationCardMetadata {
isAgentRequired: boolean;
@@ -22,6 +21,7 @@ export interface Tab {
overflow?: 'hidden' | 'scroll';
showSearchTools?: boolean;
subCategory?: string;
+ appendAutoImportCard?: boolean;
sortByFeaturedIntegrations: boolean;
height?: string;
}
@@ -42,17 +42,3 @@ export type TopCalloutRenderer = React.FC<{
isAgentRequired?: boolean;
selectedTabId: IntegrationTabId;
}>;
-
-export type AvailablePackagesResult = Pick<
- ReturnType,
- 'isLoading' | 'searchTerm' | 'setCategory' | 'setSearchTerm' | 'setSelectedSubCategory'
->;
-
-export type RenderChildrenType = React.FC<{
- allowedIntegrations: IntegrationCardItem[];
- availablePackagesResult: AvailablePackagesResult;
- checkCompleteMetadata?: IntegrationCardMetadata;
- featuredCardIds?: string[];
- selectedTabResult: UseSelectedTabReturn;
- topCalloutRenderer?: TopCalloutRenderer;
-}>;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/integrations/index.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/integrations/index.test.ts
new file mode 100644
index 0000000000000..f9693f59680a3
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/integrations/index.test.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 { addPathParamToUrl, RETURN_APP_ID, RETURN_PATH } from '.';
+import { APP_UI_ID } from '../../../../common';
+
+describe('addPathParamToUrl', () => {
+ const encodedOnboardingLink = encodeURIComponent('/onboarding');
+ it('should append query parameters to a URL without existing query parameters', () => {
+ const url = 'https://example.com';
+ const onboardingLink = '/onboarding';
+ const result = addPathParamToUrl(url, onboardingLink);
+
+ expect(result).toBe(
+ `https://example.com?${RETURN_APP_ID}=${APP_UI_ID}&${RETURN_PATH}=${encodedOnboardingLink}`
+ );
+ });
+
+ it('should append query parameters to a URL with existing query parameters', () => {
+ const url = 'https://example.com?foo=bar';
+ const onboardingLink = '/onboarding';
+ const result = addPathParamToUrl(url, onboardingLink);
+
+ expect(result).toBe(
+ `https://example.com?foo=bar&${RETURN_APP_ID}=${APP_UI_ID}&${RETURN_PATH}=${encodedOnboardingLink}`
+ );
+ });
+
+ it('should encode the onboarding link correctly', () => {
+ const url = 'https://example.com';
+ const onboardingLink = '/onboarding?step=1&next=2';
+ const customEncodedOnboardingLink = encodeURIComponent(onboardingLink);
+ const result = addPathParamToUrl(url, onboardingLink);
+
+ expect(result).toBe(
+ `https://example.com?${RETURN_APP_ID}=${APP_UI_ID}&${RETURN_PATH}=${customEncodedOnboardingLink}`
+ );
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/integrations/index.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/integrations/index.ts
new file mode 100644
index 0000000000000..30f3f49d0aec6
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/integrations/index.ts
@@ -0,0 +1,28 @@
+/*
+ * 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 { stringifyUrl } from 'query-string';
+import { APP_UI_ID } from '../../../../common';
+
+export const RETURN_APP_ID = 'returnAppId';
+export const RETURN_PATH = 'returnPath';
+
+export const addPathParamToUrl = (url: string, onboardingLink: string): string => {
+ return stringifyUrl(
+ {
+ url,
+ query: {
+ [RETURN_APP_ID]: APP_UI_ID,
+ [RETURN_PATH]: onboardingLink,
+ },
+ },
+ {
+ encode: true,
+ sort: false,
+ }
+ );
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts
index b3af6a753b324..224fb5146f9f6 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts
@@ -14,6 +14,7 @@ import { alertsCardConfig } from './cards/alerts';
import { assistantCardConfig } from './cards/assistant';
import { aiConnectorCardConfig } from './cards/siem_migrations/ai_connector';
import { startMigrationCardConfig } from './cards/siem_migrations/start_migration';
+import { siemMigrationIntegrationsCardConfig } from './cards/siem_migrations/integrations';
import { integrationsExternalDetectionsCardConfig } from './cards/integrations_external_detections';
import { knowledgeSourceCardConfig } from './cards/knowledge_source';
@@ -68,6 +69,6 @@ export const siemMigrationsBodyConfig: OnboardingGroupConfig[] = [
title: i18n.translate('xpack.securitySolution.onboarding.migrate.title', {
defaultMessage: 'Migrate rules & add data',
}),
- cards: [startMigrationCardConfig],
+ cards: [startMigrationCardConfig, siemMigrationIntegrationsCardConfig],
},
];
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/integration_card_top_callout.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/integration_card_top_callout.tsx
index 7cf2826e3c609..a58c8507cd4ff 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/integration_card_top_callout.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/integration_card_top_callout.tsx
@@ -14,31 +14,21 @@ import { ActiveIntegrationsCallout } from './active_integrations_callout';
import { EndpointCallout } from './endpoint_callout';
import { IntegrationTabId } from '../../../../../../../common/lib/integrations/types';
-export const useShowActiveCallout = ({
- activeIntegrationsCount,
- isAgentRequired,
-}: {
- activeIntegrationsCount: number;
- isAgentRequired?: boolean;
-}) => {
- return activeIntegrationsCount > 0 || isAgentRequired;
-};
-
-export const IntegrationCardTopCalloutComponent: React.FC<{
+export const IntegrationCardTopCallout = React.memo<{
activeIntegrationsCount: number;
isAgentRequired?: boolean;
selectedTabId: IntegrationTabId;
-}> = ({ activeIntegrationsCount, isAgentRequired, selectedTabId }) => {
+}>(({ activeIntegrationsCount, isAgentRequired, selectedTabId }) => {
const { isAgentlessAvailable$ } = useOnboardingService();
const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined);
- const showActiveCallout = useShowActiveCallout({
- activeIntegrationsCount,
- isAgentRequired,
- });
+
+ const showActiveCallout = activeIntegrationsCount > 0 || isAgentRequired;
+
const showAgentlessCallout =
isAgentlessAvailable &&
activeIntegrationsCount === 0 &&
selectedTabId !== IntegrationTabId.endpoint;
+
const showEndpointCallout =
activeIntegrationsCount === 0 && selectedTabId === IntegrationTabId.endpoint;
@@ -58,8 +48,5 @@ export const IntegrationCardTopCalloutComponent: React.FC<{
)}
>
);
-};
-
-export const IntegrationCardTopCallout = React.memo(IntegrationCardTopCalloutComponent);
-
+});
IntegrationCardTopCallout.displayName = 'IntegrationCardTopCallout';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.test.tsx
index 0f017a9f3b077..121ac052a0f63 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.test.tsx
@@ -16,10 +16,6 @@ jest.mock('../../../../../../../common/hooks/use_add_integrations_url', () => ({
}),
}));
-jest.mock('../../card_callout', () => ({
- CardCallOut: ({ text }: { text: React.ReactNode }) => {text}
,
-}));
-
describe('ManageIntegrationsCallout', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -36,9 +32,7 @@ describe('ManageIntegrationsCallout', () => {
test('renders callout with correct message and link when there are active integrations', () => {
const { getByText, getByTestId } = render(
,
- {
- wrapper: TestProviders,
- }
+ { wrapper: TestProviders }
);
expect(getByText('5 integrations have been added')).toBeInTheDocument();
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.tsx
index 60960a28ffff2..4035dc6b9377b 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/integrations/callouts/manage_integrations_callout.tsx
@@ -40,35 +40,24 @@ export const ManageIntegrationsCallout = React.memo(
text={
- ),
- link: (
-
-
-
- ),
- icon: ,
- }}
+ id="xpack.securitySolution.onboarding.integrationsCard.callout.completeText"
+ defaultMessage="{count} {count, plural, one {integration has} other {integrations have}} been added"
+ values={{ count: activeIntegrationsCount }}
/>
}
+ action={
+
+
+
+
+ }
/>
);
}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx
index 418de211e9557..713954314782f 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx
@@ -10,7 +10,8 @@ import IntegrationsCard from './integrations_card';
import { render } from '@testing-library/react';
jest.mock('../../../onboarding_context');
-jest.mock('../../../../../common/lib/integrations/components');
+jest.mock('../../../../../common/lib/integrations/components/security_integrations');
+
const props = {
setComplete: jest.fn(),
checkComplete: jest.fn(),
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts
index 9f37576479031..d449158a376d3 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { DEFAULT_CHECK_COMPLETE_METADATA } from '../../../../../common/lib/integrations/components/integration_card_grid_tabs';
+import { DEFAULT_CHECK_COMPLETE_METADATA } from '../../../../../common/lib/integrations/components/security_integrations';
import type { IntegrationCardMetadata } from '../../../../../common/lib/integrations/types';
import type { StartServices } from '../../../../../types';
import type { OnboardingCardCheckComplete } from '../../../../types';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.test.tsx
index 93811eb21817d..7d2c32d536001 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.test.tsx
@@ -9,7 +9,10 @@ import React from 'react';
import IntegrationsCard from './integrations_card';
import { render } from '@testing-library/react';
jest.mock('../../../onboarding_context');
-jest.mock('../../../../../common/lib/integrations/components/with_filtered_integrations');
+
+jest.mock('../../../../../common/lib/integrations/components/security_integrations_grid_tabs');
+jest.mock('../../../../../common/lib/integrations/components/with_available_packages');
+
const props = {
setComplete: jest.fn(),
checkComplete: jest.fn(),
@@ -47,6 +50,6 @@ describe('IntegrationsCard', () => {
/>
);
expect(queryByTestId('loadingInstalledIntegrations')).not.toBeInTheDocument();
- expect(queryByTestId('withFilteredIntegrations')).toBeInTheDocument();
+ expect(queryByTestId('withAvailablePackages')).toBeInTheDocument();
});
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.tsx
index bfe21865dc565..41c18916df8ce 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations_external_detections/integrations_card.tsx
@@ -15,43 +15,64 @@ import { useOnboardingContext } from '../../../onboarding_context';
import { useEnhancedIntegrationCards } from '../../../../../common/lib/search_ai_lake/hooks';
import type {
IntegrationCardMetadata,
- RenderChildrenType,
+ TopCalloutRenderer,
} from '../../../../../common/lib/integrations/types';
-import { WithFilteredIntegrations } from '../../../../../common/lib/integrations/components/with_filtered_integrations';
-import { IntegrationsCardGridTabsComponent } from '../../../../../common/lib/integrations/components/integration_card_grid_tabs_component';
-import { DEFAULT_CHECK_COMPLETE_METADATA } from '../../../../../common/lib/integrations/components/integration_card_grid_tabs';
+import {
+ withAvailablePackages,
+ type AvailablePackages,
+} from '../../../../../common/lib/integrations/components/with_available_packages';
+import { SecurityIntegrationsGridTabs } from '../../../../../common/lib/integrations/components/security_integrations_grid_tabs';
+import { DEFAULT_CHECK_COMPLETE_METADATA } from '../../../../../common/lib/integrations/components/security_integrations';
import { IntegrationContextProvider } from '../../../../../common/lib/integrations/hooks/integration_context';
import { ONBOARDING_PATH } from '../../../../../../common/constants';
import type { ExternalIntegrationCardMetadata } from './integrations_check_complete';
+import { useSelectedTab } from '../../../../../common/lib/integrations/hooks/use_selected_tab';
-const IntegrationsCardGridTabs: RenderChildrenType = ({
- allowedIntegrations,
- availablePackagesResult,
- checkCompleteMetadata = DEFAULT_CHECK_COMPLETE_METADATA,
- selectedTabResult,
-}) => {
- const { activeIntegrations, isAgentRequired } = checkCompleteMetadata;
+interface IntegrationsCardGridTabsProps {
+ availablePackages: AvailablePackages;
+ checkCompleteMetadata?: IntegrationCardMetadata;
+ featuredCardIds?: string[];
+ topCalloutRenderer?: TopCalloutRenderer;
+}
- const { available: list } = useEnhancedIntegrationCards(allowedIntegrations, activeIntegrations, {
- showInstallationStatus: true,
- showCompressedInstallationStatus: true,
- returnPath: ONBOARDING_PATH,
- });
- const activeIntegrationsCount = activeIntegrations.length;
- return (
-
- );
-};
+const IntegrationsCardGridTabs = withAvailablePackages(
+ ({ availablePackages, checkCompleteMetadata = DEFAULT_CHECK_COMPLETE_METADATA }) => {
+ const { activeIntegrations, isAgentRequired } = checkCompleteMetadata;
+ const { selectedTab } = useSelectedTab();
+
+ const allowedIntegrations = useMemo(
+ () =>
+ availablePackages.filteredCards.filter(
+ (card) => selectedTab?.featuredCardIds?.includes(card.id) ?? true
+ ),
+ [availablePackages.filteredCards, selectedTab]
+ );
+
+ const { available: list } = useEnhancedIntegrationCards(
+ allowedIntegrations,
+ activeIntegrations,
+ {
+ showInstallationStatus: true,
+ showCompressedInstallationStatus: true,
+ returnPath: ONBOARDING_PATH,
+ }
+ );
+ const activeIntegrationsCount = activeIntegrations.length;
+
+ return (
+
+ );
+ }
+);
export const IntegrationsCard: OnboardingCardComponent =
React.memo(({ checkCompleteMetadata }) => {
@@ -80,9 +101,8 @@ export const IntegrationsCard: OnboardingCardComponent
-
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/index.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/index.ts
new file mode 100644
index 0000000000000..281e862c94225
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/index.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 type { IntegrationCardMetadata } from '../../../../../../common/lib/integrations/types';
+import type { OnboardingCardConfig } from '../../../../../types';
+import { OnboardingCardId } from '../../../../../constants';
+import { START_MIGRATION_INTEGRATIONS_CARD_TITLE } from './translations';
+import integrationsIcon from '../../common/integrations/images/integrations_icon.png';
+import integrationsDarkIcon from '../../common/integrations/images/integrations_icon_dark.png';
+import { checkIntegrationsCardComplete } from './integrations_check_complete';
+
+export const siemMigrationIntegrationsCardConfig: OnboardingCardConfig = {
+ id: OnboardingCardId.siemMigrationIntegrations,
+ title: START_MIGRATION_INTEGRATIONS_CARD_TITLE,
+ icon: integrationsIcon,
+ iconDark: integrationsDarkIcon,
+ Component: React.lazy(
+ () =>
+ import(
+ /* webpackChunkName: "onboarding_siem_migrations_integrations_card" */
+ './integrations_card'
+ )
+ ),
+ checkComplete: checkIntegrationsCardComplete,
+ capabilitiesRequired: 'fleet.read',
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_card.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_card.test.tsx
new file mode 100644
index 0000000000000..d8e8185f4471b
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_card.test.tsx
@@ -0,0 +1,78 @@
+/*
+ * 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 IntegrationsCard from './integrations_card';
+import { render } from '@testing-library/react';
+import { TestProviders } from '../../../../../../common/mock';
+import {
+ mockAvailablePackages,
+ getDefaultAvailablePackages,
+} from '../../../../../../common/lib/integrations/components/__mocks__/with_available_packages';
+
+jest.mock('../../../../onboarding_context');
+jest.mock('../../../../../../common/lib/integrations/components/security_integrations_grid_tabs');
+jest.mock('../../../../../../common/lib/integrations/components/with_available_packages');
+
+const props = {
+ setComplete: jest.fn(),
+ checkComplete: jest.fn(),
+ setExpandedCardId: jest.fn(),
+ isCardAvailable: jest.fn(),
+ isCardComplete: jest.fn(),
+};
+
+const mockUseGetIntegrationsStats = jest.fn((_: Function) => ({
+ getIntegrationsStats: jest.fn(),
+ isLoading: false,
+}));
+jest.mock(
+ '../../../../../../siem_migrations/rules/service/hooks/use_get_integrations_stats',
+ () => ({ useGetIntegrationsStats: (params: Function) => mockUseGetIntegrationsStats(params) })
+);
+
+describe('IntegrationsCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseGetIntegrationsStats.mockImplementation((_: Function) => ({
+ getIntegrationsStats: jest.fn(),
+ isLoading: false,
+ }));
+ mockAvailablePackages.mockReturnValue(getDefaultAvailablePackages());
+ });
+
+ it('renders a loading spinner when checkCompleteMetadata is undefined', () => {
+ const { getByTestId } = render(
+ ,
+ { wrapper: TestProviders }
+ );
+ expect(getByTestId('loadingInstalledIntegrations')).toBeInTheDocument();
+ });
+
+ it('renders the content', () => {
+ const { queryByTestId } = render(
+ ,
+ { wrapper: TestProviders }
+ );
+ expect(queryByTestId('loadingInstalledIntegrations')).not.toBeInTheDocument();
+ expect(queryByTestId('securityIntegrationsGridTabs')).toBeInTheDocument();
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_card.tsx
new file mode 100644
index 0000000000000..9ca90779b133e
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_card.tsx
@@ -0,0 +1,199 @@
+/*
+ * 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, { useCallback, useEffect, useMemo, useState } from 'react';
+import { EuiBadge, EuiSpacer } from '@elastic/eui';
+
+import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
+import type { OnboardingCardComponent } from '../../../../../types';
+import type { RuleMigrationAllIntegrationsStats } from '../../../../../../../common/siem_migrations/model/rule_migration.gen';
+import { CenteredLoadingSpinner } from '../../../../../../common/components/centered_loading_spinner';
+import type {
+ IntegrationCardMetadata,
+ Tab,
+ TopCalloutRenderer,
+} from '../../../../../../common/lib/integrations/types';
+import { IntegrationContextProvider } from '../../../../../../common/lib/integrations/hooks/integration_context';
+import { INTEGRATION_TABS } from '../../../../../../common/lib/integrations/configs/integration_tabs_configs';
+import { SecurityIntegrationsGridTabs } from '../../../../../../common/lib/integrations/components/security_integrations_grid_tabs';
+import { useIntegrationCardList } from '../../../../../../common/lib/integrations/hooks/use_integration_card_list';
+import {
+ withAvailablePackages,
+ type AvailablePackages,
+} from '../../../../../../common/lib/integrations/components/with_available_packages';
+import { useOnboardingContext } from '../../../../onboarding_context';
+import { OnboardingCardContentPanel } from '../../common/card_content_panel';
+import { IntegrationCardTopCallout } from '../../common/integrations/callouts/integration_card_top_callout';
+import { useGetIntegrationsStats } from '../../../../../../siem_migrations/rules/service/hooks/use_get_integrations_stats';
+import { OnboardingCardId } from '../../../../../constants';
+import { MissingMigrationCallout } from './missing_migration_callout';
+import * as i18n from './translations';
+
+export const DEFAULT_CHECK_COMPLETE_METADATA: IntegrationCardMetadata = {
+ activeIntegrations: [],
+ isAgentRequired: false,
+};
+
+interface SecurityIntegrationsProps {
+ availablePackages: AvailablePackages;
+ integrationsStats: RuleMigrationAllIntegrationsStats;
+ checkCompleteMetadata?: IntegrationCardMetadata;
+ topCalloutRenderer?: TopCalloutRenderer;
+}
+
+export const SecurityMigrationIntegrations = withAvailablePackages(
+ ({
+ availablePackages,
+ checkCompleteMetadata = DEFAULT_CHECK_COMPLETE_METADATA,
+ integrationsStats,
+ topCalloutRenderer,
+ }) => {
+ const { isAgentRequired, activeIntegrations } = checkCompleteMetadata;
+ const activeIntegrationsCount = activeIntegrations?.length ?? 0;
+ const list = useIntegrationCardList({
+ integrationsList: availablePackages.filteredCards,
+ activeIntegrations,
+ });
+
+ // Create the integrations list using integrationStats which is already sorted by total rules
+ const integrationList = useMemo(() => {
+ if (!integrationsStats?.length) {
+ return list;
+ }
+ const indexedStats = Object.fromEntries(
+ integrationsStats.map((stats) => [stats.id, stats.total_rules])
+ );
+ // Process the list to include only the cards that have integrations stats and set the title badge
+ // Use indexedStats to keep O(n) complexity
+ const indexedCards = list.reduce>((acc, card) => {
+ const totalRules = indexedStats[card.id];
+ if (!totalRules) {
+ return acc;
+ }
+ const titleBadge = {i18n.TOTAL_RULES(totalRules)};
+ acc[card.id] = { ...card, titleBadge };
+ return acc;
+ }, {});
+
+ // Use the same order as the integrationsStats (descending by total rules from API)
+ return integrationsStats.reduce((acc, { id }) => {
+ const card = indexedCards[id];
+ if (card) {
+ acc.push(card);
+ }
+ return acc;
+ }, []);
+ }, [list, integrationsStats]);
+
+ return (
+
+ );
+ }
+);
+
+export const IntegrationsCard: OnboardingCardComponent = React.memo(
+ ({ checkCompleteMetadata, isCardComplete, setExpandedCardId }) => {
+ const { spaceId, telemetry } = useOnboardingContext();
+
+ const isMigrationsCardComplete = isCardComplete(OnboardingCardId.siemMigrationsRules);
+
+ const expandMigrationsCard = useCallback(() => {
+ setExpandedCardId(OnboardingCardId.siemMigrationsRules);
+ }, [setExpandedCardId]);
+
+ const [integrationsStats, setIntegrationsStats] = useState(
+ []
+ );
+ const processIntegrationsStats = useCallback((stats: RuleMigrationAllIntegrationsStats) => {
+ // Prefix IDs with 'epr:' to match the integration card IDs
+ setIntegrationsStats(stats.map((stat) => ({ ...stat, id: `epr:${stat.id}` })));
+ }, []);
+
+ const { getIntegrationsStats, isLoading } = useGetIntegrationsStats(processIntegrationsStats);
+
+ useEffect(() => {
+ // fetch integrations stats only if the migrations card is complete (al least one migration is complete),
+ if (isMigrationsCardComplete) {
+ getIntegrationsStats();
+ }
+ }, [getIntegrationsStats, isMigrationsCardComplete]);
+
+ // Replace the static "recommended" tab by the dynamic "detected" tab, based on the migrations integrations stats
+ const integrationTabs = useMemo((): Tab[] => {
+ const [recommendedTab, ...rest] = INTEGRATION_TABS;
+ if (!integrationsStats?.length) {
+ return [
+ { ...recommendedTab, appendAutoImportCard: true, overflow: 'scroll' as const },
+ ...rest,
+ ];
+ }
+ const featuredCardIds = integrationsStats.map(({ id }) => id);
+ return [
+ {
+ ...recommendedTab,
+ label: i18n.DETECTED_TAB_LABEL,
+ featuredCardIds,
+ appendAutoImportCard: true,
+ overflow: 'scroll' as const,
+ },
+ ...rest,
+ ];
+ }, [integrationsStats]);
+
+ // Wrap the top callout renderer to include the missing migration callout
+ const topCalloutRenderer = useCallback(
+ ({ activeIntegrationsCount, isAgentRequired, selectedTabId }) => {
+ return (
+ <>
+ {!isMigrationsCardComplete && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+ },
+ [isMigrationsCardComplete, expandMigrationsCard]
+ );
+
+ if (!checkCompleteMetadata || isLoading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ );
+ }
+);
+IntegrationsCard.displayName = 'IntegrationsCard';
+
+// eslint-disable-next-line import/no-default-export
+export default IntegrationsCard;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_check_complete.test.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_check_complete.test.ts
new file mode 100644
index 0000000000000..278c9ffa9bd6a
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_check_complete.test.ts
@@ -0,0 +1,188 @@
+/*
+ * 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 { installationStatuses } from '@kbn/fleet-plugin/public';
+import { lastValueFrom } from 'rxjs';
+import { checkIntegrationsCardComplete } from './integrations_check_complete';
+import type { StartServices } from '../../../../../../types';
+
+jest.mock('rxjs', () => ({
+ ...jest.requireActual('rxjs'),
+ lastValueFrom: jest.fn(),
+}));
+
+describe('checkIntegrationsCardComplete', () => {
+ const mockLastValueFrom = lastValueFrom as jest.Mock;
+ const mockHttpGet: jest.Mock = jest.fn();
+ const mockSearch: jest.Mock = jest.fn();
+ const mockService = {
+ http: {
+ get: mockHttpGet,
+ },
+ data: {
+ search: {
+ search: mockSearch,
+ },
+ },
+ notifications: {
+ toasts: {
+ addError: jest.fn(),
+ },
+ },
+ } as unknown as StartServices;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns isComplete as false when no packages are active', async () => {
+ mockHttpGet.mockResolvedValue({
+ items: [],
+ });
+
+ mockLastValueFrom.mockResolvedValue({
+ rawResponse: {
+ hits: { total: 0 },
+ },
+ });
+
+ const result = await checkIntegrationsCardComplete(mockService);
+
+ expect(result).toEqual({
+ isComplete: false,
+ metadata: {
+ isAgentRequired: true,
+ activeIntegrations: [],
+ },
+ });
+ });
+
+ it('returns isComplete as true when packages are active but no agent data is available', async () => {
+ const mockActiveIntegrations = [
+ {
+ status: installationStatuses.Installed,
+ dataStreams: [
+ {
+ name: 'test-data-stream',
+ title: 'Test Data Stream',
+ },
+ ],
+ },
+ ];
+ mockHttpGet.mockResolvedValue({
+ items: mockActiveIntegrations,
+ });
+
+ mockLastValueFrom.mockResolvedValue({
+ rawResponse: {
+ hits: { total: 0 },
+ },
+ });
+
+ const result = await checkIntegrationsCardComplete(mockService);
+
+ expect(result).toEqual({
+ isComplete: true,
+ completeBadgeText: '1 integration added',
+ metadata: {
+ isAgentRequired: false,
+ activeIntegrations: mockActiveIntegrations,
+ },
+ });
+ });
+
+ it('returns isComplete as true and isAgentRequired as false when both packages and agent data are available', async () => {
+ const mockActiveIntegrations = [
+ {
+ status: installationStatuses.Installed,
+ dataStreams: [
+ {
+ name: 'test-data-stream 1',
+ title: 'Test Data Stream 1',
+ },
+ ],
+ },
+ {
+ status: installationStatuses.InstallFailed,
+ dataStreams: [
+ {
+ name: 'test-data-stream 2',
+ title: 'Test Data Stream 2',
+ },
+ ],
+ },
+ ];
+
+ mockHttpGet.mockResolvedValue({
+ items: mockActiveIntegrations,
+ });
+
+ mockLastValueFrom.mockResolvedValue({
+ rawResponse: {
+ hits: { total: 1 },
+ },
+ });
+
+ const result = await checkIntegrationsCardComplete(mockService);
+
+ expect(result).toEqual({
+ isComplete: true,
+ completeBadgeText: '2 integrations added',
+ metadata: {
+ isAgentRequired: false,
+ activeIntegrations: mockActiveIntegrations,
+ },
+ });
+ });
+
+ it('renders an error toast when fetching integrations data fails', async () => {
+ const err = new Error('Failed to fetch integrations data');
+ mockHttpGet.mockRejectedValue(err);
+ mockLastValueFrom.mockResolvedValue({
+ rawResponse: {
+ hits: { total: 0 },
+ },
+ });
+
+ const res = await checkIntegrationsCardComplete(mockService);
+
+ expect(mockService.notifications.toasts.addError).toHaveBeenCalledWith(err, {
+ title: 'Error fetching integrations data',
+ });
+ expect(res).toEqual({
+ isComplete: false,
+ metadata: {
+ isAgentRequired: true,
+ activeIntegrations: [],
+ },
+ });
+ });
+
+ it('renders an error toast when fetching agents data fails', async () => {
+ mockHttpGet.mockResolvedValue({
+ items: [],
+ });
+
+ const err = new Error('Failed to fetch agents data');
+ mockLastValueFrom.mockRejectedValue(err);
+
+ const res = await checkIntegrationsCardComplete(mockService);
+
+ expect(mockService.notifications.toasts.addError).toHaveBeenCalledWith(
+ new Error('Failed to fetch agents data'),
+ {
+ title: 'Error fetching agents data',
+ }
+ );
+ expect(res).toEqual({
+ isComplete: false,
+ metadata: {
+ isAgentRequired: true,
+ activeIntegrations: [],
+ },
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_check_complete.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_check_complete.ts
new file mode 100644
index 0000000000000..50ea8023ae50c
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/integrations_check_complete.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { DEFAULT_CHECK_COMPLETE_METADATA } from '../../../../../../common/lib/integrations/components/security_integrations';
+import type { IntegrationCardMetadata } from '../../../../../../common/lib/integrations/types';
+import type { StartServices } from '../../../../../../types';
+import type { OnboardingCardCheckComplete } from '../../../../../types';
+import {
+ getAgentsData,
+ getCompleteBadgeText,
+ getActiveIntegrationList,
+} from '../../common/integrations/integrations_check_complete_helpers';
+
+export const checkIntegrationsCardComplete: OnboardingCardCheckComplete<
+ IntegrationCardMetadata
+> = async (services: StartServices) => {
+ const { isComplete, activePackages: activeIntegrations } = await getActiveIntegrationList(
+ services
+ );
+
+ const { isAgentRequired } = await getAgentsData(services, isComplete);
+
+ if (!isComplete) {
+ return {
+ isComplete,
+ metadata: { ...DEFAULT_CHECK_COMPLETE_METADATA, isAgentRequired },
+ };
+ }
+
+ return {
+ isComplete,
+ completeBadgeText: getCompleteBadgeText(activeIntegrations.length),
+ metadata: {
+ isAgentRequired,
+ activeIntegrations,
+ },
+ };
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/missing_migration_callout.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/missing_migration_callout.tsx
new file mode 100644
index 0000000000000..4288a754e62cb
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/missing_migration_callout.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { EuiPanel, EuiLink, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
+import { CardCallOut } from '../../common/card_callout';
+import * as i18n from './translations';
+
+interface MissingMigrationCalloutProps {
+ onExpandMigrationsCard: () => void;
+}
+
+export const MissingMigrationCallout = React.memo(
+ ({ onExpandMigrationsCard }) => (
+
+
+
+ {i18n.MIGRATION_MISSING_BUTTON}
+
+
+
+
+
+ }
+ />
+
+ )
+);
+MissingMigrationCallout.displayName = 'MissingMigrationCallout';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/translations.ts
new file mode 100644
index 0000000000000..dc23177d17817
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/integrations/translations.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const START_MIGRATION_INTEGRATIONS_CARD_TITLE = i18n.translate(
+ 'xpack.securitySolution.onboarding.migrationIntegrations.title',
+ { defaultMessage: 'Add SIEM data with Integrations' }
+);
+
+export const MIGRATION_MISSING_TEXT = i18n.translate(
+ 'xpack.securitySolution.onboarding.migrationIntegrations.missingMigration.title',
+ { defaultMessage: 'Complete a rule migration to get integration recommendations' }
+);
+
+export const MIGRATION_MISSING_BUTTON = i18n.translate(
+ 'xpack.securitySolution.onboarding.migrationIntegrations.missingMigration.button',
+ { defaultMessage: 'Start rule migration' }
+);
+
+export const DETECTED_TAB_LABEL = i18n.translate(
+ 'xpack.securitySolution.onboarding.migrationIntegrations.detectedTabLabel',
+ { defaultMessage: 'Detected' }
+);
+
+export const TOTAL_RULES = (count: number) =>
+ i18n.translate('xpack.securitySolution.onboarding.migrationIntegrations.totalRules', {
+ values: { count },
+ defaultMessage: '{count} rules',
+ });
diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/constants.ts
index 0e36fc63c0adc..368212c52cad3 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/constants.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/constants.ts
@@ -26,4 +26,5 @@ export enum OnboardingCardId {
// siem_migrations topic cards
siemMigrationsAiConnectors = 'ai_connectors',
siemMigrationsRules = 'migrate_rules',
+ siemMigrationIntegrations = 'migration_integrations',
}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts
index c224f1b42c1ee..5440baa69c713 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts
@@ -26,6 +26,7 @@ import {
SIEM_RULE_MIGRATIONS_INTEGRATIONS_PATH,
SIEM_RULE_MIGRATION_MISSING_PRIVILEGES_PATH,
SIEM_RULE_MIGRATION_RULES_PATH,
+ SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH,
} from '../../../../common/siem_migrations/constants';
import type {
CreateRuleMigrationResponse,
@@ -44,6 +45,7 @@ import type {
GetRuleMigrationPrivilegesResponse,
GetRuleMigrationRulesResponse,
CreateRuleMigrationRulesRequestBody,
+ GetRuleMigrationIntegrationsStatsResponse,
} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
export interface GetRuleMigrationStatsParams {
@@ -309,13 +311,27 @@ export interface GetIntegrationsParams {
/** Retrieves existing integrations. */
export const getIntegrations = async ({
signal,
-}: GetIntegrationsParams): Promise => {
+}: GetIntegrationsParams = {}): Promise => {
return KibanaServices.get().http.get(
SIEM_RULE_MIGRATIONS_INTEGRATIONS_PATH,
{ version: '1', signal }
);
};
+export interface GetIntegrationsStatsParams {
+ /** Optional AbortSignal for cancelling request */
+ signal?: AbortSignal;
+}
+/** Retrieves existing integrations. */
+export const getIntegrationsStats = async ({
+ signal,
+}: GetIntegrationsParams = {}): Promise => {
+ return KibanaServices.get().http.get(
+ SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH,
+ { version: '1', signal }
+ );
+};
+
export interface UpdateRulesParams {
/** `id` of the migration to install rules for */
migrationId: string;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts
index dfa6451ed9145..d382ce6411d0e 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts
@@ -39,7 +39,7 @@ export const useCreateMigration = (onSuccess: OnSuccess) => {
try {
dispatch({ type: 'start' });
const migrationId = await siemMigrations.rules.createRuleMigration(data);
- const stats = await siemMigrations.rules.getRuleMigrationStats(migrationId);
+ const stats = await siemMigrations.rules.api.getRuleMigrationStats({ migrationId });
notifications.toasts.addSuccess({
title: RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS_TITLE,
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations.ts
index 8ed94e78f31c4..aff08c0e4d83e 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations.ts
@@ -26,7 +26,7 @@ export const useGetIntegrations = (onSuccess: OnSuccess) => {
(async () => {
try {
dispatch({ type: 'start' });
- const integrations = await siemMigrations.rules.getIntegrations();
+ const integrations = await siemMigrations.rules.api.getIntegrations();
onSuccess(integrations);
dispatch({ type: 'success' });
@@ -36,7 +36,7 @@ export const useGetIntegrations = (onSuccess: OnSuccess) => {
dispatch({ type: 'error', error: apiError });
}
})();
- }, [siemMigrations.rules, notifications.toasts, onSuccess]);
+ }, [siemMigrations.rules.api, notifications.toasts, onSuccess]);
return { isLoading: state.loading, error: state.error, getIntegrations };
};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations_stats.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations_stats.ts
new file mode 100644
index 0000000000000..e9b2e45bd7c58
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_integrations_stats.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { useCallback, useReducer } from 'react';
+import { i18n } from '@kbn/i18n';
+import type { RuleMigrationAllIntegrationsStats } from '../../../../../common/siem_migrations/model/rule_migration.gen';
+import { useKibana } from '../../../../common/lib/kibana/kibana_react';
+import { reducer, initialState } from './common/api_request_reducer';
+
+export const GET_INTEGRATIONS_STATS_ERROR = i18n.translate(
+ 'xpack.securitySolution.siemMigrations.rules.service.getIntegrationsStatsError',
+ { defaultMessage: 'Failed to fetch integrations stats' }
+);
+
+export type OnSuccess = (integrationsStats: RuleMigrationAllIntegrationsStats) => void;
+
+export const useGetIntegrationsStats = (onSuccess: OnSuccess) => {
+ const { siemMigrations, notifications } = useKibana().services;
+ const [state, dispatch] = useReducer(reducer, initialState);
+
+ const getIntegrationsStats = useCallback(() => {
+ (async () => {
+ try {
+ dispatch({ type: 'start' });
+ const integrationsStats = await siemMigrations.rules.api.getIntegrationsStats();
+
+ onSuccess(integrationsStats);
+ dispatch({ type: 'success' });
+ } catch (err) {
+ const apiError = err.body ?? err;
+ notifications.toasts.addError(apiError, { title: GET_INTEGRATIONS_STATS_ERROR });
+ dispatch({ type: 'error', error: apiError });
+ }
+ })();
+ }, [siemMigrations.rules.api, notifications.toasts, onSuccess]);
+
+ return { isLoading: state.loading, error: state.error, getIntegrationsStats };
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_missing_resources.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_missing_resources.ts
index 5c4ec3925c5e3..aaf26405c3bc6 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_missing_resources.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_get_missing_resources.ts
@@ -28,7 +28,9 @@ export const useGetMissingResources = (onSuccess: OnSuccess) => {
(async () => {
try {
dispatch({ type: 'start' });
- const missingResources = await siemMigrations.rules.getMissingResources(migrationId);
+ const missingResources = await siemMigrations.rules.api.getMissingResources({
+ migrationId,
+ });
onSuccess(missingResources);
dispatch({ type: 'success' });
@@ -41,7 +43,7 @@ export const useGetMissingResources = (onSuccess: OnSuccess) => {
}
})();
},
- [siemMigrations.rules, notifications.toasts, onSuccess]
+ [siemMigrations.rules.api, notifications.toasts, onSuccess]
);
return { isLoading: state.loading, error: state.error, getMissingResources };
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.test.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.test.ts
index 608f02a507d31..56276bb381064 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.test.ts
@@ -19,10 +19,7 @@ import {
createRuleMigration,
upsertMigrationResources,
startRuleMigration as startRuleMigrationAPI,
- getRuleMigrationStats,
getRuleMigrationsStatsAll,
- getMissingResources,
- getIntegrations,
addRulesToMigration,
} from '../api';
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
@@ -244,17 +241,6 @@ describe('SiemRulesMigrationsService', () => {
});
});
- describe('getRuleMigrationStats', () => {
- it('should return migration stats', async () => {
- const stats = { id: 'mig-1', status: SiemMigrationTaskStatus.RUNNING };
- (getRuleMigrationStats as jest.Mock).mockResolvedValue(stats);
-
- const result = await service.getRuleMigrationStats('mig-1');
- expect(getRuleMigrationStats).toHaveBeenCalledWith({ migrationId: 'mig-1' });
- expect(result).toEqual(stats);
- });
- });
-
describe('getRuleMigrationsStats', () => {
it('should fetch and update latest stats', async () => {
const statsArray = [
@@ -274,28 +260,6 @@ describe('SiemRulesMigrationsService', () => {
});
});
- describe('getMissingResources', () => {
- it('should return missing resources', async () => {
- const resources = [{ resource: 'res1' }];
- (getMissingResources as jest.Mock).mockResolvedValue(resources);
-
- const result = await service.getMissingResources('mig-1');
- expect(getMissingResources).toHaveBeenCalledWith({ migrationId: 'mig-1' });
- expect(result).toEqual(resources);
- });
- });
-
- describe('getIntegrations', () => {
- it('should return integrations', async () => {
- const integrations = { integration1: { id: 'int-1' } };
- (getIntegrations as jest.Mock).mockResolvedValue(integrations);
-
- const result = await service.getIntegrations();
- expect(getIntegrations).toHaveBeenCalledWith({});
- expect(result).toEqual(integrations);
- });
- });
-
describe('Polling behavior', () => {
it('should poll and send a success toast when a migration finishes', async () => {
// Use fake timers to simulate delays inside the polling loop.
diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts
index a37b1db3a3404..ca1ac50cdc5fd 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts
@@ -13,14 +13,9 @@ import {
TRACE_OPTIONS_SESSION_STORAGE_KEY,
} from '@kbn/elastic-assistant/impl/assistant_context/constants';
import type { TelemetryServiceStart } from '../../../common/lib/telemetry';
-import type { RelatedIntegration } from '../../../../common/api/detection_engine';
-import type {
- RuleMigrationResourceBase,
- RuleMigrationTaskStats,
-} from '../../../../common/siem_migrations/model/rule_migration.gen';
+import type { RuleMigrationTaskStats } from '../../../../common/siem_migrations/model/rule_migration.gen';
import type {
CreateRuleMigrationRulesRequestBody,
- GetRuleMigrationStatsResponse,
StartRuleMigrationResponse,
UpsertRuleMigrationResourcesRequestBody,
} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
@@ -29,18 +24,7 @@ import { SiemMigrationTaskStatus } from '../../../../common/siem_migrations/cons
import type { StartPluginsDependencies } from '../../../types';
import { ExperimentalFeaturesService } from '../../../common/experimental_features_service';
import { licenseService } from '../../../common/hooks/use_license';
-import type { StartRuleMigrationParams } from '../api';
-import {
- createRuleMigration,
- getRuleMigrationStats,
- getRuleMigrationsStatsAll,
- startRuleMigration,
- type GetRuleMigrationsStatsAllParams,
- getMissingResources,
- upsertMigrationResources,
- getIntegrations,
- addRulesToMigration,
-} from '../api';
+import * as api from '../api';
import {
getMissingCapabilities,
type MissingCapability,
@@ -85,6 +69,10 @@ export class SiemRulesMigrationsService {
});
}
+ public get api() {
+ return api;
+ }
+
public getLatestStats$(): Observable {
return this.latestStats$.asObservable();
}
@@ -132,7 +120,7 @@ export class SiemRulesMigrationsService {
// Batching creation to avoid hitting the max payload size limit of the API
for (let i = 0; i < rulesCount; i += CREATE_MIGRATION_BODY_BATCH_SIZE) {
const rulesBatch = rules.slice(i, i + CREATE_MIGRATION_BODY_BATCH_SIZE);
- await addRulesToMigration({ migrationId, body: rulesBatch });
+ await api.addRulesToMigration({ migrationId, body: rulesBatch });
}
}
@@ -144,7 +132,7 @@ export class SiemRulesMigrationsService {
try {
// create the migration
- const { migration_id: migrationId } = await createRuleMigration({});
+ const { migration_id: migrationId } = await api.createRuleMigration({});
await this.addRulesToMigration(migrationId, data);
@@ -170,7 +158,7 @@ export class SiemRulesMigrationsService {
// Batching creation to avoid hitting the max payload size limit of the API
for (let i = 0; i < count; i += CREATE_MIGRATION_BODY_BATCH_SIZE) {
const bodyBatch = body.slice(i, i + CREATE_MIGRATION_BODY_BATCH_SIZE);
- await upsertMigrationResources({ migrationId, body: bodyBatch });
+ await api.upsertMigrationResources({ migrationId, body: bodyBatch });
}
this.telemetry.reportSetupResourceUploaded({ migrationId, type, count });
} catch (error) {
@@ -195,7 +183,7 @@ export class SiemRulesMigrationsService {
this.core.notifications.toasts.add(getNoConnectorToast(this.core));
return { started: false };
}
- const params: StartRuleMigrationParams = { migrationId, connectorId, retry };
+ const params: api.StartRuleMigrationParams = { migrationId, connectorId, retry };
const traceOptions = this.traceOptionsStorage.get();
if (traceOptions) {
@@ -206,7 +194,7 @@ export class SiemRulesMigrationsService {
}
try {
- const result = await startRuleMigration(params);
+ const result = await api.startRuleMigration(params);
this.startPolling();
this.telemetry.reportStartTranslation(params);
@@ -217,12 +205,8 @@ export class SiemRulesMigrationsService {
}
}
- public async getRuleMigrationStats(migrationId: string): Promise {
- return getRuleMigrationStats({ migrationId });
- }
-
public async getRuleMigrationsStats(
- params: GetRuleMigrationsStatsAllParams = {}
+ params: api.GetRuleMigrationsStatsAllParams = {}
): Promise {
const allStats = await this.getRuleMigrationsStatsWithRetry(params);
const results = allStats.map(
@@ -233,19 +217,15 @@ export class SiemRulesMigrationsService {
return results;
}
- public async getMissingResources(migrationId: string): Promise {
- return getMissingResources({ migrationId });
- }
-
private async getRuleMigrationsStatsWithRetry(
- params: GetRuleMigrationsStatsAllParams = {},
+ params: api.GetRuleMigrationsStatsAllParams = {},
sleepSecs?: number
): Promise {
if (sleepSecs) {
await new Promise((resolve) => setTimeout(resolve, sleepSecs * 1000));
}
- return getRuleMigrationsStatsAll(params).catch((e) => {
+ return api.getRuleMigrationsStatsAll(params).catch((e) => {
// Retry only on network errors (no status) and 503 (Service Unavailable), otherwise throw
const status = e.response?.status || e.status;
if (status && status !== 503) {
@@ -260,10 +240,6 @@ export class SiemRulesMigrationsService {
});
}
- public async getIntegrations(): Promise> {
- return getIntegrations({});
- }
-
private async startTaskStatsPolling(): Promise {
let pendingMigrationIds: string[] = [];
do {
@@ -290,7 +266,7 @@ export class SiemRulesMigrationsService {
if (result.status === SiemMigrationTaskStatus.STOPPED && !result.last_error) {
const connectorId = this.connectorIdStorage.get();
if (connectorId && !this.hasMissingCapabilities('all')) {
- await startRuleMigration({ migrationId: result.id, connectorId });
+ await api.startRuleMigration({ migrationId: result.id, connectorId });
pendingMigrationIds.push(result.id);
}
}
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts
index 4d49dc0aad896..1b66f660719c7 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts
@@ -27,6 +27,7 @@ import { registerSiemRuleMigrationsEvaluateRoute } from './evaluation/evaluate';
import { registerSiemRuleMigrationsCreateRulesRoute } from './rules/create';
import { registerSiemRuleMigrationsGetRulesRoute } from './rules/get';
import { registerSiemRuleMigrationsDeleteRoute } from './delete';
+import { registerSiemRuleMigrationsIntegrationsStatsRoute } from './integrations_stats';
export const registerSiemRuleMigrationsRoutes = (
router: SecuritySolutionPluginRouter,
@@ -54,9 +55,14 @@ export const registerSiemRuleMigrationsRoutes = (
registerSiemRuleMigrationsStopRoute(router, logger);
/** *******/
+ /** Install */
registerSiemRuleMigrationsInstallRoute(router, logger);
+ /** *******/
+ /** Integrations */
registerSiemRuleMigrationsIntegrationsRoute(router, logger);
+ registerSiemRuleMigrationsIntegrationsStatsRoute(router, logger);
+ /** *******/
/** Resources */
registerSiemRuleMigrationsResourceUpsertRoute(router, logger);
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts
new file mode 100644
index 0000000000000..a5f8850c49784
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { IKibanaResponse, Logger } from '@kbn/core/server';
+import { type GetRuleMigrationIntegrationsStatsResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
+import { SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH } from '../../../../../common/siem_migrations/constants';
+import type { SecuritySolutionPluginRouter } from '../../../../types';
+import { authz } from './util/authz';
+import { withLicense } from './util/with_license';
+import { SiemMigrationAuditLogger } from './util/audit';
+
+export const registerSiemRuleMigrationsIntegrationsStatsRoute = (
+ router: SecuritySolutionPluginRouter,
+ logger: Logger
+) => {
+ router.versioned
+ .get({
+ path: SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH,
+ access: 'internal',
+ security: { authz },
+ })
+ .addVersion(
+ {
+ version: '1',
+ validate: {},
+ },
+ withLicense(
+ async (
+ context,
+ _req,
+ res
+ ): Promise> => {
+ const siemMigrationAuditLogger = new SiemMigrationAuditLogger(context.securitySolution);
+ try {
+ const ctx = await context.resolve(['securitySolution']);
+ const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient();
+ await siemMigrationAuditLogger.logGetAllIntegrationsStats();
+
+ const allIntegrationsStats =
+ await ruleMigrationsClient.data.rules.getAllIntegrationsStats();
+
+ return res.ok({ body: allIntegrationsStats });
+ } catch (error) {
+ logger.error(error);
+ await siemMigrationAuditLogger.logGetAllIntegrationsStats({ error });
+ return res.customError({ statusCode: 500, body: error.message });
+ }
+ }
+ )
+ );
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/audit.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/audit.ts
index 191ad3faab904..20d88a0b07501 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/audit.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/audit.ts
@@ -21,6 +21,7 @@ export enum SiemMigrationsAuditActions {
SIEM_MIGRATION_STOPPED = 'siem_migration_stopped',
SIEM_MIGRATION_UPDATED_RULE = 'siem_migration_updated_rule',
SIEM_MIGRATION_INSTALLED_RULES = 'siem_migration_installed_rules',
+ SIEM_MIGRATION_RETRIEVED_INTEGRATIONS_STATS = 'siem_migration_retrieved_integrations_stats',
}
export enum AUDIT_TYPE {
@@ -59,6 +60,7 @@ export const siemMigrationAuditEventType: Record<
[SiemMigrationsAuditActions.SIEM_MIGRATION_ADDED_RULES]: AUDIT_TYPE.CREATION,
[SiemMigrationsAuditActions.SIEM_MIGRATION_RETRIEVED_RULES]: AUDIT_TYPE.ACCESS,
[SiemMigrationsAuditActions.SIEM_MIGRATION_DELETED]: AUDIT_TYPE.CHANGE,
+ [SiemMigrationsAuditActions.SIEM_MIGRATION_RETRIEVED_INTEGRATIONS_STATS]: AUDIT_TYPE.ACCESS,
};
interface SiemMigrationAuditEvent {
@@ -232,4 +234,17 @@ export class SiemMigrationAuditLogger {
}
return this.log(events);
}
+
+ public async logGetAllIntegrationsStats({
+ error,
+ }: {
+ error?: Error;
+ } = {}): Promise {
+ const message = `User retrieved all integrations stats for SIEM rule migrations`;
+ return this.log({
+ action: SiemMigrationsAuditActions.SIEM_MIGRATION_RETRIEVED_INTEGRATIONS_STATS,
+ error,
+ message,
+ });
+ }
}
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts
index 661e87b3d6508..e2b0ec9ade6d0 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts
@@ -22,7 +22,10 @@ import {
SiemMigrationStatus,
RuleTranslationResult,
} from '../../../../../common/siem_migrations/constants';
-import type { RuleMigrationRule } from '../../../../../common/siem_migrations/model/rule_migration.gen';
+import type {
+ RuleMigrationAllIntegrationsStats,
+ RuleMigrationRule,
+} from '../../../../../common/siem_migrations/model/rule_migration.gen';
import {
type RuleMigrationTaskStats,
type RuleMigrationTranslationStats,
@@ -45,6 +48,8 @@ export interface RuleMigrationGetRulesOptions {
size?: number;
}
+/** Maximum size for searches, aggregations and terms queries */
+const QUERY_MAX_SIZE = 10_000 as const;
/* BULK_MAX_SIZE defines the number to break down the bulk operations by.
* The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed. */
const BULK_MAX_SIZE = 500 as const;
@@ -301,7 +306,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
const index = await this.getIndexName();
const aggregations: { migrationIds: AggregationsAggregationContainer } = {
migrationIds: {
- terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: 10000 },
+ terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: QUERY_MAX_SIZE },
aggregations: {
status: { terms: { field: 'status' } },
createdAt: { min: { field: '@timestamp' } },
@@ -329,6 +334,33 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
}));
}
+ /** Retrieves the stats for the integrations of all the migration rules */
+ async getAllIntegrationsStats(): Promise {
+ const index = await this.getIndexName();
+ const aggregations: { integrationIds: AggregationsAggregationContainer } = {
+ integrationIds: {
+ terms: {
+ field: 'elastic_rule.integration_ids', // aggregate by integration ids
+ exclude: '', // excluding empty string integration ids
+ size: QUERY_MAX_SIZE,
+ },
+ },
+ };
+ const result = await this.esClient
+ .search({ index, aggregations, _source: false })
+ .catch((error) => {
+ this.logger.error(`Error getting all integrations stats: ${error.message}`);
+ throw error;
+ });
+
+ const integrationsAgg = result.aggregations?.integrationIds as AggregationsStringTermsAggregate;
+ const buckets = (integrationsAgg?.buckets as AggregationsStringTermsBucket[]) ?? [];
+ return buckets.map((bucket) => ({
+ id: `${bucket.key}`,
+ total_rules: bucket.doc_count,
+ }));
+ }
+
private statusAggCounts(
statusAgg: AggregationsStringTermsAggregate
): Record {
@@ -422,7 +454,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
* */
async prepareDelete(migrationId: string): Promise {
const index = await this.getIndexName();
- const rulesToBeDeleted = await this.get(migrationId, { size: 10000 });
+ const rulesToBeDeleted = await this.get(migrationId, { size: QUERY_MAX_SIZE });
const rulesToBeDeletedDocIds = rulesToBeDeleted.data.map((rule) => rule.id);
return rulesToBeDeletedDocIds.map((docId) => ({
diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts
index 29bdba8cee3c2..6cfbdfa0b957f 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts
@@ -28,7 +28,6 @@ export const getTranslateRuleNode = ({
const indexPatterns =
state.integration?.data_streams?.map((dataStream) => dataStream.index_pattern).join(',') ||
'logs-*';
- const integrationId = state.integration?.id || '';
const splunkRule = {
title: state.original_rule.title,
@@ -57,11 +56,11 @@ export const getTranslateRuleNode = ({
return {
comments: [generateAssistantComment(cleanMarkdown(translationSummary))],
elastic_rule: {
- integration_ids: [integrationId],
query: esqlQuery,
query_language: 'esql',
risk_score: getElasticRiskScoreFromOriginalRule(state.original_rule),
severity: getElasticSeverityFromOriginalRule(state.original_rule),
+ ...(state.integration?.id && { integration_ids: [state.integration.id] }),
},
};
};
diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts
index 1ee72e69cc884..a7053364161bd 100644
--- a/x-pack/test/api_integration/services/security_solution_api.gen.ts
+++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts
@@ -1150,6 +1150,16 @@ finalize it.
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
+ /**
+ * Retrieves the stats of all the integrations for all the rule migrations, including the number of rules associated with the integration
+ */
+ getRuleMigrationIntegrationsStats(kibanaSpace: string = 'default') {
+ return supertest
+ .get(routeWithNamespace('/internal/siem_migrations/rules/integrations/stats', kibanaSpace))
+ .set('kbn-xsrf', 'true')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
+ },
/**
* Retrieves all available prebuilt rules (installed and installable)
*/
diff --git a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/index.ts
index b4731fb37142e..cb71a85149890 100644
--- a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/index.ts
+++ b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/index.ts
@@ -20,5 +20,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./start'));
loadTestFile(require.resolve('./stop'));
loadTestFile(require.resolve('./get_integrations'));
+ loadTestFile(require.resolve('./integrations_stats'));
});
}
diff --git a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/integrations_stats.ts b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/integrations_stats.ts
new file mode 100644
index 0000000000000..3e5dfb36c02ed
--- /dev/null
+++ b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/integrations_stats.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 expect from 'expect';
+import { v4 as uuidv4 } from 'uuid';
+import {
+ createMigrationRules,
+ defaultElasticRule,
+ deleteAllRuleMigrations,
+ getMigrationRuleDocuments,
+ ruleMigrationRouteHelpersFactory,
+} from '../../utils';
+import { FtrProviderContext } from '../../../../ftr_provider_context';
+
+export default ({ getService }: FtrProviderContext) => {
+ const es = getService('es');
+ const supertest = getService('supertest');
+ const migrationRulesRoutes = ruleMigrationRouteHelpersFactory(supertest);
+
+ describe('@ess @serverless @serverlessQA Stats API', () => {
+ beforeEach(async () => {
+ await deleteAllRuleMigrations(es);
+ });
+
+ it('should return empty stats when no migration exists', async () => {
+ const response = await migrationRulesRoutes.integrationStats();
+ expect(response.body).toEqual([]);
+ });
+
+ it('should return integrations stats', async () => {
+ const documents = [
+ {
+ migrationId: uuidv4(),
+ elastic_rule: {
+ ...defaultElasticRule,
+ integration_ids: ['integration3', 'integration2', 'integration1'],
+ },
+ },
+ {
+ migrationId: uuidv4(),
+ elastic_rule: {
+ ...defaultElasticRule,
+ integration_ids: ['integration2', 'integration1'],
+ },
+ },
+ {
+ migrationId: uuidv4(),
+ elastic_rule: { ...defaultElasticRule, integration_ids: ['integration1'] },
+ },
+ ];
+ const migrationRuleDocuments = getMigrationRuleDocuments(
+ documents.length,
+ (index) => documents[index]
+ );
+ await createMigrationRules(es, migrationRuleDocuments);
+
+ const response = await migrationRulesRoutes.integrationStats();
+ expect(response.body).toEqual([
+ { id: 'integration1', total_rules: 3 },
+ { id: 'integration2', total_rules: 2 },
+ { id: 'integration3', total_rules: 1 },
+ ]);
+ });
+
+ it('should omit integration_ids with empty string', async () => {
+ const documents = [
+ {
+ migrationId: uuidv4(),
+ elastic_rule: {
+ ...defaultElasticRule,
+ integration_ids: ['integration1', ''],
+ },
+ },
+ {
+ migrationId: uuidv4(),
+ elastic_rule: { ...defaultElasticRule, integration_ids: ['integration1', ''] },
+ },
+ ];
+ const migrationRuleDocuments = getMigrationRuleDocuments(2, (index) => documents[index]);
+ await createMigrationRules(es, migrationRuleDocuments);
+
+ const response = await migrationRulesRoutes.integrationStats();
+ expect(response.body).toEqual([{ id: 'integration1', total_rules: 2 }]);
+ });
+ });
+};
diff --git a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/utils/rules.ts b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/utils/rules.ts
index c6e2ce3d5448f..342cf511b45a3 100644
--- a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/utils/rules.ts
+++ b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/utils/rules.ts
@@ -24,6 +24,7 @@ import {
SIEM_RULE_MIGRATION_STOP_PATH,
SIEM_RULE_MIGRATIONS_INTEGRATIONS_PATH,
SIEM_RULE_MIGRATION_RULES_PATH,
+ SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH,
} from '@kbn/security-solution-plugin/common/siem_migrations/constants';
import {
CreateRuleMigrationResponse,
@@ -332,5 +333,20 @@ export const ruleMigrationRouteHelpersFactory = (supertest: SuperTest.Agent) =>
return response;
},
+
+ integrationStats: async ({ expectStatusCode = 200 }: RequestParams = {}): Promise<{
+ body: GetRuleMigrationIntegrationsResponse;
+ }> => {
+ const response = await supertest
+ .get(SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH)
+ .set('kbn-xsrf', 'true')
+ .set(ELASTIC_HTTP_VERSION_HEADER, API_VERSIONS.internal.v1)
+ .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
+ .send();
+
+ assertStatusCode(expectStatusCode, response);
+
+ return response;
+ },
};
};