diff --git a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts index 8a2d6cf3775c9..5e6c5edbfd3fc 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts @@ -17,6 +17,13 @@ export const SIEM_RULE_MIGRATION_STOP_PATH = `${SIEM_RULE_MIGRATION_PATH}/stop` export const SIEM_RULE_MIGRATION_RESOURCES_PATH = `${SIEM_RULE_MIGRATION_PATH}/resources` as const; +export enum SiemMigrationTaskStatus { + READY = 'ready', + RUNNING = 'running', + STOPPED = 'stopped', + FINISHED = 'finished', +} + export enum SiemMigrationStatus { PENDING = 'pending', PROCESSING = 'processing', diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts index 36728e0e928a0..463ec97dd200e 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts @@ -167,16 +167,7 @@ export type UpsertRuleMigrationResourcesRequestParamsInput = z.input< export type UpsertRuleMigrationResourcesRequestBody = z.infer< typeof UpsertRuleMigrationResourcesRequestBody >; -export const UpsertRuleMigrationResourcesRequestBody = z.array( - RuleMigrationResourceData.merge( - z.object({ - /** - * The rule resource migration id - */ - id: NonEmptyString, - }) - ) -); +export const UpsertRuleMigrationResourcesRequestBody = z.array(RuleMigrationResourceData); export type UpsertRuleMigrationResourcesRequestBodyInput = z.input< typeof UpsertRuleMigrationResourcesRequestBody >; diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml index fdb589e7b45cd..6e713e498f6be 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml @@ -10,6 +10,7 @@ paths: summary: Creates a new rule migration operationId: CreateRuleMigration x-codegen-enabled: true + x-internal: true description: Creates a new SIEM rules migration using the original vendor rules provided tags: - SIEM Rule Migrations @@ -39,6 +40,7 @@ paths: summary: Updates rules migrations operationId: UpdateRuleMigration x-codegen-enabled: true + x-internal: true description: Updates rules migrations attributes tags: - SIEM Rule Migrations @@ -84,6 +86,7 @@ paths: summary: Retrieves the stats for all rule migrations operationId: GetAllStatsRuleMigration x-codegen-enabled: true + x-internal: true description: Retrieves the rule migrations stats for all migrations stored in the system tags: - SIEM Rule Migrations @@ -104,6 +107,7 @@ paths: summary: Retrieves all the rules of a migration operationId: GetRuleMigration x-codegen-enabled: true + x-internal: true description: Retrieves the rule documents stored in the system given the rule migration id tags: - SIEM Rule Migrations @@ -131,6 +135,7 @@ paths: summary: Starts a rule migration operationId: StartRuleMigration x-codegen-enabled: true + x-internal: true description: Starts a SIEM rules migration using the migration id provided tags: - SIEM Rule Migrations @@ -175,6 +180,7 @@ paths: summary: Gets a rule migration task stats operationId: GetRuleMigrationStats x-codegen-enabled: true + x-internal: true description: Retrieves the stats of a SIEM rules migration using the migration id provided tags: - SIEM Rule Migrations @@ -200,6 +206,7 @@ paths: summary: Stops an existing rule migration operationId: StopRuleMigration x-codegen-enabled: true + x-internal: true description: Stops a running SIEM rules migration using the migration id provided tags: - SIEM Rule Migrations @@ -233,6 +240,7 @@ paths: summary: Creates or updates rule migration resources for a migration operationId: UpsertRuleMigrationResources x-codegen-enabled: true + x-internal: true description: Creates or updates resources for an existing SIEM rules migration tags: - SIEM Rule Migrations @@ -251,15 +259,7 @@ paths: schema: type: array items: - allOf: - - $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationResourceData' - - type: object - required: - - id - properties: - id: - description: The rule resource migration id - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationResourceData' responses: 200: description: Indicates migration resources have been created or updated correctly. @@ -278,6 +278,7 @@ paths: summary: Gets rule migration resources for a migration operationId: GetRuleMigrationResources x-codegen-enabled: true + x-internal: true description: Retrieves resources for an existing SIEM rules migration tags: - SIEM Rule Migrations diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index 2260b83190e22..82e3c5549fd86 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -113,7 +113,7 @@ export type RuleMigrationTranslationResultEnum = typeof RuleMigrationTranslation export const RuleMigrationTranslationResultEnum = RuleMigrationTranslationResult.enum; /** - * The status of the rule migration process. + * The status of each rule migration. */ export type RuleMigrationStatus = z.infer; export const RuleMigrationStatus = z.enum(['pending', 'processing', 'completed', 'failed']); @@ -186,6 +186,14 @@ export const RuleMigration = z }) .merge(RuleMigrationData); +/** + * The status of the migration task. + */ +export type RuleMigrationTaskStatus = z.infer; +export const RuleMigrationTaskStatus = z.enum(['ready', 'running', 'stopped', 'finished']); +export type RuleMigrationTaskStatusEnum = typeof RuleMigrationTaskStatus.enum; +export const RuleMigrationTaskStatusEnum = RuleMigrationTaskStatus.enum; + /** * The rule migration task stats object. */ @@ -198,7 +206,7 @@ export const RuleMigrationTaskStats = z.object({ /** * Indicates if the migration task status. */ - status: z.enum(['ready', 'running', 'stopped', 'finished']), + status: RuleMigrationTaskStatus, /** * The rules migration stats. */ diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index 17c70665b9ad3..82892b4fa0722 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -155,13 +155,8 @@ components: description: The migration id $ref: './common.schema.yaml#/components/schemas/NonEmptyString' status: - type: string description: Indicates if the migration task status. - enum: - - ready - - running - - stopped - - finished + $ref: '#/components/schemas/RuleMigrationTaskStatus' rules: type: object description: The rules migration stats. @@ -194,6 +189,15 @@ components: type: string description: The moment of the last update. + RuleMigrationTaskStatus: + type: string + description: The status of the migration task. + enum: # should match SiemMigrationTaskStatus enum at ../constants.ts + - ready + - running + - stopped + - finished + RuleMigrationTranslationResult: type: string description: The rule translation result. @@ -204,7 +208,7 @@ components: RuleMigrationStatus: type: string - description: The status of the rule migration process. + description: The status of each rule migration. enum: # should match SiemMigrationsStatus enum at ../constants.ts - pending - processing diff --git a/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context_mocks.ts b/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/mocks.ts similarity index 61% rename from x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context_mocks.ts rename to x-pack/plugins/security_solution/public/onboarding/components/__mocks__/mocks.ts index dcd5d681b34bf..8947800d529c3 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context_mocks.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/mocks.ts @@ -14,3 +14,17 @@ export const mockReportCardOpen = jest.fn(); export const mockReportCardComplete = jest.fn(); export const mockReportCardLinkClicked = jest.fn(); + +export const telemetry = { + reportCardOpen: mockReportCardOpen, + reportCardComplete: mockReportCardComplete, + reportCardLinkClicked: mockReportCardLinkClicked, +}; +export const mockTelemetry = jest.fn(() => telemetry); + +export const onboardingContext = { + spaceId: 'default', + telemetry: mockTelemetry(), + config: new Map(), +}; +export const mockOnboardingContext = jest.fn(() => onboardingContext); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context.tsx b/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context.tsx index d1c9afcef33d6..a8b7eecf273b3 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/__mocks__/onboarding_context.tsx @@ -12,17 +12,8 @@ */ import type { OnboardingContextValue } from '../onboarding_context'; -import { - mockReportCardOpen, - mockReportCardComplete, - mockReportCardLinkClicked, -} from './onboarding_context_mocks'; +import { mockOnboardingContext } from './mocks'; export const useOnboardingContext = (): OnboardingContextValue => { - return { - spaceId: 'default', - reportCardOpen: mockReportCardOpen, - reportCardComplete: mockReportCardComplete, - reportCardLinkClicked: mockReportCardLinkClicked, - }; + return mockOnboardingContext(); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/__snapshots__/onboarding_context.test.tsx.snap b/x-pack/plugins/security_solution/public/onboarding/components/__snapshots__/onboarding_context.test.tsx.snap new file mode 100644 index 0000000000000..07275346cda1e --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/__snapshots__/onboarding_context.test.tsx.snap @@ -0,0 +1,257 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OnboardingContextProvider config when all requirements are met should return all topics config correctly 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, + "topic1" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "topic1Card1", + }, + ], + "id": "topic1Group1", + }, + ], + "capabilitiesRequired": Array [ + "capability1", + ], + "experimentalFlagRequired": "flag1", + "id": "topic1", + "licenseTypeRequired": "gold", + }, + "topic2" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "experimentalFlagRequired": "flag1", + "id": "topic2Card1", + }, + Object { + "id": "topic2Card2", + "licenseTypeRequired": "gold", + }, + Object { + "capabilitiesRequired": Array [ + "capability1", + ], + "id": "topic2Card3", + }, + ], + "id": "topic2Group1", + }, + ], + "id": "topic2", + }, +} +`; + +exports[`OnboardingContextProvider config when the required capabilities are not met should filter the topics config correctly 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, + "topic2" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "experimentalFlagRequired": "flag1", + "id": "topic2Card1", + }, + Object { + "id": "topic2Card2", + "licenseTypeRequired": "gold", + }, + ], + "id": "topic2Group1", + }, + ], + "id": "topic2", + }, +} +`; + +exports[`OnboardingContextProvider config when the required experimental flag is not met and the required license is not met either and the required capabilities are not met either should return only the default topics config 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, +} +`; + +exports[`OnboardingContextProvider config when the required experimental flag is not met and the required license is not met either should filter the topics config correctly 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, + "topic2" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "capabilitiesRequired": Array [ + "capability1", + ], + "id": "topic2Card3", + }, + ], + "id": "topic2Group1", + }, + ], + "id": "topic2", + }, +} +`; + +exports[`OnboardingContextProvider config when the required experimental flag is not met should filter the topics config correctly 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, + "topic2" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "topic2Card2", + "licenseTypeRequired": "gold", + }, + Object { + "capabilitiesRequired": Array [ + "capability1", + ], + "id": "topic2Card3", + }, + ], + "id": "topic2Group1", + }, + ], + "id": "topic2", + }, +} +`; + +exports[`OnboardingContextProvider config when the required license is not met and the required capabilities are not met either should filter the topics config correctly 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, + "topic2" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "experimentalFlagRequired": "flag1", + "id": "topic2Card1", + }, + ], + "id": "topic2Group1", + }, + ], + "id": "topic2", + }, +} +`; + +exports[`OnboardingContextProvider config when the required license is not met should filter the topics config correctly 1`] = ` +Map { + "default" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "id": "defaultCard1", + }, + ], + "id": "defaultGroup1", + }, + ], + "id": "default", + }, + "topic2" => Object { + "body": Array [ + Object { + "cards": Array [ + Object { + "experimentalFlagRequired": "flag1", + "id": "topic2Card1", + }, + Object { + "capabilitiesRequired": Array [ + "capability1", + ], + "id": "topic2Card3", + }, + ], + "id": "topic2Group1", + }, + ], + "id": "topic2", + }, +} +`; diff --git a/x-pack/plugins/security_solution/public/onboarding/hooks/use_onboarding_service.ts b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_onboarding_service.ts similarity index 82% rename from x-pack/plugins/security_solution/public/onboarding/hooks/use_onboarding_service.ts rename to x-pack/plugins/security_solution/public/onboarding/components/hooks/use_onboarding_service.ts index 55f3ecb8d4aca..3d94d81530289 100644 --- a/x-pack/plugins/security_solution/public/onboarding/hooks/use_onboarding_service.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_onboarding_service.ts @@ -5,6 +5,6 @@ * 2.0. */ -import { useKibana } from '../../common/lib/kibana/kibana_react'; +import { useKibana } from '../../../common/lib/kibana/kibana_react'; export const useOnboardingService = () => useKibana().services.onboarding; diff --git a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_stored_state.ts similarity index 60% rename from x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts rename to x-pack/plugins/security_solution/public/onboarding/components/hooks/use_stored_state.ts index eac269f3a4a35..87e22de599aae 100644 --- a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_stored_state.ts @@ -6,23 +6,23 @@ */ import useLocalStorage from 'react-use/lib/useLocalStorage'; -import type { OnboardingCardId } from '../constants'; -import type { IntegrationTabId } from '../components/onboarding_body/cards/integrations/types'; +import type { OnboardingCardId } from '../../constants'; +import type { IntegrationTabId } from '../onboarding_body/cards/integrations/types'; const LocalStorageKey = { - avcBannerDismissed: 'ONBOARDING_HUB.AVC_BANNER_DISMISSED', - videoVisited: 'ONBOARDING_HUB.VIDEO_VISITED', - completeCards: 'ONBOARDING_HUB.COMPLETE_CARDS', - expandedCard: 'ONBOARDING_HUB.EXPANDED_CARD', - selectedIntegrationTabId: 'ONBOARDING_HUB.SELECTED_INTEGRATION_TAB_ID', - IntegrationSearchTerm: 'ONBOARDING_HUB.INTEGRATION_SEARCH_TERM', - IntegrationScrollTop: 'ONBOARDING_HUB.INTEGRATION_SCROLL_TOP', + avcBannerDismissed: 'securitySolution.onboarding.avcBannerDismissed', + videoVisited: 'securitySolution.onboarding.videoVisited', + completeCards: 'securitySolution.onboarding.completeCards', + expandedCard: 'securitySolution.onboarding.expandedCard', + urlDetails: 'securitySolution.onboarding.urlDetails', + selectedIntegrationTabId: 'securitySolution.onboarding.selectedIntegrationTabId', + integrationSearchTerm: 'securitySolution.onboarding.integrationSearchTerm', } as const; /** * Wrapper hook for useLocalStorage, but always returns the default value when not defined instead of `undefined`. */ -const useDefinedLocalStorage = (key: string, defaultValue: T) => { +export const useDefinedLocalStorage = (key: string, defaultValue: T) => { const [value, setValue] = useLocalStorage(key, defaultValue); return [value ?? defaultValue, setValue] as const; }; @@ -40,13 +40,10 @@ export const useStoredCompletedCardIds = (spaceId: string) => useDefinedLocalStorage(`${LocalStorageKey.completeCards}.${spaceId}`, []); /** - * Stores the expanded card ID per space + * Stores the selected topic ID per space */ -export const useStoredExpandedCardId = (spaceId: string) => - useDefinedLocalStorage( - `${LocalStorageKey.expandedCard}.${spaceId}`, - null - ); +export const useStoredUrlDetails = (spaceId: string) => + useDefinedLocalStorage(`${LocalStorageKey.urlDetails}.${spaceId}`, null); /** * Stores the selected integration tab ID per space @@ -65,6 +62,6 @@ export const useStoredIntegrationTabId = ( */ export const useStoredIntegrationSearchTerm = (spaceId: string) => useDefinedLocalStorage( - `${LocalStorageKey.IntegrationSearchTerm}.${spaceId}`, + `${LocalStorageKey.integrationSearchTerm}.${spaceId}`, null ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_topic_id.ts b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_topic_id.ts new file mode 100644 index 0000000000000..b20e8ae392b62 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_topic_id.ts @@ -0,0 +1,35 @@ +/* + * 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 } from 'react'; +import { useParams } from 'react-router-dom'; +import { OnboardingTopicId } from '../../constants'; +import type { OnboardingRouteParams } from '../../types'; +import { useUrlDetail } from './use_url_detail'; + +/** + * Hook that returns the topic id from the URL, or the default topic id if none is present + * This is the Single Source of Truth for the topic id + */ +export const useTopicId = (): OnboardingTopicId => { + const { topicId = OnboardingTopicId.default } = useParams(); + return topicId; +}; + +export const useTopic = (): [OnboardingTopicId, (topicId: OnboardingTopicId) => void] => { + const topicId = useTopicId(); + const { setTopicDetail } = useUrlDetail(); + + const setTopicId = useCallback( + (newTopicId: OnboardingTopicId) => { + setTopicDetail(newTopicId); + }, + [setTopicDetail] + ); + + return [topicId, setTopicId]; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_url_detail.ts b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_url_detail.ts new file mode 100644 index 0000000000000..387e9d66865b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/hooks/use_url_detail.ts @@ -0,0 +1,74 @@ +/* + * 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 } from 'react'; +import { SecurityPageName, useNavigateTo } from '@kbn/security-solution-navigation'; +import { useStoredUrlDetails } from './use_stored_state'; +import { OnboardingTopicId, type OnboardingCardId } from '../../constants'; +import { useOnboardingContext } from '../onboarding_context'; +import { useTopicId } from './use_topic_id'; + +export const getCardIdFromHash = (hash: string): OnboardingCardId | null => + (hash.split('?')[0].replace('#', '') as OnboardingCardId) || null; + +const setHash = (cardId: OnboardingCardId | null) => { + history.replaceState(null, '', cardId == null ? ' ' : `#${cardId}`); +}; + +const getTopicPath = (topicId: OnboardingTopicId) => + topicId !== OnboardingTopicId.default ? topicId : ''; + +const getCardHash = (cardId: OnboardingCardId | null) => (cardId ? `#${cardId}` : ''); + +/** + * This hook manages the expanded card id state in the LocalStorage and the hash in the URL. + */ +export const useUrlDetail = () => { + const { spaceId, telemetry } = useOnboardingContext(); + const topicId = useTopicId(); + const [storedUrlDetail, setStoredUrlDetail] = useStoredUrlDetails(spaceId); + + const { navigateTo } = useNavigateTo(); + + const setTopicDetail = useCallback( + (newTopicId: OnboardingTopicId) => { + const path = newTopicId === OnboardingTopicId.default ? undefined : newTopicId; + setStoredUrlDetail(path ?? null); + navigateTo({ deepLinkId: SecurityPageName.landing, path }); + }, + [setStoredUrlDetail, navigateTo] + ); + + const setCardDetail = useCallback( + (newCardId: OnboardingCardId | null) => { + setHash(newCardId); + setStoredUrlDetail(`${getTopicPath(topicId)}${getCardHash(newCardId)}` || null); + if (newCardId != null) { + telemetry.reportCardOpen(newCardId); + } + }, + [setStoredUrlDetail, topicId, telemetry] + ); + + const syncUrlDetails = useCallback( + (pathTopicId: OnboardingTopicId | null, hashCardId: OnboardingCardId | null) => { + const urlDetail = `${pathTopicId || ''}${hashCardId ? `#${hashCardId}` : ''}`; + if (urlDetail && urlDetail !== storedUrlDetail) { + if (hashCardId) { + telemetry.reportCardOpen(hashCardId, { auto: true }); + } + setStoredUrlDetail(urlDetail); + } + if (!urlDetail && storedUrlDetail) { + navigateTo({ deepLinkId: SecurityPageName.landing, path: storedUrlDetail }); + } + }, + [navigateTo, setStoredUrlDetail, storedUrlDetail, telemetry] + ); + + return { setTopicDetail, setCardDetail, syncUrlDetails }; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/common/lib/__mocks__/telemetry.ts b/x-pack/plugins/security_solution/public/onboarding/components/lib/__mocks__/telemetry.ts similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/common/lib/__mocks__/telemetry.ts rename to x-pack/plugins/security_solution/public/onboarding/components/lib/__mocks__/telemetry.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/common/lib/telemetry.ts b/x-pack/plugins/security_solution/public/onboarding/components/lib/telemetry.ts similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/common/lib/telemetry.ts rename to x-pack/plugins/security_solution/public/onboarding/components/lib/telemetry.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding.tsx index 17f4840e68dc4..caa0d9f9b79d7 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding.tsx @@ -7,17 +7,23 @@ import React from 'react'; +import { Routes, Route } from '@kbn/shared-ux-router'; import { EuiSpacer, useEuiTheme } from '@elastic/eui'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { Redirect } from 'react-router-dom'; +import { ONBOARDING_PATH } from '../../../common/constants'; import { PluginTemplateWrapper } from '../../common/components/plugin_template_wrapper'; import { CenteredLoadingSpinner } from '../../common/components/centered_loading_spinner'; import { useSpaceId } from '../../common/hooks/use_space_id'; +import { OnboardingTopicId, PAGE_CONTENT_WIDTH } from '../constants'; import { OnboardingContextProvider } from './onboarding_context'; import { OnboardingAVCBanner } from './onboarding_banner'; -import { OnboardingHeader } from './onboarding_header'; -import { OnboardingBody } from './onboarding_body'; +import { OnboardingRoute } from './onboarding_route'; import { OnboardingFooter } from './onboarding_footer'; -import { PAGE_CONTENT_WIDTH } from '../constants'; + +const topicPathParam = `:topicId(${Object.values(OnboardingTopicId) // any topics + .filter((val) => val !== OnboardingTopicId.default) // except "default" + .join('|')})?`; // optional parameter export const OnboardingPage = React.memo(() => { const spaceId = useSpaceId(); @@ -42,8 +48,14 @@ export const OnboardingPage = React.memo(() => { bottomBorder="extended" style={{ backgroundColor: euiTheme.colors.body }} > - - + + + } /> + diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_banner/onboarding_banner.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_banner/onboarding_banner.tsx index 201fae862b43c..0976dbc110cc8 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_banner/onboarding_banner.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_banner/onboarding_banner.tsx @@ -8,7 +8,7 @@ import React, { useCallback } from 'react'; import { AVCResultsBanner2024, useIsStillYear2024 } from '@kbn/avc-banner'; -import { useStoredIsAVCBannerDismissed } from '../../hooks/use_stored_state'; +import { useStoredIsAVCBannerDismissed } from '../hooks/use_stored_state'; export const OnboardingBanner = React.memo(() => { const [isAVCBannerDismissed, setIsAVCBannerDismissed] = useStoredIsAVCBannerDismissed(); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts index 7f97b5c8eacd1..93690f98b48e8 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts @@ -12,8 +12,9 @@ import { dashboardsCardConfig } from './cards/dashboards'; import { rulesCardConfig } from './cards/rules'; import { alertsCardConfig } from './cards/alerts'; import { assistantCardConfig } from './cards/assistant'; +import { aiConnectorCardConfig } from './cards/siem_migrations/ai_connector'; -export const bodyConfig: OnboardingGroupConfig[] = [ +export const defaultBodyConfig: OnboardingGroupConfig[] = [ { title: i18n.translate('xpack.securitySolution.onboarding.dataGroup.title', { defaultMessage: 'Ingest your data', @@ -34,3 +35,12 @@ export const bodyConfig: OnboardingGroupConfig[] = [ cards: [assistantCardConfig], }, ]; + +export const siemMigrationsBodyConfig: OnboardingGroupConfig[] = [ + { + title: i18n.translate('xpack.securitySolution.onboarding.configure.title', { + defaultMessage: 'Configure', + }), + cards: [aiConnectorCardConfig], + }, +]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx index b728606937020..8c6ce3034c181 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useMemo } from 'react'; import { - EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -21,10 +20,10 @@ import { OnboardingCardId } from '../../../../constants'; import type { OnboardingCardComponent } from '../../../../types'; import * as i18n from './translations'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -import { ConnectorCards } from './connectors/connector_cards'; +import { ConnectorCards } from '../common/connectors/connector_cards'; import { CardCallOut } from '../common/card_callout'; import type { AssistantCardMetadata } from './types'; -import { MissingPrivilegesDescription } from './connectors/missing_privileges_tooltip'; +import { MissingPrivilegesCallOut } from '../common/connectors/missing_privileges'; export const AssistantCard: OnboardingCardComponent = ({ isCardComplete, @@ -94,9 +93,7 @@ export const AssistantCard: OnboardingCardComponent = ({ ) : ( - - - + )} ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_check_complete.ts index 8c0d029cee583..6242eb02bd540 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_check_complete.ts @@ -9,7 +9,7 @@ import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugi import type { AIConnector } from '@kbn/elastic-assistant/impl/connectorland/connector_selector'; import { i18n } from '@kbn/i18n'; import type { OnboardingCardCheckComplete } from '../../../../types'; -import { AllowedActionTypeIds } from './constants'; +import { AIActionTypeIds } from '../common/connectors/constants'; import type { AssistantCardMetadata } from './types'; export const checkAssistantCardComplete: OnboardingCardCheckComplete< @@ -21,7 +21,7 @@ export const checkAssistantCardComplete: OnboardingCardCheckComplete< } = application; const aiConnectors = allConnectors.reduce((acc: AIConnector[], connector) => { - if (!connector.isMissingSecrets && AllowedActionTypeIds.includes(connector.actionTypeId)) { + if (!connector.isMissingSecrets && AIActionTypeIds.includes(connector.actionTypeId)) { acc.push(connector); } return acc; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/connector_cards.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/connector_cards.tsx deleted file mode 100644 index 472459b631b0a..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/connector_cards.tsx +++ /dev/null @@ -1,118 +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 AIConnector } from '@kbn/elastic-assistant/impl/connectorland/connector_selector'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiLoadingSpinner, - EuiText, - EuiBadge, - EuiSpacer, - EuiCallOut, -} from '@elastic/eui'; -import { css } from '@emotion/css'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { CreateConnectorPopover } from './create_connector_popover'; -import { ConnectorSetup } from './connector_setup'; -import * as i18n from './translations'; -import { MissingPrivilegesDescription } from './missing_privileges_tooltip'; - -interface ConnectorCardsProps { - connectors?: AIConnector[]; - onConnectorSaved: () => void; - canCreateConnectors?: boolean; -} - -export const ConnectorCards = React.memo( - ({ connectors, onConnectorSaved, canCreateConnectors }) => { - const { - triggersActionsUi: { actionTypeRegistry }, - } = useKibana().services; - - if (!connectors) { - return ; - } - - const hasConnectors = connectors.length > 0; - - // show callout when user is missing actions.save privilege - if (!hasConnectors && !canCreateConnectors) { - return ( - - - - ); - } - - return ( - <> - {hasConnectors ? ( - <> - - - - - ) : ( - - )} - - ); - } -); -ConnectorCards.displayName = 'ConnectorCards'; - -interface ConnectorListProps { - connectors: AIConnector[]; - actionTypeRegistry: ReturnType< - typeof useKibana - >['services']['triggersActionsUi']['actionTypeRegistry']; -} - -const ConnectorList = React.memo(({ connectors, actionTypeRegistry }) => ( - - {connectors.map((connector) => ( - - - - - {connector.name} - - - - {actionTypeRegistry.get(connector.actionTypeId).actionTypeTitle} - - - - - - ))} - -)); - -ConnectorList.displayName = 'ConnectorList'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/index.ts index fedf975052327..4850b1ee2d865 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/index.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/index.ts @@ -25,8 +25,6 @@ export const assistantCardConfig: OnboardingCardConfig = ) ), checkComplete: checkAssistantCardComplete, - // Both capabilities are needed for this card, so we should use a double array to create an AND conditional - // (a single array would create an OR conditional between them) - capabilities: [['securitySolutionAssistant.ai-assistant']], - licenseType: 'enterprise', + capabilitiesRequired: ['securitySolutionAssistant.ai-assistant'], + licenseTypeRequired: 'enterprise', }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/translations.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/translations.ts index de3c111280436..1c526d4974a9a 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/translations.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/translations.ts @@ -35,17 +35,3 @@ export const ASSISTANT_CARD_CALLOUT_INTEGRATIONS_BUTTON = i18n.translate( defaultMessage: 'Add integrations step', } ); - -export const ASSISTANT_CARD_CREATE_NEW_CONNECTOR_POPOVER = i18n.translate( - 'xpack.securitySolution.onboarding.assistantCard.createNewConnectorPopover', - { - defaultMessage: 'Create new connector', - } -); - -export const PRIVILEGES_MISSING_TITLE = i18n.translate( - 'xpack.securitySolution.onboarding.assistantCard.callout.title', - { - defaultMessage: 'Missing privileges', - } -); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/attack_discovery/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/attack_discovery/index.ts index 3e174caa27157..beec64bd90782 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/attack_discovery/index.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/attack_discovery/index.ts @@ -22,6 +22,6 @@ export const attackDiscoveryCardConfig: OnboardingCardConfig = { './attack_discovery_card' ) ), - capabilities: 'securitySolutionAttackDiscovery.attack-discovery', - licenseType: 'enterprise', + capabilitiesRequired: 'securitySolutionAttackDiscovery.attack-discovery', + licenseTypeRequired: 'enterprise', }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_link_button.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_link_button.tsx index 96466466ee4a8..35254b45968f5 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_link_button.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_link_button.tsx @@ -19,13 +19,13 @@ export const withReportCardLinkClick = ): React.FC => React.memo(function WithReportCardLinkClick({ onClick, cardId, linkId, ...rest }) { - const { reportCardLinkClicked } = useOnboardingContext(); + const { telemetry } = useOnboardingContext(); const onClickWithReport = useCallback( (ev) => { - reportCardLinkClicked(cardId, linkId); + telemetry.reportCardLinkClicked(cardId, linkId); onClick?.(ev); }, - [reportCardLinkClicked, cardId, linkId, onClick] + [telemetry, cardId, linkId, onClick] ); return ; }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_cards.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_cards.tsx new file mode 100644 index 0000000000000..b8b51198c75ff --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_cards.tsx @@ -0,0 +1,153 @@ +/* + * 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 { type AIConnector } from '@kbn/elastic-assistant/impl/connectorland/connector_selector'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiLoadingSpinner, + EuiText, + EuiBadge, + EuiSpacer, + EuiCallOut, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { + CreateConnectorPopover, + type CreateConnectorPopoverProps, +} from './create_connector_popover'; +import { ConnectorSetup } from './connector_setup'; +import * as i18n from './translations'; +import { MissingPrivilegesDescription } from './missing_privileges'; + +interface ConnectorCardsProps + extends CreateConnectorPopoverProps, + Omit { + connectors?: AIConnector[]; // make connectors optional to handle loading state +} + +export const ConnectorCards = React.memo( + ({ + connectors, + onConnectorSaved, + canCreateConnectors, + selectedConnectorId, + setSelectedConnectorId, + }) => { + if (!connectors) { + return ; + } + + const hasConnectors = connectors.length > 0; + + // show callout when user is missing actions.save privilege + if (!hasConnectors && !canCreateConnectors) { + return ( + + + + ); + } + + return ( + <> + {hasConnectors ? ( + <> + + + + + ) : ( + + )} + + ); + } +); +ConnectorCards.displayName = 'ConnectorCards'; + +interface ConnectorListProps { + connectors: AIConnector[]; + selectedConnectorId?: string | null; + setSelectedConnectorId?: (id: string) => void; +} + +const ConnectorList = React.memo( + ({ connectors, selectedConnectorId, setSelectedConnectorId }) => { + const { euiTheme } = useEuiTheme(); + const { actionTypeRegistry } = useKibana().services.triggersActionsUi; + const onConnectorClick = useCallback( + (id: string) => { + setSelectedConnectorId?.(id); + }, + [setSelectedConnectorId] + ); + + const selectedCss = `border: 2px solid ${euiTheme.colors.primary};`; + + return ( + + {connectors.map((connector) => ( + + onConnectorClick(connector.id) : undefined} + css={css` + ${selectedConnectorId === connector.id ? selectedCss : ''} + `} + color={selectedConnectorId === connector.id ? 'primary' : 'plain'} + > + + + {connector.name} + + + + {actionTypeRegistry.get(connector.actionTypeId).actionTypeTitle} + + + + + + ))} + + ); + } +); + +ConnectorList.displayName = 'ConnectorList'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/connector_setup.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_setup.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/connector_setup.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/connector_setup.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/constants.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/constants.ts similarity index 77% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/constants.ts rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/constants.ts index 35811c18de471..5c9c94e369854 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/constants.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/constants.ts @@ -5,4 +5,4 @@ * 2.0. */ -export const AllowedActionTypeIds = ['.bedrock', '.gen-ai', '.gemini']; +export const AIActionTypeIds = ['.bedrock', '.gen-ai', '.gemini']; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/create_connector_popover.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/create_connector_popover.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/create_connector_popover.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/create_connector_popover.tsx index 32bcd66f49249..c6c378fc8e29f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/create_connector_popover.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/create_connector_popover.tsx @@ -9,9 +9,9 @@ import { css } from '@emotion/css'; import { EuiPopover, EuiLink, EuiText } from '@elastic/eui'; import { ConnectorSetup } from './connector_setup'; import * as i18n from './translations'; -import { MissingPrivilegesTooltip } from './missing_privileges_tooltip'; +import { MissingPrivilegesTooltip } from './missing_privileges'; -interface CreateConnectorPopoverProps { +export interface CreateConnectorPopoverProps { onConnectorSaved: () => void; canCreateConnectors?: boolean; } diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/hooks/use_load_action_types.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/hooks/use_load_action_types.ts similarity index 81% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/hooks/use_load_action_types.ts rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/hooks/use_load_action_types.ts index 5bdee57baafc0..48b8fdfc20d59 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/hooks/use_load_action_types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/hooks/use_load_action_types.ts @@ -9,9 +9,9 @@ import { useMemo } from 'react'; import { useLoadActionTypes as loadActionTypes } from '@kbn/elastic-assistant/impl/connectorland/use_load_action_types'; import type { HttpSetup } from '@kbn/core-http-browser'; import type { IToasts } from '@kbn/core-notifications-browser'; -import { AllowedActionTypeIds } from '../../constants'; +import { AIActionTypeIds } from '../constants'; export const useFilteredActionTypes = (http: HttpSetup, toasts: IToasts) => { const { data } = loadActionTypes({ http, toasts }); - return useMemo(() => data?.filter(({ id }) => AllowedActionTypeIds.includes(id)), [data]); + return useMemo(() => data?.filter(({ id }) => AIActionTypeIds.includes(id)), [data]); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/missing_privileges_tooltip.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/missing_privileges.tsx similarity index 78% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/missing_privileges_tooltip.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/missing_privileges.tsx index 811ef72d67634..40e211d857680 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/missing_privileges_tooltip.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/missing_privileges.tsx @@ -5,13 +5,29 @@ * 2.0. */ import React from 'react'; -import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { EuiCallOut, EuiCode, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import * as i18n from './translations'; +export const MissingPrivilegesDescription = React.memo(() => { + return ( + + {i18n.PRIVILEGES_REQUIRED_TITLE} + + +
    +
  • {i18n.REQUIRED_PRIVILEGES_CONNECTORS_ALL}
  • +
+
+
+ {i18n.CONTACT_ADMINISTRATOR} +
+ ); +}); +MissingPrivilegesDescription.displayName = 'MissingPrivilegesDescription'; + interface MissingPrivilegesTooltip { children: React.ReactElement; // EuiToolTip requires a single ReactElement child } - export const MissingPrivilegesTooltip = React.memo(({ children }) => ( (({ )); MissingPrivilegesTooltip.displayName = 'MissingPrivilegesTooltip'; -export const MissingPrivilegesDescription = React.memo(() => { +export const MissingPrivilegesCallOut = React.memo(() => { return ( - - {i18n.PRIVILEGES_REQUIRED_TITLE} - - -
    -
  • {i18n.REQUIRED_PRIVILEGES_CONNECTORS_ALL}
  • -
-
-
- {i18n.CONTACT_ADMINISTRATOR} -
+ + + ); }); -MissingPrivilegesDescription.displayName = 'MissingPrivilegesDescription'; +MissingPrivilegesCallOut.displayName = 'MissingPrivilegesCallOut'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/translations.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/connectors/translations.ts rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/connectors/translations.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/dashboards/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/dashboards/index.ts index 356b15f50bf9b..6d9bce2e34904 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/dashboards/index.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/dashboards/index.ts @@ -22,5 +22,5 @@ export const dashboardsCardConfig: OnboardingCardConfig = { './dashboards_card' ) ), - capabilities: ['dashboard.show'], + capabilitiesRequired: ['dashboard.show'], }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx index 53e8b6c34e8f2..4f5ae2f919d66 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx @@ -14,10 +14,10 @@ import React from 'react'; import { render } from '@testing-library/react'; import { AgentRequiredCallout } from './agent_required_callout'; import { TestProviders } from '../../../../../../common/mock/test_providers'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../common/lib/telemetry'); +jest.mock('../../../../lib/telemetry'); describe('AgentRequiredCallout', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx index b1d18b138487b..763dfe749adba 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx @@ -12,7 +12,7 @@ import { LinkAnchor } from '../../../../../../common/components/links'; import { CardCallOut } from '../../common/card_callout'; import { useNavigation } from '../../../../../../common/lib/kibana'; import { FLEET_APP_ID, ADD_AGENT_PATH, TELEMETRY_AGENT_REQUIRED } from '../constants'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; const fleetAgentLinkProps = { appId: FLEET_APP_ID, path: ADD_AGENT_PATH }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx index 7cd3b60c0c6ed..e761381747f46 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx @@ -10,10 +10,10 @@ import React from 'react'; import { TestProviders } from '../../../../../../common/mock/test_providers'; import { AgentlessAvailableCallout } from './agentless_available_callout'; import { useKibana } from '../../../../../../common/lib/kibana'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../common/lib/telemetry'); +jest.mock('../../../../lib/telemetry'); describe('AgentlessAvailableCallout', () => { const mockUseKibana = useKibana as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx index eaf8cbaa3b287..81c4db22f39ab 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx @@ -12,7 +12,7 @@ import { css } from '@emotion/react'; import { useKibana } from '../../../../../../common/lib/kibana'; import { LinkAnchor } from '../../../../../../common/components/links'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; import { CardCallOut } from '../../common/card_callout'; import { TELEMETRY_AGENTLESS_LEARN_MORE } from '../constants'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.test.tsx index 50ac060eba241..7d89003359743 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.test.tsx @@ -14,10 +14,10 @@ import React from 'react'; import { render } from '@testing-library/react'; import { EndpointCallout } from './endpoint_callout'; import { TestProviders } from '../../../../../../common/mock/test_providers'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../common/lib/telemetry'); +jest.mock('../../../../lib/telemetry'); describe('EndpointCallout', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx index d5b0199c9f401..b761a17901a38 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx @@ -13,7 +13,7 @@ import { css } from '@emotion/react'; import { useKibana } from '../../../../../../common/lib/kibana/kibana_react'; import { LinkAnchor } from '../../../../../../common/components/links'; import { CardCallOut } from '../../common/card_callout'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; import { TELEMETRY_ENDPOINT_LEARN_MORE } from '../constants'; export const EndpointCallout = React.memo(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx index e0aedafe45595..9cf346aeed901 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { of } from 'rxjs'; import { IntegrationCardTopCallout } from './integration_card_top_callout'; -import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; +import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; import { IntegrationTabId } from '../types'; -jest.mock('../../../../../hooks/use_onboarding_service', () => ({ +jest.mock('../../../../hooks/use_onboarding_service', () => ({ useOnboardingService: jest.fn(), })); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx index 3a6b5ae3be92c..40f4ae95cf088 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx @@ -8,7 +8,7 @@ import React from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; +import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; import { AgentlessAvailableCallout } from './agentless_available_callout'; import { InstalledIntegrationsCallout } from './installed_integrations_callout'; import { IntegrationTabId } from '../types'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx index 839e5870d4b7e..4085f2310d570 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx @@ -11,7 +11,7 @@ import { EuiIcon } from '@elastic/eui'; import { LinkAnchor } from '../../../../../../common/components/links'; import { CardCallOut } from '../../common/card_callout'; import { useAddIntegrationsUrl } from '../../../../../../common/hooks/use_add_integrations_url'; -import { trackOnboardingLinkClick } from '../../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../../lib/telemetry'; import { TELEMETRY_MANAGE_INTEGRATIONS } from '../constants'; export const ManageIntegrationsCallout = React.memo( diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/index.ts index 07e80ab64f522..3568376c192cf 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/index.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/index.ts @@ -27,5 +27,5 @@ export const integrationsCardConfig: OnboardingCardConfig ({ ...jest.requireActual('../../../../../common/lib/kibana'), useNavigation: jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx index e1ce7f5cdecf1..6b5e3f60a24e1 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx @@ -14,7 +14,7 @@ import { withLazyHook } from '../../../../../common/components/with_lazy_hook'; import { useStoredIntegrationSearchTerm, useStoredIntegrationTabId, -} from '../../../../hooks/use_stored_state'; +} from '../../../hooks/use_stored_state'; import { useOnboardingContext } from '../../../onboarding_context'; import { DEFAULT_TAB, @@ -29,7 +29,7 @@ import { INTEGRATION_TABS, INTEGRATION_TABS_BY_ID } from './integration_tabs_con import { useIntegrationCardList } from './use_integration_card_list'; import { IntegrationTabId } from './types'; import { IntegrationCardTopCallout } from './callouts/integration_card_top_callout'; -import { trackOnboardingLinkClick } from '../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../lib/telemetry'; export interface IntegrationsCardGridTabsProps { installedIntegrationsCount: number; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts index 19ab340276b83..095b2f988e59c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts @@ -6,9 +6,9 @@ */ import { renderHook } from '@testing-library/react-hooks'; import { useIntegrationCardList } from './use_integration_card_list'; -import { trackOnboardingLinkClick } from '../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../lib/telemetry'; -jest.mock('../../../../common/lib/telemetry'); +jest.mock('../../../lib/telemetry'); jest.mock('../../../../../common/lib/kibana', () => ({ ...jest.requireActual('../../../../../common/lib/kibana'), useNavigation: jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts index ccea5299551c1..660464ba73501 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts @@ -23,7 +23,7 @@ import { TELEMETRY_INTEGRATION_CARD, } from './constants'; import type { GetAppUrl, NavigateTo } from '../../../../../common/lib/kibana'; -import { trackOnboardingLinkClick } from '../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../lib/telemetry'; const addPathParamToUrl = (url: string, onboardingLink: string) => { const encoded = encodeURIComponent(onboardingLink); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx new file mode 100644 index 0000000000000..127e6b4d57ebd --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx @@ -0,0 +1,82 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiText, + useEuiTheme, + COLOR_MODES_STANDARD, +} from '@elastic/eui'; +import { useKibana } from '../../../../../../common/lib/kibana/kibana_react'; +import { useDefinedLocalStorage } from '../../../../hooks/use_stored_state'; +import type { OnboardingCardComponent } from '../../../../../types'; +import * as i18n from './translations'; +import { OnboardingCardContentPanel } from '../../common/card_content_panel'; +import { ConnectorCards } from '../../common/connectors/connector_cards'; +import type { AIConnectorCardMetadata } from './types'; +import { MissingPrivilegesCallOut } from '../../common/connectors/missing_privileges'; + +export const AIConnectorCard: OnboardingCardComponent = ({ + checkCompleteMetadata, + checkComplete, + setComplete, +}) => { + const { siemMigrations } = useKibana().services; + const { euiTheme, colorMode } = useEuiTheme(); + const isDarkMode = colorMode === COLOR_MODES_STANDARD.dark; + + const [storedConnectorId, setStoredConnectorId] = useDefinedLocalStorage( + siemMigrations.rules.connectorIdStorage.key, + null + ); + const setSelectedConnectorId = useCallback( + (connectorId: string) => { + setStoredConnectorId(connectorId); + setComplete(true); + }, + [setComplete, setStoredConnectorId] + ); + + const connectors = checkCompleteMetadata?.connectors; + const canExecuteConnectors = checkCompleteMetadata?.canExecuteConnectors; + const canCreateConnectors = checkCompleteMetadata?.canCreateConnectors; + + return ( + + {canExecuteConnectors ? ( + + + + {i18n.AI_CONNECTOR_CARD_DESCRIPTION} + + + + + + + ) : ( + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default AIConnectorCard; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/connectors_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/connectors_check_complete.ts new file mode 100644 index 0000000000000..d7121fe97cf7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/connectors_check_complete.ts @@ -0,0 +1,45 @@ +/* + * 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 { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; +import type { AIConnector } from '@kbn/elastic-assistant/impl/connectorland/connector_selector'; +import type { OnboardingCardCheckComplete } from '../../../../../types'; +import { AIActionTypeIds } from '../../common/connectors/constants'; +import type { AIConnectorCardMetadata } from './types'; + +export const checkAssistantCardComplete: OnboardingCardCheckComplete< + AIConnectorCardMetadata +> = async ({ http, application, siemMigrations }) => { + let isComplete = false; + const allConnectors = await loadConnectors({ http }); + const { capabilities } = application; + + const aiConnectors = allConnectors.reduce((acc: AIConnector[], connector) => { + if (!connector.isMissingSecrets && AIActionTypeIds.includes(connector.actionTypeId)) { + acc.push(connector); + } + return acc; + }, []); + + const storedConnectorId = siemMigrations.rules.connectorIdStorage.get(); + if (storedConnectorId) { + if (aiConnectors.length === 0) { + siemMigrations.rules.connectorIdStorage.remove(); + } else { + isComplete = aiConnectors.some((connector) => connector.id === storedConnectorId); + } + } + + return { + isComplete, + metadata: { + connectors: aiConnectors, + canExecuteConnectors: Boolean(capabilities.actions?.show && capabilities.actions?.execute), + canCreateConnectors: Boolean(capabilities.actions?.save), + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts new file mode 100644 index 0000000000000..45080123889d5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { AssistantAvatar } from '@kbn/elastic-assistant'; +import type { OnboardingCardConfig } from '../../../../../types'; +import { OnboardingCardId } from '../../../../../constants'; +import { AI_CONNECTOR_CARD_TITLE } from './translations'; +import { checkAssistantCardComplete } from './connectors_check_complete'; +import type { AIConnectorCardMetadata } from './types'; + +export const aiConnectorCardConfig: OnboardingCardConfig = { + id: OnboardingCardId.siemMigrationsAiConnectors, + title: AI_CONNECTOR_CARD_TITLE, + icon: AssistantAvatar, + Component: React.lazy( + () => + import( + /* webpackChunkName: "onboarding_siem_migrations_ai_connector_card" */ + './ai_connector_card' + ) + ), + checkComplete: checkAssistantCardComplete, + licenseTypeRequired: 'enterprise', +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/translations.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/translations.ts new file mode 100644 index 0000000000000..c05951e1ddf27 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/translations.ts @@ -0,0 +1,23 @@ +/* + * 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 AI_CONNECTOR_CARD_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.aiConnector.title', + { + defaultMessage: 'Configure AI Provider', + } +); + +export const AI_CONNECTOR_CARD_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.aiConnector.description', + { + defaultMessage: + 'Choose and configure any AI provider available to start a SIEM rules migration.', + } +); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/types.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/types.ts new file mode 100644 index 0000000000000..3e0a471da6f5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/types.ts @@ -0,0 +1,14 @@ +/* + * 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 { ActionConnector } from '@kbn/alerts-ui-shared'; + +export interface AIConnectorCardMetadata { + connectors: ActionConnector[]; + canExecuteConnectors: boolean; + canCreateConnectors: boolean; +} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts index 775ff09546fe6..c2c89594669c9 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts @@ -6,112 +6,59 @@ */ import { renderHook } from '@testing-library/react-hooks'; import { useBodyConfig } from './use_body_config'; -import { useKibana } from '../../../../common/lib/kibana/kibana_react'; -import useObservable from 'react-use/lib/useObservable'; -import { hasCapabilities } from '../../../../common/lib/capabilities'; +import { mockOnboardingContext, onboardingContext } from '../../__mocks__/mocks'; -const bodyConfig = [ - { - title: 'Group 1', - cards: [ - { - id: 'license_card', - title: 'licensed card', - icon: 'fleetApp', - licenseType: 'platinum', - }, - { - id: 'capabilities_card', - title: 'rbac card', - icon: 'fleetApp', - capabilities: ['siem.crud'], - }, - ], - }, - { - title: 'Group 2', - cards: [ - { - id: 'capabilities_license_card', - title: 'all card', - icon: 'fleetApp', - capabilities: ['siem.crud'], - licenseType: 'platinum', - }, - ], - }, -]; +const topicId = 'topic-id'; +const mockUseTopicId = jest.fn(() => topicId); +jest.mock('../../hooks/use_topic_id', () => ({ + useTopicId: () => mockUseTopicId(), +})); -// Mock dependencies -jest.mock('react-use/lib/useObservable'); -jest.mock('../../../../common/lib/kibana/kibana_react'); -jest.mock('../../../../common/lib/capabilities'); -jest.mock('../body_config', () => ({ bodyConfig })); +const defaultBodyConfig = [{ title: 'Default Group 1', cards: [] }]; +const bodyConfig = [{ title: 'Group 1', cards: [] }]; +const config = new Map([ + ['default', { body: defaultBodyConfig }], + [topicId, { body: bodyConfig }], +]); -const mockLicenseHasAtLeast = jest.fn(); -const mockUseObservable = useObservable as jest.Mock; -const mockHasCapabilities = hasCapabilities as jest.Mock; -mockUseObservable.mockReturnValue({ hasAtLeast: mockLicenseHasAtLeast }); - -(useKibana as jest.Mock).mockReturnValue({ - services: { application: { capabilities: {} }, licensing: {} }, -}); +jest.mock('../../onboarding_context'); describe('useBodyConfig', () => { beforeEach(() => { - mockLicenseHasAtLeast.mockReturnValue(true); - mockHasCapabilities.mockReturnValue(true); jest.clearAllMocks(); }); - it('should return an empty array if license is not defined', () => { - mockUseObservable.mockReturnValueOnce(undefined); - const { result } = renderHook(useBodyConfig); - expect(result.current).toEqual([]); - }); + describe('when the selected topic does not have a body config', () => { + beforeEach(() => { + mockOnboardingContext.mockReturnValue({ ...onboardingContext, config: new Map() }); + }); - it('should return all cards if no capabilities or licenseType are filtered', () => { - const { result } = renderHook(useBodyConfig); - expect(result.current).toEqual(bodyConfig); + it('should return an empty array', () => { + const { result } = renderHook(() => useBodyConfig()); + expect(result.current).toEqual([]); + }); }); - it('should filter out cards based on license', () => { - mockLicenseHasAtLeast.mockReturnValue(false); + describe('when the selected topic has a body config', () => { + beforeEach(() => { + mockOnboardingContext.mockReturnValue({ ...onboardingContext, config }); + }); - const { result } = renderHook(useBodyConfig); - - expect(result.current).toEqual([ - { - title: 'Group 1', - cards: [ - { - id: 'capabilities_card', - title: 'rbac card', - icon: 'fleetApp', - capabilities: ['siem.crud'], - }, - ], - }, - ]); + it('should return the body config for the selected topic', () => { + const { result } = renderHook(() => useBodyConfig()); + expect(result.current).toEqual(bodyConfig); + }); }); - it('should filter out cards based on capabilities', () => { - mockHasCapabilities.mockReturnValue(false); - - const { result } = renderHook(useBodyConfig); + describe('when the selected topic does not exist (not expected)', () => { + beforeEach(() => { + mockUseTopicId.mockReturnValue('non-existent-topic'); + mockOnboardingContext.mockReturnValue({ ...onboardingContext, config }); + }); - expect(result.current).toEqual([ - { - title: 'Group 1', - cards: [ - { - id: 'license_card', - title: 'licensed card', - icon: 'fleetApp', - licenseType: 'platinum', - }, - ], - }, - ]); + it('should return the body config for the selected topic', () => { + const { result } = renderHook(() => useBodyConfig()); + expect(result.current).toEqual([]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts index f7b12e5988c0d..0d6a26a3439d6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts @@ -5,51 +5,26 @@ * 2.0. */ -import useObservable from 'react-use/lib/useObservable'; import { useMemo } from 'react'; -import { hasCapabilities } from '../../../../common/lib/capabilities'; -import { useKibana } from '../../../../common/lib/kibana/kibana_react'; -import { bodyConfig } from '../body_config'; +import { useOnboardingContext } from '../../onboarding_context'; +import { useTopicId } from '../../hooks/use_topic_id'; import type { OnboardingGroupConfig } from '../../../types'; /** - * Hook that filters the config based on the user's capabilities and license + * Hook that returns the body config for the selected topic */ -export const useBodyConfig = () => { - const { application, licensing } = useKibana().services; - const license = useObservable(licensing.license$); - - const filteredBodyConfig = useMemo(() => { - // Return empty array when the license is not defined. It should always become defined at some point. - // This exit case prevents code dependant on the cards config (like completion checks) from running multiple times. - if (!license) { - return []; +export const useBodyConfig = (): OnboardingGroupConfig[] => { + const topicId = useTopicId(); + const { config } = useOnboardingContext(); + const topicBodyConfig = useMemo(() => { + let bodyConfig: OnboardingGroupConfig[] = []; + const topicConfig = config.get(topicId); + // The selected topic should always exist in the config, but we check just in case + if (topicConfig) { + bodyConfig = topicConfig.body; } - return bodyConfig.reduce((filteredGroups, group) => { - const filteredCards = group.cards.filter((card) => { - if (card.capabilities) { - const cardHasCapabilities = hasCapabilities(application.capabilities, card.capabilities); - if (!cardHasCapabilities) { - return false; - } - } - - if (card.licenseType) { - const cardHasLicense = license.hasAtLeast(card.licenseType); - if (!cardHasLicense) { - return false; - } - } - - return true; - }); - - if (filteredCards.length > 0) { - filteredGroups.push({ ...group, cards: filteredCards }); - } - return filteredGroups; - }, []); - }, [license, application.capabilities]); + return bodyConfig; + }, [config, topicId]); - return filteredBodyConfig; + return topicBodyConfig; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.test.ts index 2c9fcd573f0d6..1ace059a5115e 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.test.ts @@ -6,11 +6,10 @@ */ import { renderHook, act, type RenderHookResult } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/react'; import { useCompletedCards } from './use_completed_cards'; import type { OnboardingGroupConfig } from '../../../types'; import type { OnboardingCardId } from '../../../constants'; -import { mockReportCardComplete } from '../../__mocks__/onboarding_context_mocks'; +import { mockReportCardComplete } from '../../__mocks__/mocks'; import { useKibana } from '../../../../common/lib/kibana'; const defaultStoredCompletedCardIds: OnboardingCardId[] = []; @@ -20,8 +19,8 @@ const mockUseStoredCompletedCardIds = jest.fn(() => [ defaultStoredCompletedCardIds, mockSetStoredCompletedCardIds, ]); -jest.mock('../../../hooks/use_stored_state', () => ({ - ...jest.requireActual('../../../hooks/use_stored_state'), +jest.mock('../../hooks/use_stored_state', () => ({ + ...jest.requireActual('../../hooks/use_stored_state'), useStoredCompletedCardIds: () => mockUseStoredCompletedCardIds(), })); @@ -99,6 +98,8 @@ const mockFailureCardsGroupConfig = [ }, ] as unknown as OnboardingGroupConfig[]; +const flushPromises = () => new Promise(setImmediate); + describe('useCompletedCards Hook', () => { beforeEach(() => { jest.clearAllMocks(); @@ -114,11 +115,7 @@ describe('useCompletedCards Hook', () => { services: { notifications: { toasts: { addError: mockAddError } } }, }); renderResult = renderHook(useCompletedCards, { initialProps: mockFailureCardsGroupConfig }); - await act(async () => { - await waitFor(() => { - expect(mockSetStoredCompletedCardIds).toHaveBeenCalledTimes(0); // number of completed cards - }); - }); + await act(flushPromises); }); describe('when a the auto check is called', () => { @@ -158,11 +155,7 @@ describe('useCompletedCards Hook', () => { >; beforeEach(async () => { renderResult = renderHook(useCompletedCards, { initialProps: mockCardsGroupConfig }); - await act(async () => { - await waitFor(() => { - expect(mockSetStoredCompletedCardIds).toHaveBeenCalledTimes(4); // number of completed cards - }); - }); + await act(flushPromises); }); it('should set the correct completed card ids', async () => { @@ -258,12 +251,8 @@ describe('useCompletedCards Hook', () => { beforeEach(async () => { jest.clearAllMocks(); cardIncomplete.checkComplete.mockResolvedValueOnce(true); - await act(async () => { - renderResult.result.current.checkCardComplete(cardIncomplete.id); - await waitFor(() => { - expect(mockSetStoredCompletedCardIds).toHaveBeenCalledTimes(1); - }); - }); + renderResult.result.current.checkCardComplete(cardIncomplete.id); + await act(flushPromises); }); it('should set the correct completed card ids', async () => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts index 34092bf2d5eec..8f3bcf0b618d6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts @@ -5,15 +5,15 @@ * 2.0. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useKibana } from '../../../../common/lib/kibana'; -import { useStoredCompletedCardIds } from '../../../hooks/use_stored_state'; +import { useStoredCompletedCardIds } from '../../hooks/use_stored_state'; import type { OnboardingCardId } from '../../../constants'; import type { CheckCompleteResult, CheckCompleteResponse, - OnboardingGroupConfig, OnboardingCardConfig, + OnboardingGroupConfig, } from '../../../types'; import { useOnboardingContext } from '../../onboarding_context'; @@ -32,10 +32,9 @@ export type CardCheckCompleteResult = Partial { - const { spaceId, reportCardComplete } = useOnboardingContext(); +export const useCompletedCards = (bodyConfig: OnboardingGroupConfig[]) => { + const { spaceId, telemetry } = useOnboardingContext(); const services = useKibana().services; - const autoCheckCompletedRef = useRef(false); // Use stored state to keep localStorage in sync, and a local state to avoid unnecessary re-renders. const [storedCompleteCardIds, setStoredCompleteCardIds] = useStoredCompletedCardIds(spaceId); @@ -55,7 +54,7 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => const isCurrentlyComplete = currentCompleteCards.includes(cardId); if (completed && !isCurrentlyComplete) { const newCompleteCardIds = [...currentCompleteCards, cardId]; - reportCardComplete(cardId, options); + telemetry.reportCardComplete(cardId, options); setStoredCompleteCardIds(newCompleteCardIds); // Keep the stored state in sync with the local state return newCompleteCardIds; } else if (!completed && isCurrentlyComplete) { @@ -66,7 +65,7 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => return currentCompleteCards; // No change }); }, - [reportCardComplete, setStoredCompleteCardIds] // static dependencies, this function needs to be stable + [setStoredCompleteCardIds, telemetry] // static dependencies, this function needs to be stable ); const getCardCheckCompleteResult = useCallback( @@ -88,11 +87,11 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => // Internal: stores all cards that have a checkComplete function in a flat array const cardsWithAutoCheck = useMemo( () => - cardsGroupConfig.reduce((acc, group) => { + bodyConfig.reduce((acc, group) => { acc.push(...group.cards.filter((card) => card.checkComplete)); return acc; }, []), - [cardsGroupConfig] + [bodyConfig] ); // Internal: sets the result of a checkComplete function @@ -118,9 +117,7 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => .checkComplete?.(services) .catch((err: Error) => { services.notifications.toasts.addError(err, { title: cardConfig.title }); - return { - isComplete: false, - }; + return { isComplete: false }; }) .then((checkCompleteResult) => { processCardCheckCompleteResult(cardId, checkCompleteResult); @@ -131,19 +128,13 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => ); useEffect(() => { - // Initial auto-check for all cards, it should run only once, after cardsGroupConfig is properly populated - if (cardsWithAutoCheck.length === 0 || autoCheckCompletedRef.current) { - return; - } - autoCheckCompletedRef.current = true; + // Initial auto-check for all body cards, it should run once per `bodyConfig` (topic) change. cardsWithAutoCheck.map((card) => card .checkComplete?.(services) .catch((err: Error) => { services.notifications.toasts.addError(err, { title: card.title }); - return { - isComplete: false, - }; + return { isComplete: false }; }) .then((checkCompleteResult) => { processCardCheckCompleteResult(card.id, checkCompleteResult); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.test.ts index 55f60e591c17d..26612d83b565f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.test.ts @@ -9,16 +9,14 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useExpandedCard } from './use_expanded_card'; import { HEIGHT_ANIMATION_DURATION } from '../onboarding_card_panel.styles'; import type { OnboardingCardId } from '../../../constants'; -import { mockReportCardOpen } from '../../__mocks__/onboarding_context_mocks'; import { waitFor } from '@testing-library/react'; const scrollTimeout = HEIGHT_ANIMATION_DURATION + 50; -const mockSetStorageExpandedCardId = jest.fn(); -const mockUseStoredExpandedCardId = jest.fn(() => [null, mockSetStorageExpandedCardId]); -jest.mock('../../../hooks/use_stored_state', () => ({ - ...jest.requireActual('../../../hooks/use_stored_state'), - useStoredExpandedCardId: () => mockUseStoredExpandedCardId(), +const mockSetCardDetail = jest.fn(); +jest.mock('../../hooks/use_url_detail', () => ({ + ...jest.requireActual('../../hooks/use_url_detail'), + useUrlDetail: () => ({ setCardDetail: mockSetCardDetail }), })); jest.mock('react-router-dom', () => ({ @@ -26,14 +24,10 @@ jest.mock('react-router-dom', () => ({ useLocation: () => ({ hash: '#card-1', pathname: '/test' }), })); -jest.mock('../../onboarding_context'); - describe('useExpandedCard Hook', () => { const mockCardId = 'card-1' as OnboardingCardId; const mockScrollTo = jest.fn(); global.window.scrollTo = mockScrollTo; - const mockReplaceState = jest.fn(); - global.history.replaceState = mockReplaceState; const mockGetElementById = jest.fn().mockReturnValue({ focus: jest.fn(), @@ -45,40 +39,11 @@ describe('useExpandedCard Hook', () => { jest.clearAllMocks(); }); - describe('when the page is loading', () => { - beforeEach(() => { - Object.defineProperty(document, 'readyState', { - value: 'loading', - configurable: true, - }); - }); - - it('should not scroll if the page is not fully loaded', async () => { - renderHook(useExpandedCard); - - // Ensure that scroll and focus were triggered - await waitFor( - () => { - expect(mockScrollTo).not.toHaveBeenCalled(); - }, - { timeout: scrollTimeout } - ); - }); - }); - describe('when the page is completely loaded', () => { beforeEach(() => { - Object.defineProperty(document, 'readyState', { - value: 'complete', - configurable: true, - }); renderHook(useExpandedCard); }); - it('should set the expanded card id from the hash', () => { - expect(mockSetStorageExpandedCardId).toHaveBeenCalledWith(mockCardId); - }); - it('should scroll to the expanded card id from the hash', async () => { // Ensure that scroll and focus were triggered await waitFor( @@ -89,10 +54,6 @@ describe('useExpandedCard Hook', () => { { timeout: scrollTimeout } ); }); - - it('should report the expanded card id from the hash', () => { - expect(mockReportCardOpen).toHaveBeenCalledWith(mockCardId, { auto: true }); - }); }); describe('when the card is expanded manually', () => { @@ -111,12 +72,8 @@ describe('useExpandedCard Hook', () => { }); }); - it('should set the expanded card id in storage', () => { - expect(mockSetStorageExpandedCardId).toHaveBeenCalledWith(mockCardId); - }); - - it('should set the URL hash', () => { - expect(mockReplaceState).toHaveBeenCalledWith(null, '', `#${mockCardId}`); + it('should set the expanded card id', () => { + expect(mockSetCardDetail).toHaveBeenCalledWith(mockCardId); }); it('should not scroll', async () => { @@ -129,10 +86,6 @@ describe('useExpandedCard Hook', () => { { timeout: scrollTimeout } ); }); - - it('should report the expanded card id', () => { - expect(mockReportCardOpen).toHaveBeenCalledWith(mockCardId); - }); }); describe('when scroll is enabled', () => { @@ -143,12 +96,8 @@ describe('useExpandedCard Hook', () => { }); }); - it('should set the expanded card id in storage', () => { - expect(mockSetStorageExpandedCardId).toHaveBeenCalledWith(mockCardId); - }); - - it('should set the URL hash', () => { - expect(mockReplaceState).toHaveBeenCalledWith(null, '', `#${mockCardId}`); + it('should set the expanded card id', () => { + expect(mockSetCardDetail).toHaveBeenCalledWith(mockCardId); }); it('should scroll', async () => { @@ -161,10 +110,6 @@ describe('useExpandedCard Hook', () => { { timeout: scrollTimeout } ); }); - - it('should report the expanded card id', () => { - expect(mockReportCardOpen).toHaveBeenCalledWith(mockCardId); - }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.ts index 131953e4b0687..514618390695c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_expanded_card.ts @@ -5,13 +5,12 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { useStoredExpandedCardId } from '../../../hooks/use_stored_state'; import { HEIGHT_ANIMATION_DURATION } from '../onboarding_card_panel.styles'; -import type { OnboardingCardId } from '../../../constants'; +import { type OnboardingCardId } from '../../../constants'; import type { SetExpandedCardId } from '../../../types'; -import { useOnboardingContext } from '../../onboarding_context'; +import { getCardIdFromHash, useUrlDetail } from '../../hooks/use_url_detail'; const HEADER_OFFSET = 40; @@ -25,59 +24,36 @@ const scrollToCard = (cardId: OnboardingCardId) => { }, HEIGHT_ANIMATION_DURATION); }; -const setHash = (cardId: OnboardingCardId | null) => { - history.replaceState(null, '', cardId == null ? ' ' : `#${cardId}`); -}; - /** * This hook manages the expanded card id state in the LocalStorage and the hash in the URL. */ export const useExpandedCard = () => { - const { spaceId, reportCardOpen } = useOnboardingContext(); - const [expandedCardId, setStorageExpandedCardId] = useStoredExpandedCardId(spaceId); - const location = useLocation(); - - const [documentReadyState, setReadyState] = useState(document.readyState); + const { setCardDetail } = useUrlDetail(); + const { hash } = useLocation(); + const cardIdFromHash = useMemo(() => getCardIdFromHash(hash), [hash]); - useEffect(() => { - const readyStateListener = () => setReadyState(document.readyState); - document.addEventListener('readystatechange', readyStateListener); - return () => document.removeEventListener('readystatechange', readyStateListener); - }, []); + const [cardId, setCardId] = useState(null); - // This effect implements auto-scroll in the initial render, further changes in the hash should not trigger this effect + // This effect implements auto-scroll in the initial render. useEffect(() => { - if (documentReadyState !== 'complete') return; // Wait for page to finish loading before scrolling - let cardIdFromHash = location.hash.split('?')[0].replace('#', '') as OnboardingCardId | ''; - if (!cardIdFromHash) { - if (expandedCardId == null) return; - // If the hash is empty, but it is defined the storage we use the storage value - cardIdFromHash = expandedCardId; - setHash(cardIdFromHash); - } - - // If the hash is defined and different from the storage, the hash takes precedence - if (expandedCardId !== cardIdFromHash) { - setStorageExpandedCardId(cardIdFromHash); - reportCardOpen(cardIdFromHash, { auto: true }); + if (cardIdFromHash) { + setCardId(cardIdFromHash); + scrollToCard(cardIdFromHash); } - scrollToCard(cardIdFromHash); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [documentReadyState]); + // cardIdFromHash is only defined once on page load + // it does not change with subsequent url hash changes since history.replaceState is used + }, [cardIdFromHash]); const setExpandedCardId = useCallback( - (cardId, options) => { - setStorageExpandedCardId(cardId); - setHash(cardId); - if (cardId != null) { - reportCardOpen(cardId); - if (options?.scroll) { - scrollToCard(cardId); - } + (newCardId, options) => { + setCardId(newCardId); + setCardDetail(newCardId); + if (newCardId != null && options?.scroll) { + scrollToCard(newCardId); } }, - [setStorageExpandedCardId, reportCardOpen] + [setCardDetail] ); - return { expandedCardId, setExpandedCardId }; + return { expandedCardId: cardId, setExpandedCardId }; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx index 3209028e1f0cd..0b55db750c080 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx @@ -17,7 +17,6 @@ import { useCompletedCards } from './hooks/use_completed_cards'; export const OnboardingBody = React.memo(() => { const bodyConfig = useBodyConfig(); - const { expandedCardId, setExpandedCardId } = useExpandedCard(); const { isCardComplete, setCardComplete, getCardCheckCompleteResult, checkCardComplete } = useCompletedCards(bodyConfig); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.test.tsx new file mode 100644 index 0000000000000..cb395996903be --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.test.tsx @@ -0,0 +1,161 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { OnboardingContextProvider, useOnboardingContext } from './onboarding_context'; +import { useLicense } from '../../common/hooks/use_license'; +import { hasCapabilities } from '../../common/lib/capabilities'; +import { ExperimentalFeaturesService } from '../../common/experimental_features_service'; + +jest.mock('../../common/lib/kibana/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ services: { application: { capabilities: {} } } }), +})); +jest.mock('../../common/lib/capabilities', () => ({ hasCapabilities: jest.fn() })); +const mockHasCapabilities = hasCapabilities as jest.Mock; + +jest.mock('../../common/hooks/use_license', () => ({ useLicense: jest.fn() })); +const mockUseLicense = useLicense as jest.Mock; + +jest.mock('../../common/experimental_features_service', () => ({ + ExperimentalFeaturesService: { get: jest.fn() }, +})); +const mockExperimentalFeatures = ExperimentalFeaturesService.get as jest.Mock; + +jest.mock('../config', () => ({ + onboardingConfig: [ + { + id: 'default', + body: [ + { + id: 'defaultGroup1', + cards: [{ id: 'defaultCard1' }], + }, + ], + }, + { + id: 'topic1', + experimentalFlagRequired: 'flag1', + licenseTypeRequired: 'gold', + capabilitiesRequired: ['capability1'], + body: [ + { + id: 'topic1Group1', + cards: [{ id: 'topic1Card1' }], + }, + ], + }, + { + id: 'topic2', + body: [ + { + id: 'topic2Group1', + cards: [ + { id: 'topic2Card1', experimentalFlagRequired: 'flag1' }, + { id: 'topic2Card2', licenseTypeRequired: 'gold' }, + { id: 'topic2Card3', capabilitiesRequired: ['capability1'] }, + ], + }, + ], + }, + ], +})); + +const wrapper: React.FC> = ({ children }) => ( + {children} +); + +describe('OnboardingContextProvider', () => { + describe('config', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExperimentalFeatures.mockReturnValue({ flag1: true }); + mockUseLicense.mockReturnValue({ isAtLeast: jest.fn(() => true) }); + mockHasCapabilities.mockReturnValue(true); + }); + + describe('when all requirements are met', () => { + it('should return all topics config correctly', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(3); + expect(result.current.config).toMatchSnapshot(); + }); + }); + + describe('when the required experimental flag is not met', () => { + beforeEach(() => { + mockExperimentalFeatures.mockReturnValue({}); + }); + + it('should filter the topics config correctly', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(2); + expect(result.current.config).toMatchSnapshot(); + }); + + describe('and the required license is not met either', () => { + beforeEach(() => { + mockUseLicense.mockReturnValue({ isAtLeast: jest.fn(() => false) }); + }); + + it('should filter the topics config correctly', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(2); + expect(result.current.config).toMatchSnapshot(); + }); + + describe('and the required capabilities are not met either', () => { + beforeEach(() => { + mockHasCapabilities.mockReturnValue(false); + }); + + it('should return only the default topics config', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(1); + expect(result.current.config).toMatchSnapshot(); + }); + }); + }); + }); + + describe('when the required license is not met', () => { + beforeEach(() => { + mockUseLicense.mockReturnValue({ isAtLeast: jest.fn(() => false) }); + }); + + it('should filter the topics config correctly', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(2); + expect(result.current.config).toMatchSnapshot(); + }); + + describe('and the required capabilities are not met either', () => { + beforeEach(() => { + mockHasCapabilities.mockReturnValue(false); + }); + + it('should filter the topics config correctly', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(2); + expect(result.current.config).toMatchSnapshot(); + }); + }); + }); + + describe('when the required capabilities are not met', () => { + beforeEach(() => { + mockHasCapabilities.mockReturnValue(false); + }); + + it('should filter the topics config correctly', () => { + const { result } = renderHook(useOnboardingContext, { wrapper }); + expect(result.current.config.size).toEqual(2); + expect(result.current.config).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.tsx index 2a6597628a26d..17932207c6271 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_context.tsx @@ -6,46 +6,43 @@ */ import type { PropsWithChildren } from 'react'; -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useCallback, useContext, useMemo } from 'react'; import { useKibana } from '../../common/lib/kibana/kibana_react'; -import type { OnboardingCardId } from '../constants'; +import type { OnboardingTopicId, OnboardingCardId } from '../constants'; import { OnboardingHubEventTypes } from '../../common/lib/telemetry'; +import { useLicense } from '../../common/hooks/use_license'; +import { ExperimentalFeaturesService } from '../../common/experimental_features_service'; -export interface OnboardingContextValue { - spaceId: string; +import { hasCapabilities } from '../../common/lib/capabilities'; +import type { + OnboardingConfigAvailabilityProps, + OnboardingGroupConfig, + TopicConfig, +} from '../types'; +import { onboardingConfig } from '../config'; + +export interface OnboardingTelemetry { reportCardOpen: (cardId: OnboardingCardId, options?: { auto?: boolean }) => void; reportCardComplete: (cardId: OnboardingCardId, options?: { auto?: boolean }) => void; reportCardLinkClicked: (cardId: OnboardingCardId, linkId: string) => void; } + +export type OnboardingConfig = Map; +export interface OnboardingContextValue { + spaceId: string; + telemetry: OnboardingTelemetry; + config: OnboardingConfig; +} const OnboardingContext = createContext(null); export const OnboardingContextProvider: React.FC> = React.memo(({ children, spaceId }) => { - const { telemetry } = useKibana().services; + const config = useFilteredConfig(); + const telemetry = useOnboardingTelemetry(); const value = useMemo( - () => ({ - spaceId, - reportCardOpen: (cardId, { auto = false } = {}) => { - telemetry.reportEvent(OnboardingHubEventTypes.OnboardingHubStepOpen, { - stepId: cardId, - trigger: auto ? 'navigation' : 'click', - }); - }, - reportCardComplete: (cardId, { auto = false } = {}) => { - telemetry.reportEvent(OnboardingHubEventTypes.OnboardingHubStepFinished, { - stepId: cardId, - trigger: auto ? 'auto_check' : 'click', - }); - }, - reportCardLinkClicked: (cardId, linkId: string) => { - telemetry.reportEvent(OnboardingHubEventTypes.OnboardingHubStepLinkClicked, { - originStepId: cardId, - stepLinkId: linkId, - }); - }, - }), - [spaceId, telemetry] + () => ({ spaceId, telemetry, config }), + [spaceId, telemetry, config] ); return {children}; @@ -61,3 +58,82 @@ export const useOnboardingContext = () => { } return context; }; + +/** + * Hook that filters the config based on the user's capabilities, license and experimental features + */ +const useFilteredConfig = (): OnboardingConfig => { + const { capabilities } = useKibana().services.application; + const experimentalFeatures = ExperimentalFeaturesService.get(); + const license = useLicense(); + + const isAvailable = useCallback( + (item: OnboardingConfigAvailabilityProps) => { + if (item.experimentalFlagRequired && !experimentalFeatures[item.experimentalFlagRequired]) { + return false; + } + if (item.licenseTypeRequired && !license.isAtLeast(item.licenseTypeRequired)) { + return false; + } + if (item.capabilitiesRequired && !hasCapabilities(capabilities, item.capabilitiesRequired)) { + return false; + } + return true; + }, + [license, capabilities, experimentalFeatures] + ); + + const filteredConfig = useMemo( + () => + onboardingConfig.reduce((filteredTopicConfigs, topicConfig) => { + if (!isAvailable(topicConfig)) { + return filteredTopicConfigs; + } + const filteredBody = topicConfig.body.reduce( + (filteredGroups, group) => { + const filteredCards = group.cards.filter(isAvailable); + + if (filteredCards.length > 0) { + filteredGroups.push({ ...group, cards: filteredCards }); + } + return filteredGroups; + }, + [] + ); + if (filteredBody.length > 0) { + filteredTopicConfigs.set(topicConfig.id, { ...topicConfig, body: filteredBody }); + } + return filteredTopicConfigs; + }, new Map()), + [isAvailable] + ); + + return filteredConfig; +}; + +const useOnboardingTelemetry = (): OnboardingTelemetry => { + const { telemetry } = useKibana().services; + return useMemo( + () => ({ + reportCardOpen: (cardId, { auto = false } = {}) => { + telemetry.reportEvent(OnboardingHubEventTypes.OnboardingHubStepOpen, { + stepId: cardId, + trigger: auto ? 'navigation' : 'click', + }); + }, + reportCardComplete: (cardId, { auto = false } = {}) => { + telemetry.reportEvent(OnboardingHubEventTypes.OnboardingHubStepFinished, { + stepId: cardId, + trigger: auto ? 'auto_check' : 'click', + }); + }, + reportCardLinkClicked: (cardId, linkId: string) => { + telemetry.reportEvent(OnboardingHubEventTypes.OnboardingHubStepLinkClicked, { + originStepId: cardId, + stepLinkId: linkId, + }); + }, + }), + [telemetry] + ); +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.test.tsx index ae80d0c9273c3..2b663add12248 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.test.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { trackOnboardingLinkClick } from '../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../lib/telemetry'; import { FooterLinkItem } from './onboarding_footer'; import { OnboardingFooterLinkItemId, TELEMETRY_FOOTER_LINK } from './constants'; -jest.mock('../../common/lib/telemetry'); +jest.mock('../lib/telemetry'); describe('OnboardingFooterComponent', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.tsx index 125d2af118d3f..9db64386be067 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_footer/onboarding_footer.tsx @@ -9,7 +9,7 @@ import React, { useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { useFooterStyles } from './onboarding_footer.styles'; import { useFooterItems } from './footer_items'; -import { trackOnboardingLinkClick } from '../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../lib/telemetry'; import type { OnboardingFooterLinkItemId } from './constants'; import { TELEMETRY_FOOTER_LINK } from './constants'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.test.tsx index 83bfa317d8fbb..febc8431627b8 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.test.tsx @@ -8,10 +8,10 @@ import React from 'react'; import { render } from '@testing-library/react'; import { LinkCard } from './link_card'; -import { OnboardingHeaderCardId, TELEMETRY_HEADER_CARD } from '../../../constants'; -import { trackOnboardingLinkClick } from '../../../../common/lib/telemetry'; +import { OnboardingHeaderCardId, TELEMETRY_HEADER_CARD } from '../../constants'; +import { trackOnboardingLinkClick } from '../../../lib/telemetry'; -jest.mock('../../../../common/lib/telemetry'); +jest.mock('../../../lib/telemetry'); describe('DataIngestionHubHeaderCardComponent', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.tsx index 12b3877628dbc..71ab7b007b35f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/common/link_card.tsx @@ -8,10 +8,10 @@ import React, { useCallback } from 'react'; import { EuiCard, EuiImage, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import classNames from 'classnames'; -import { trackOnboardingLinkClick } from '../../../../common/lib/telemetry'; +import { trackOnboardingLinkClick } from '../../../lib/telemetry'; import { useCardStyles } from './link_card.styles'; -import type { OnboardingHeaderCardId } from '../../../constants'; -import { TELEMETRY_HEADER_CARD } from '../../../constants'; +import type { OnboardingHeaderCardId } from '../../constants'; +import { TELEMETRY_HEADER_CARD } from '../../constants'; interface LinkCardProps { id: OnboardingHeaderCardId; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/demo_card/demo_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/demo_card/demo_card.tsx index b86ae2dcd219d..9daf13527108d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/demo_card/demo_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/demo_card/demo_card.tsx @@ -10,7 +10,7 @@ import { LinkCard } from '../common/link_card'; import demoImage from './images/demo_card.png'; import darkDemoImage from './images/demo_card_dark.png'; import * as i18n from './translations'; -import { OnboardingHeaderCardId } from '../../../constants'; +import { OnboardingHeaderCardId } from '../../constants'; export const DemoCard = React.memo<{ isDarkMode: boolean }>(({ isDarkMode }) => { return ( diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx index 81e6ffb3657fe..0a425acd0a93f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx @@ -7,8 +7,8 @@ import React from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { OnboardingHeaderCardId } from '../../../constants'; -import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; +import { OnboardingHeaderCardId } from '../../constants'; +import { useOnboardingService } from '../../../hooks/use_onboarding_service'; import { LinkCard } from '../common/link_card'; import teammatesImage from './images/teammates_card.png'; import darkTeammatesImage from './images/teammates_card_dark.png'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/video_card/video_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/video_card/video_card.tsx index 2e91b7374c505..15a8950aed277 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/video_card/video_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/video_card/video_card.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback, useState } from 'react'; -import { OnboardingHeaderCardId } from '../../../constants'; +import { OnboardingHeaderCardId } from '../../constants'; import { OnboardingHeaderVideoModal } from './video_modal'; import * as i18n from './translations'; import videoImage from './images/video_card.png'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/constants.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/constants.ts similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/components/constants.ts rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/constants.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.styles.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.styles.ts index 34cc060a97386..40cfd7a5d9e1f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.styles.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.styles.ts @@ -18,5 +18,8 @@ export const useOnboardingHeaderStyles = () => { .onboardingHeaderGreetings { color: ${euiTheme.colors.darkShade}; } + .onboardingHeaderTopicSelector { + width: calc(${PAGE_CONTENT_WIDTH} / 3); + } `; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.tsx index 0210c88186a9a..1175c125e6a81 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header.tsx @@ -17,6 +17,7 @@ import { useEuiTheme, } from '@elastic/eui'; import { useCurrentUser } from '../../../common/lib/kibana/hooks'; +import { OnboardingHeaderTopicSelector } from './onboarding_header_topic_selector'; import { useOnboardingHeaderStyles } from './onboarding_header.styles'; import rocketImage from './images/header_rocket.png'; import rocketDarkImage from './images/header_rocket_dark.png'; @@ -42,23 +43,25 @@ export const OnboardingHeader = React.memo(() => { {currentUserName && ( - {i18n.GET_STARTED_PAGE_TITLE(currentUserName)} + {i18n.ONBOARDING_PAGE_TITLE(currentUserName)} )} -

{i18n.GET_STARTED_DATA_INGESTION_HUB_SUBTITLE}

+

{i18n.ONBOARDING_PAGE_SUBTITLE}

- {i18n.GET_STARTED_DATA_INGESTION_HUB_DESCRIPTION} + {i18n.ONBOARDING_PAGE_DESCRIPTION} + +
diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_topic_selector.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_topic_selector.tsx new file mode 100644 index 0000000000000..c949f51d23da1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/onboarding_header_topic_selector.tsx @@ -0,0 +1,44 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiButtonGroup } from '@elastic/eui'; +import type { OnboardingTopicId } from '../../constants'; +import { useOnboardingContext } from '../onboarding_context'; +import { useTopic } from '../hooks/use_topic_id'; + +export const OnboardingHeaderTopicSelector = React.memo(() => { + const { config } = useOnboardingContext(); + const [topicId, setTopicId] = useTopic(); + + const selectorOptions = useMemo( + () => + [...config.values()].map((topicConfig) => ({ + id: topicConfig.id, + label: topicConfig.title, + })), + [config] + ); + + if (selectorOptions.length < 2) { + return null; + } + + return ( + setTopicId(id as OnboardingTopicId)} + isFullWidth + /> + ); +}); +OnboardingHeaderTopicSelector.displayName = 'OnboardingHeaderTopicSelector'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/translations.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/translations.ts index c1f8ca8695bb6..62eadcdcd83a6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/translations.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/translations.ts @@ -7,22 +7,36 @@ import { i18n } from '@kbn/i18n'; -export const GET_STARTED_PAGE_TITLE = (userName: string) => +export const ONBOARDING_PAGE_TITLE = (userName: string) => i18n.translate('xpack.securitySolution.onboarding.Title', { defaultMessage: `Hi {userName}!`, values: { userName }, }); -export const GET_STARTED_DATA_INGESTION_HUB_SUBTITLE = i18n.translate( +export const ONBOARDING_PAGE_SUBTITLE = i18n.translate( 'xpack.securitySolution.onboarding.subTitle', { defaultMessage: `Welcome to Elastic Security`, } ); -export const GET_STARTED_DATA_INGESTION_HUB_DESCRIPTION = i18n.translate( +export const ONBOARDING_PAGE_DESCRIPTION = i18n.translate( 'xpack.securitySolution.onboarding.description', { defaultMessage: `A SIEM with AI-driven security analytics, XDR and Cloud Security.`, } ); + +export const ONBOARDING_PAGE_DEFAULT_TOPIC = i18n.translate( + 'xpack.securitySolution.onboarding.topic.default', + { + defaultMessage: 'Set up security', + } +); + +export const ONBOARDING_PAGE_SIEM_MIGRATIONS_TOPIC = i18n.translate( + 'xpack.securitySolution.onboarding.topic.siemMigrations', + { + defaultMessage: 'SIEM Rule migration', + } +); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_route.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_route.tsx new file mode 100644 index 0000000000000..6e7dca524ce81 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_route.tsx @@ -0,0 +1,38 @@ +/* + * 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, { useEffect } from 'react'; + +import type { RouteComponentProps } from 'react-router-dom'; +import { OnboardingHeader } from './onboarding_header'; +import { OnboardingBody } from './onboarding_body'; +import type { OnboardingRouteParams } from '../types'; +import { getCardIdFromHash, useUrlDetail } from './hooks/use_url_detail'; + +type OnboardingRouteProps = RouteComponentProps; + +export const OnboardingRoute = React.memo(({ match, location }) => { + const { syncUrlDetails } = useUrlDetail(); + + /** + * This effect syncs the URL details with the stored state, it only needs to be executed once per page load. + */ + useEffect(() => { + const pathTopicId = match.params.topicId || null; + const hashCardId = getCardIdFromHash(location.hash); + syncUrlDetails(pathTopicId, hashCardId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + ); +}); +OnboardingRoute.displayName = 'OnboardingContent'; diff --git a/x-pack/plugins/security_solution/public/onboarding/config.ts b/x-pack/plugins/security_solution/public/onboarding/config.ts new file mode 100644 index 0000000000000..a8f5909f9b059 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/config.ts @@ -0,0 +1,33 @@ +/* + * 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'; +import { OnboardingTopicId } from './constants'; +import { + defaultBodyConfig, + siemMigrationsBodyConfig, +} from './components/onboarding_body/body_config'; +import type { TopicConfig } from './types'; + +export const onboardingConfig: TopicConfig[] = [ + { + id: OnboardingTopicId.default, + title: i18n.translate('xpack.securitySolution.onboarding.topic.default', { + defaultMessage: 'Set up security', + }), + body: defaultBodyConfig, + }, + { + id: OnboardingTopicId.siemMigrations, + title: i18n.translate('xpack.securitySolution.onboarding.topic.siemMigrations', { + defaultMessage: 'SIEM Rule migration', + }), + body: siemMigrationsBodyConfig, + licenseTypeRequired: 'enterprise', + experimentalFlagRequired: 'siemMigrationsEnabled', + }, +]; diff --git a/x-pack/plugins/security_solution/public/onboarding/constants.ts b/x-pack/plugins/security_solution/public/onboarding/constants.ts index 0eb277bd61875..e360e4591bb37 100644 --- a/x-pack/plugins/security_solution/public/onboarding/constants.ts +++ b/x-pack/plugins/security_solution/public/onboarding/constants.ts @@ -6,6 +6,11 @@ */ export const PAGE_CONTENT_WIDTH = '1150px'; +export enum OnboardingTopicId { + default = 'default', + siemMigrations = 'siem_migrations', +} + export enum OnboardingCardId { integrations = 'integrations', dashboards = 'dashboards', @@ -13,4 +18,7 @@ export enum OnboardingCardId { alerts = 'alerts', assistant = 'assistant', attackDiscovery = 'attack_discovery', + + // siem_migrations topic cards + siemMigrationsAiConnectors = 'ai_connectors', } diff --git a/x-pack/plugins/security_solution/public/onboarding/types.ts b/x-pack/plugins/security_solution/public/onboarding/types.ts index 9dfe1e75596db..d79dd73ced799 100644 --- a/x-pack/plugins/security_solution/public/onboarding/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/types.ts @@ -9,7 +9,8 @@ import type React from 'react'; import type { IconType } from '@elastic/eui'; import type { LicenseType } from '@kbn/licensing-plugin/public'; -import type { OnboardingCardId } from './constants'; +import type { ExperimentalFeatures } from '../../common'; +import type { OnboardingTopicId, OnboardingCardId } from './constants'; import type { RequiredCapabilities } from '../common/lib/capabilities'; import type { StartServices } from '../types'; @@ -74,31 +75,17 @@ export type OnboardingCardCheckComplete = ( services: StartServices ) => Promise>; -export interface OnboardingCardConfig { - id: OnboardingCardId; - title: string; - icon: IconType; +export interface OnboardingConfigAvailabilityProps { /** - * Component that renders the card content when expanded. - * It receives a `setComplete` function to allow the card to mark itself as complete if needed. - * Please use React.lazy() to load the component. - */ - Component: React.LazyExoticComponent>; - /** - * Function for auto-checking completion for the card - * @returns Promise for the complete status - */ - checkComplete?: OnboardingCardCheckComplete; - /** - * The RBAC capability strings required to enable the card. It uses object dot notation. e.g. `'siem.crud'`. + * The RBAC capability strings required to enable the item. It uses object dot notation. e.g. `'siem.crud'`. * * The format of the capabilities property supports OR and AND mechanism: * * To specify capabilities in an OR fashion, they can be defined in a single level array like: `capabilities: [cap1, cap2]`. - * If either of "cap1 || cap2" is granted the card will be included. + * If either of "cap1 || cap2" is granted the item will be included. * * To specify capabilities with AND conditional, use a second level array: `capabilities: [['cap1', 'cap2']]`. - * This would result in the boolean expression "cap1 && cap2", both capabilities must be granted to include the card. + * This would result in the boolean expression "cap1 && cap2", both capabilities must be granted to include the item. * * They can also be combined like: `capabilities: ['cap1', ['cap2', 'cap3']]` which would result in the boolean expression "cap1 || (cap2 && cap3)". * @@ -106,12 +93,34 @@ export interface OnboardingCardConfig { * * Default is `undefined` (no capabilities required) */ - capabilities?: RequiredCapabilities; + capabilitiesRequired?: RequiredCapabilities; /** - * Minimum license required to enable the card. + * Minimum license required to enable the item. * Default is `basic` */ - licenseType?: LicenseType; + licenseTypeRequired?: LicenseType; + /** + * The experimental features required to enable the item. + */ + experimentalFlagRequired?: keyof ExperimentalFeatures; +} + +export interface OnboardingCardConfig + extends OnboardingConfigAvailabilityProps { + id: OnboardingCardId; + title: string; + icon: IconType; + /** + * Component that renders the card content when expanded. + * It receives a `setComplete` function to allow the card to mark itself as complete if needed. + * Please use React.lazy() to load the component. + */ + Component: React.LazyExoticComponent>; + /** + * Function for auto-checking completion for the card + * @returns Promise for the complete status + */ + checkComplete?: OnboardingCardCheckComplete; } export interface OnboardingGroupConfig { @@ -120,3 +129,19 @@ export interface OnboardingGroupConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any cards: Array>; } + +export interface TopicConfig extends OnboardingConfigAvailabilityProps { + id: OnboardingTopicId; + /** + * The onboarding topic title. + */ + title: string; + /** + * The onboarding body configuration. + */ + body: OnboardingGroupConfig[]; +} + +export interface OnboardingRouteParams { + topicId?: OnboardingTopicId; +} diff --git a/x-pack/plugins/security_solution/public/plugin_services.ts b/x-pack/plugins/security_solution/public/plugin_services.ts index 92b4bc586a5b6..cd066da31f549 100644 --- a/x-pack/plugins/security_solution/public/plugin_services.ts +++ b/x-pack/plugins/security_solution/public/plugin_services.ts @@ -153,7 +153,7 @@ export class PluginServices { customDataService, timelineDataService, topValuesPopover: new TopValuesPopoverService(), - siemMigrations: await createSiemMigrationsService(coreStart), + siemMigrations: await createSiemMigrationsService(coreStart, startPlugins), ...(params && { onAppLeave: params.onAppLeave, setHeaderActionMenu: params.setHeaderActionMenu, diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/api/api.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/api/api.ts index 7232cb722bd1a..f953a53c281f5 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/api/api.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/api/api.ts @@ -12,10 +12,12 @@ import { KibanaServices } from '../../../common/lib/kibana'; import { SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, SIEM_RULE_MIGRATION_PATH, + SIEM_RULE_MIGRATION_START_PATH, } from '../../../../common/siem_migrations/constants'; import type { GetAllStatsRuleMigrationResponse, GetRuleMigrationResponse, + StartRuleMigrationRequestBody, } from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; /** @@ -32,11 +34,31 @@ export const getRuleMigrationsStatsAll = async ({ }): Promise => { return KibanaServices.get().http.fetch( SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, - { - method: 'GET', - version: '1', - signal, - } + { method: 'GET', version: '1', signal } + ); +}; + +/** + * Starts a new migration with the provided rules. + * + * @param migrationId `id` of the migration to start + * @param body The body containing the `connectorId` to use for the migration + * @param signal AbortSignal for cancelling request + * + * @throws An error if response is not OK + */ +export const startRuleMigration = async ({ + migrationId, + body, + signal, +}: { + migrationId: string; + body: StartRuleMigrationRequestBody; + signal: AbortSignal | undefined; +}): Promise => { + return KibanaServices.get().http.put( + replaceParams(SIEM_RULE_MIGRATION_START_PATH, { migration_id: migrationId }), + { body: JSON.stringify(body), version: '1', signal } ); }; @@ -57,10 +79,6 @@ export const getRuleMigrations = async ({ }): Promise => { return KibanaServices.get().http.fetch( replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }), - { - method: 'GET', - version: '1', - signal, - } + { method: 'GET', version: '1', signal } ); }; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts index ba6543f5171d3..a872d79a46027 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts @@ -8,28 +8,35 @@ import { BehaviorSubject, type Observable } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { SiemMigrationTaskStatus } from '../../../../common/siem_migrations/constants'; +import type { StartPluginsDependencies } from '../../../types'; import { ExperimentalFeaturesService } from '../../../common/experimental_features_service'; import { licenseService } from '../../../common/hooks/use_license'; -import { getRuleMigrationsStatsAll } from '../api/api'; -import type { RuleMigrationStats } from '../types'; +import { getRuleMigrationsStatsAll, startRuleMigration } from '../api/api'; +import type { RuleMigrationTask } from '../types'; import { getSuccessToast } from './success_notification'; - -const POLLING_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.siemMigrations.rulesService.polling.errorTitle', - { defaultMessage: 'Error fetching rule migrations' } -); +import { RuleMigrationsStorage } from './storage'; export class SiemRulesMigrationsService { private readonly pollingInterval = 5000; - private readonly latestStats$: BehaviorSubject; + private readonly latestStats$: BehaviorSubject; + private readonly signal = new AbortController().signal; private isPolling = false; + public connectorIdStorage = new RuleMigrationsStorage('connectorId'); + + constructor( + private readonly core: CoreStart, + private readonly plugins: StartPluginsDependencies + ) { + this.latestStats$ = new BehaviorSubject([]); - constructor(private readonly core: CoreStart) { - this.latestStats$ = new BehaviorSubject([]); - this.startPolling(); + this.plugins.spaces.getActiveSpace().then((space) => { + this.connectorIdStorage.setSpaceId(space.id); + this.startPolling(); + }); } - public getLatestStats$(): Observable { + public getLatestStats$(): Observable { return this.latestStats$.asObservable(); } @@ -45,7 +52,12 @@ export class SiemRulesMigrationsService { this.isPolling = true; this.startStatsPolling() .catch((e) => { - this.core.notifications.toasts.addError(e, { title: POLLING_ERROR_TITLE }); + this.core.notifications.toasts.addError(e, { + title: i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.polling.errorTitle', + { defaultMessage: 'Error fetching rule migrations' } + ), + }); }) .finally(() => { this.isPolling = false; @@ -55,33 +67,46 @@ export class SiemRulesMigrationsService { private async startStatsPolling(): Promise { let pendingMigrationIds: string[] = []; do { - const results = await this.fetchRuleMigrationsStats(); + const results = await this.fetchRuleMigrationTasksStats(); this.latestStats$.next(results); if (pendingMigrationIds.length > 0) { // send notifications for finished migrations pendingMigrationIds.forEach((pendingMigrationId) => { const migration = results.find((item) => item.id === pendingMigrationId); - if (migration && migration.status === 'finished') { + if (migration?.status === SiemMigrationTaskStatus.FINISHED) { this.core.notifications.toasts.addSuccess(getSuccessToast(migration, this.core)); } }); } - // reassign pending migrations - pendingMigrationIds = results.reduce((acc, item) => { - if (item.status === 'running') { - acc.push(item.id); + // reprocess pending migrations + pendingMigrationIds = []; + for (const result of results) { + if (result.status === SiemMigrationTaskStatus.RUNNING) { + pendingMigrationIds.push(result.id); } - return acc; - }, []); + + if (result.status === SiemMigrationTaskStatus.STOPPED) { + const connectorId = this.connectorIdStorage.get(); + if (connectorId) { + // automatically resume stopped migrations when connector is available + await startRuleMigration({ + migrationId: result.id, + body: { connector_id: connectorId }, + signal: this.signal, + }); + pendingMigrationIds.push(result.id); + } + } + } await new Promise((resolve) => setTimeout(resolve, this.pollingInterval)); } while (pendingMigrationIds.length > 0); } - private async fetchRuleMigrationsStats(): Promise { - const stats = await getRuleMigrationsStatsAll({ signal: new AbortController().signal }); + private async fetchRuleMigrationTasksStats(): Promise { + const stats = await getRuleMigrationsStatsAll({ signal: this.signal }); return stats.map((stat, index) => ({ ...stat, number: index + 1 })); // the array order (by creation) is guaranteed by the API } } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts new file mode 100644 index 0000000000000..bbf53ec3a5404 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts @@ -0,0 +1,29 @@ +/* + * 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 { Storage } from '@kbn/kibana-utils-plugin/public'; + +export class RuleMigrationsStorage { + private readonly storage = new Storage(localStorage); + public key: string; + + constructor(private readonly objectName: string, spaceId?: string) { + this.key = this.getStorageKey(spaceId); + } + + private getStorageKey(spaceId: string = 'default') { + return `siem_migrations.rules.${this.objectName}.${spaceId}`; + } + + public setSpaceId(spaceId: string) { + this.key = this.getStorageKey(spaceId); + } + + public get = () => this.storage.get(this.key); + public set = (value: string) => this.storage.set(this.key, value); + public remove = () => this.storage.remove(this.key); +} diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx index f87755943f830..830e3c5f4a531 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx @@ -17,9 +17,9 @@ import type { ToastInput } from '@kbn/core-notifications-browser'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import type { RuleMigrationStats } from '../types'; +import type { RuleMigrationTask } from '../types'; -export const getSuccessToast = (migration: RuleMigrationStats, core: CoreStart): ToastInput => ({ +export const getSuccessToast = (migration: RuleMigrationTask, core: CoreStart): ToastInput => ({ color: 'success', iconType: 'check', toastLifeTimeMs: 1000 * 60 * 30, // 30 minutes @@ -34,7 +34,7 @@ export const getSuccessToast = (migration: RuleMigrationStats, core: CoreStart): ), }); -const SuccessToastContent: React.FC<{ migration: RuleMigrationStats }> = ({ migration }) => { +const SuccessToastContent: React.FC<{ migration: RuleMigrationTask }> = ({ migration }) => { const navigation = { deepLinkId: SecurityPageName.siemMigrationsRules, path: migration.id }; const { navigateTo, getAppUrl } = useNavigation(); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts index db9ca9507702f..4c704e97179c0 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts @@ -7,7 +7,7 @@ import type { RuleMigrationTaskStats } from '../../../common/siem_migrations/model/rule_migration.gen'; -export interface RuleMigrationStats extends RuleMigrationTaskStats { +export interface RuleMigrationTask extends RuleMigrationTaskStats { /** The sequential number of the migration */ number: number; } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/service/index.ts b/x-pack/plugins/security_solution/public/siem_migrations/service/index.ts index 08a50d018976b..dbea3624c7c1d 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/service/index.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/service/index.ts @@ -6,13 +6,17 @@ */ import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { StartPluginsDependencies } from '../../types'; export type { SiemMigrationsService } from './siem_migrations_service'; -export const createSiemMigrationsService = async (coreStart: CoreStart) => { +export const createSiemMigrationsService = async ( + coreStart: CoreStart, + plugins: StartPluginsDependencies +) => { const { SiemMigrationsService } = await import( /* webpackChunkName: "lazySiemMigrationsService" */ './siem_migrations_service' ); - return new SiemMigrationsService(coreStart); + return new SiemMigrationsService(coreStart, plugins); }; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/service/siem_migrations_service.ts b/x-pack/plugins/security_solution/public/siem_migrations/service/siem_migrations_service.ts index 1775296f6e230..da733bf5926e3 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/service/siem_migrations_service.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/service/siem_migrations_service.ts @@ -6,12 +6,13 @@ */ import type { CoreStart } from '@kbn/core/public'; +import type { StartPluginsDependencies } from '../../types'; import { SiemRulesMigrationsService } from '../rules/service/rule_migrations_service'; export class SiemMigrationsService { public rules: SiemRulesMigrationsService; - constructor(coreStart: CoreStart) { - this.rules = new SiemRulesMigrationsService(coreStart); + constructor(coreStart: CoreStart, plugins: StartPluginsDependencies) { + this.rules = new SiemRulesMigrationsService(coreStart, plugins); } } diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index d0387c5d3abe0..f4c3cdfc0e4c6 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -138,7 +138,7 @@ export interface StartPlugins { uiActions: UiActionsStart; maps: MapsStartApi; ml?: MlPluginStart; - spaces?: SpacesPluginStart; + spaces: SpacesPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; osquery: OsqueryPluginStart; security: SecurityPluginStart; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts index be1f3e84c46ea..645fa09b49dc1 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts @@ -7,7 +7,6 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import type { RuleMigrationResource } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; import { UpsertRuleMigrationResourcesRequestBody, UpsertRuleMigrationResourcesRequestParams, @@ -15,6 +14,7 @@ import { } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATION_RESOURCES_PATH } from '../../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import type { CreateRuleMigrationResourceInput } from '../../data/rule_migrations_data_resources_client'; import { withLicense } from '../util/with_license'; export const registerSiemRuleMigrationsResourceUpsertRoute = ( @@ -49,7 +49,7 @@ export const registerSiemRuleMigrationsResourceUpsertRoute = ( const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const ruleMigrations = resources.map((resource) => ({ + const ruleMigrations = resources.map((resource) => ({ migration_id: migrationId, ...resource, })); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts index 66b463da79cc3..888a41aca944c 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts @@ -14,23 +14,35 @@ import type { import type { StoredRuleMigrationResource } from '../types'; import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; +export type CreateRuleMigrationResourceInput = Omit; + /* 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; export class RuleMigrationsDataResourcesClient extends RuleMigrationsDataBaseClient { - public async upsert(resources: RuleMigrationResource[]): Promise { + public async upsert(resources: CreateRuleMigrationResourceInput[]): Promise { const index = await this.getIndexName(); - let resourcesSlice: RuleMigrationResource[]; + let resourcesSlice: CreateRuleMigrationResourceInput[]; + + const createdAt = new Date().toISOString(); while ((resourcesSlice = resources.splice(0, BULK_MAX_SIZE)).length > 0) { await this.esClient .bulk({ refresh: 'wait_for', operations: resourcesSlice.flatMap((resource) => [ { update: { _id: this.createId(resource), _index: index } }, - { doc: resource, doc_as_upsert: true }, + { + doc: { + ...resource, + '@timestamp': createdAt, + updated_by: this.username, + updated_at: createdAt, + }, + doc_as_upsert: true, + }, ]), }) .catch((error) => { @@ -65,7 +77,7 @@ export class RuleMigrationsDataResourcesClient extends RuleMigrationsDataBaseCli }); } - private createId(resource: RuleMigrationResource): string { + private createId(resource: CreateRuleMigrationResourceInput): string { const key = `${resource.migration_id}-${resource.type}-${resource.name}`; return sha256.create().update(key).hex(); } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts index 56c7e8485d315..a6ea5c9040e16 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts @@ -8,7 +8,10 @@ import type { AuthenticatedUser, Logger } from '@kbn/core/server'; import { AbortError, abortSignalToPromise } from '@kbn/kibana-utils-plugin/server'; import type { RunnableConfig } from '@langchain/core/runnables'; -import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; +import { + SiemMigrationTaskStatus, + SiemMigrationStatus, +} from '../../../../../common/siem_migrations/constants'; import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; import type { RuleMigrationDataStats } from '../data/rule_migrations_data_rules_client'; @@ -237,17 +240,17 @@ export class RuleMigrationsTaskClient { private getTaskStatus( migrationId: string, dataStats: RuleMigrationDataStats['rules'] - ): RuleMigrationTaskStats['status'] { + ): SiemMigrationTaskStatus { if (this.migrationsRunning.has(migrationId)) { - return 'running'; + return SiemMigrationTaskStatus.RUNNING; } if (dataStats.pending === dataStats.total) { - return 'ready'; + return SiemMigrationTaskStatus.READY; } if (dataStats.completed + dataStats.failed === dataStats.total) { - return 'finished'; + return SiemMigrationTaskStatus.FINISHED; } - return 'stopped'; + return SiemMigrationTaskStatus.STOPPED; } /** Stops one running migration */