diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 21b108790b232..d1ecb1c035286 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiTitle, EuiToolTip, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -76,6 +77,7 @@ export function PackageCard({ onCardClick: onClickProp = undefined, isCollectionCard = false, titleLineClamp, + titleBadge, descriptionLineClamp, maxCardHeight, minCardHeight, @@ -231,6 +233,7 @@ export function PackageCard({ } [class*='euiCard__titleButton'] { + width: 100%; ${getLineClampStyles(titleLineClamp)} } @@ -240,7 +243,7 @@ export function PackageCard({ isquickstart={isQuickstart} betaBadgeProps={quickstartBadge(isQuickstart)} layout="horizontal" - title={title || ''} + title={} titleSize="xs" description={showDescription ? description : ''} hasBorder @@ -277,6 +280,30 @@ export function PackageCard({ ); } +const CardTitle = React.memo>( + ({ title, titleBadge }) => { + if (!titleBadge) { + return title; + } + return ( + + + +

{title}

+
+
+ {titleBadge} +
+ ); + } +); + function quickstartBadge(isQuickstart: boolean): { label: string; color: 'accent' } | undefined { return isQuickstart ? { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index 25c3a7de6b5a9..e63bc264bedbb 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -68,6 +68,7 @@ export interface IntegrationCardItem { title: string; // Security Solution uses this prop to determine how many lines the card title should be truncated titleLineClamp?: number; + titleBadge?: React.ReactNode; url: string; version: string; type?: string; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts index 0df8b92fed856..4ac8de03af508 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -387,6 +387,7 @@ import type { GetRuleMigrationRequestParamsInput, GetRuleMigrationResponse, GetRuleMigrationIntegrationsResponse, + GetRuleMigrationIntegrationsStatsResponse, GetRuleMigrationPrebuiltRulesRequestParamsInput, GetRuleMigrationPrebuiltRulesResponse, GetRuleMigrationPrivilegesResponse, @@ -1668,6 +1669,21 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Retrieves the stats of all the integrations for all the rule migrations, including the number of rules associated with the integration + */ + async getRuleMigrationIntegrationsStats() { + this.log.info(`${new Date().toISOString()} Calling API GetRuleMigrationIntegrationsStats`); + return this.kbnClient + .request({ + path: '/internal/siem_migrations/rules/integrations/stats', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Retrieves all available prebuilt rules (installed and installable) */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts index 35a07ecb80623..ae4c796dd104a 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts @@ -13,6 +13,8 @@ export const SIEM_RULE_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as cons export const SIEM_RULE_MIGRATIONS_ALL_STATS_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/stats` as const; export const SIEM_RULE_MIGRATIONS_INTEGRATIONS_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/integrations` as const; +export const SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH = + `${SIEM_RULE_MIGRATIONS_PATH}/integrations/stats` as const; export const SIEM_RULE_MIGRATION_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}` as const; export const SIEM_RULE_MIGRATION_RULES_PATH = `${SIEM_RULE_MIGRATION_PATH}/rules` as const; export const SIEM_RULE_MIGRATION_START_PATH = `${SIEM_RULE_MIGRATION_PATH}/start` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts index 938c1349b142c..91cc2b4d458c5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts @@ -19,6 +19,7 @@ import { ArrayFromString, BooleanFromString } from '@kbn/zod-helpers'; import { RuleMigrationTaskStats, + RuleMigrationAllIntegrationsStats, RuleMigration, OriginalRule, RuleMigrationRule, @@ -89,6 +90,11 @@ export type GetRuleMigrationIntegrationsResponse = z.infer< >; export const GetRuleMigrationIntegrationsResponse = z.object({}).catchall(RelatedIntegration); +export type GetRuleMigrationIntegrationsStatsResponse = z.infer< + typeof GetRuleMigrationIntegrationsStatsResponse +>; +export const GetRuleMigrationIntegrationsStatsResponse = RuleMigrationAllIntegrationsStats; + export type GetRuleMigrationPrebuiltRulesRequestParams = z.infer< typeof GetRuleMigrationPrebuiltRulesRequestParams >; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml index bb55e37bae001..b2c49fe309b8b 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml @@ -44,6 +44,23 @@ paths: additionalProperties: $ref: '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml#/components/schemas/RelatedIntegration' + /internal/siem_migrations/rules/integrations/stats: + get: + summary: Retrieves the stats of all the integrations for all the rule migrations + operationId: GetRuleMigrationIntegrationsStats + x-codegen-enabled: true + x-internal: true + description: Retrieves the stats of all the integrations for all the rule migrations, including the number of rules associated with the integration + tags: + - SIEM Rule Migrations + responses: + 200: + description: Indicates that related integrations stats have been retrieved correctly. + content: + application/json: + schema: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationAllIntegrationsStats' + /internal/siem_migrations/rules: put: summary: Creates a new rule migration diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index 9b193d5e7ba86..a747c4d1236dc 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -415,6 +415,27 @@ export const RuleMigrationRetryFilter = z.enum(['failed', 'not_fully_translated' export type RuleMigrationRetryFilterEnum = typeof RuleMigrationRetryFilter.enum; export const RuleMigrationRetryFilterEnum = RuleMigrationRetryFilter.enum; +/** + * The migration rules integration stats object. + */ +export type RuleMigrationIntegrationStats = z.infer; +export const RuleMigrationIntegrationStats = z.object({ + /** + * The integration id + */ + id: NonEmptyString, + /** + * The number of rules that are associated with the integration. + */ + total_rules: z.number().int(), +}); + +/** + * The integrations stats objects of all the rule of all the migrations. + */ +export type RuleMigrationAllIntegrationsStats = z.infer; +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; + }, }; };