From b76263bdf4b2724bcd57dfde3f9c65cfc14d9546 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Mon, 24 Oct 2022 11:32:46 +0200 Subject: [PATCH 1/3] Added guided onboarding for the rules area --- .../api/hooks/use_bulk_action_mutation.ts | 4 + .../api/hooks/use_bulk_export_mutation.ts | 8 +- .../use_create_prebuilt_rules_mutation.ts | 4 + .../api/hooks/use_create_rule_mutation.ts | 4 + .../use_fetch_prebuilt_rules_status_query.ts | 9 +- .../api/hooks/use_fetch_rule_by_id_query.ts | 9 +- .../api/hooks/use_fetch_tags_query.ts | 8 +- .../api/hooks/use_find_rules_query.ts | 11 +- .../api/hooks/use_update_rule_mutation.ts | 4 + .../rules_management_tour.tsx | 118 ++++++++++++++++++ .../guided_onboarding/translations.ts | 36 ++++++ .../use_is_element_mounted.ts | 35 ++++++ .../rules_table_filters.tsx | 2 + .../pages/rule_management/index.tsx | 2 + .../load_prepackaged_rules_button.tsx | 59 +++++---- 15 files changed, 272 insertions(+), 41 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts index e52a5cd8e0618..647230982c834 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts @@ -13,6 +13,9 @@ import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt import { useInvalidateFindRulesQuery, useUpdateRulesCache } from './use_find_rules_query'; import { useInvalidateFetchTagsQuery } from './use_fetch_tags_query'; import { useInvalidateFetchRuleByIdQuery } from './use_fetch_rule_by_id_query'; +import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; + +export const BULK_ACTION_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_BULK_ACTION]; export const useBulkActionMutation = ( options?: UseMutationOptions @@ -27,6 +30,7 @@ export const useBulkActionMutation = ( (action: BulkActionProps) => performBulkAction(action), { ...options, + mutationKey: BULK_ACTION_MUTATION_KEY, onSuccess: (...args) => { const [res, { action }] = args; switch (action) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts index bcc5fbcdbb18e..623db44af6098 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_export_mutation.ts @@ -6,14 +6,20 @@ */ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; +import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; import type { BulkExportProps, BulkExportResponse } from '../api'; import { bulkExportRules } from '../api'; +export const BULK_ACTION_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_BULK_ACTION]; + export const useBulkExportMutation = ( options?: UseMutationOptions ) => { return useMutation( (action: BulkExportProps) => bulkExportRules(action), - options + { + ...options, + mutationKey: BULK_ACTION_MUTATION_KEY, + } ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts index 2559be0609d08..86c6efbd50f85 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts @@ -11,6 +11,9 @@ import { createPrepackagedRules } from '../api'; import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; import { useInvalidateFetchTagsQuery } from './use_fetch_tags_query'; +import { PREBUILT_RULES_URL } from '../../../../../common/detection_engine/prebuilt_rules/api/urls'; + +export const CREATE_PREBUILT_RULES_MUTATION_KEY = ['PUT', PREBUILT_RULES_URL]; export const useCreatePrebuiltRulesMutation = ( options?: UseMutationOptions @@ -21,6 +24,7 @@ export const useCreatePrebuiltRulesMutation = ( return useMutation(() => createPrepackagedRules(), { ...options, + mutationKey: CREATE_PREBUILT_RULES_MUTATION_KEY, onSuccess: (...args) => { // Always invalidate all rules and the prepackaged rules status cache as // the number of rules might change after the installation diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts index 8d62927a6261f..56a3d67492713 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts @@ -6,6 +6,7 @@ */ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import type { RuleCreateProps, RuleResponse, @@ -16,6 +17,8 @@ import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt import { useInvalidateFetchTagsQuery } from './use_fetch_tags_query'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; +export const CREATE_RULE_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_URL]; + export const useCreateRuleMutation = ( options?: UseMutationOptions ) => { @@ -27,6 +30,7 @@ export const useCreateRuleMutation = ( (rule: RuleCreateProps) => createRule({ rule: transformOutput(rule) }), { ...options, + mutationKey: CREATE_RULE_MUTATION_KEY, onSuccess: (...args) => { invalidateFetchPrePackagedRulesStatusQuery(); invalidateFindRulesQuery(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_prebuilt_rules_status_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_prebuilt_rules_status_query.ts index a0344386ffe04..5fd22fae143cb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_prebuilt_rules_status_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_prebuilt_rules_status_query.ts @@ -10,14 +10,15 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getPrePackagedRulesStatus } from '../api'; import { DEFAULT_QUERY_OPTIONS } from './constants'; import type { PrePackagedRulesStatusResponse } from '../../logic'; +import { PREBUILT_RULES_STATUS_URL } from '../../../../../common/detection_engine/prebuilt_rules/api/urls'; -export const PREBUILT_RULES_STATUS_QUERY_KEY = 'prePackagedRulesStatus'; +export const PREBUILT_RULES_STATUS_QUERY_KEY = ['GET', PREBUILT_RULES_STATUS_URL]; export const useFetchPrebuiltRulesStatusQuery = ( - options: UseQueryOptions + options?: UseQueryOptions ) => { return useQuery( - [PREBUILT_RULES_STATUS_QUERY_KEY], + PREBUILT_RULES_STATUS_QUERY_KEY, async ({ signal }) => { const response = await getPrePackagedRulesStatus({ signal }); return response; @@ -40,7 +41,7 @@ export const useInvalidateFetchPrebuiltRulesStatusQuery = () => { const queryClient = useQueryClient(); return useCallback(() => { - queryClient.invalidateQueries([PREBUILT_RULES_STATUS_QUERY_KEY], { + queryClient.invalidateQueries(PREBUILT_RULES_STATUS_QUERY_KEY, { refetchType: 'active', }); }, [queryClient]); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rule_by_id_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rule_by_id_query.ts index 03fe7c6e2df17..66539807787ff 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rule_by_id_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rule_by_id_query.ts @@ -8,12 +8,13 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { transformInput } from '../../../../detections/containers/detection_engine/rules/transforms'; import type { Rule } from '../../logic'; import { fetchRuleById } from '../api'; import { DEFAULT_QUERY_OPTIONS } from './constants'; -const FIND_ONE_RULE_QUERY_KEY = 'findOneRule'; +const FIND_ONE_RULE_QUERY_KEY = ['GET', DETECTION_ENGINE_RULES_URL]; /** * A wrapper around useQuery provides default values to the underlying query, @@ -23,9 +24,9 @@ const FIND_ONE_RULE_QUERY_KEY = 'findOneRule'; * @param options - react-query options * @returns useQuery result */ -export const useFetchRuleByIdQuery = (id: string, options: UseQueryOptions) => { +export const useFetchRuleByIdQuery = (id: string, options?: UseQueryOptions) => { return useQuery( - [FIND_ONE_RULE_QUERY_KEY, id], + [...FIND_ONE_RULE_QUERY_KEY, id], async ({ signal }) => { const response = await fetchRuleById({ signal, id }); @@ -49,7 +50,7 @@ export const useInvalidateFetchRuleByIdQuery = () => { const queryClient = useQueryClient(); return useCallback(() => { - queryClient.invalidateQueries([FIND_ONE_RULE_QUERY_KEY], { + queryClient.invalidateQueries(FIND_ONE_RULE_QUERY_KEY, { refetchType: 'active', }); }, [queryClient]); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_tags_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_tags_query.ts index 1be43f992f07f..c09ae5d6cb56d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_tags_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_tags_query.ts @@ -8,12 +8,12 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; +import { DETECTION_ENGINE_TAGS_URL } from '../../../../../common/constants'; import type { FetchTagsResponse } from '../api'; import { fetchTags } from '../api'; import { DEFAULT_QUERY_OPTIONS } from './constants'; -// TODO: https://github.com/elastic/kibana/pull/142950 Let's use more detailed cache keys, e.g. ['GET', DETECTION_ENGINE_TAGS_URL] -const TAGS_QUERY_KEY = 'tags'; +const TAGS_QUERY_KEY = ['GET', DETECTION_ENGINE_TAGS_URL]; /** * Hook for using the list of Tags from the Detection Engine API @@ -21,7 +21,7 @@ const TAGS_QUERY_KEY = 'tags'; */ export const useFetchTagsQuery = (options?: UseQueryOptions) => { return useQuery( - [TAGS_QUERY_KEY], + TAGS_QUERY_KEY, async ({ signal }) => { return fetchTags({ signal }); }, @@ -36,7 +36,7 @@ export const useInvalidateFetchTagsQuery = () => { const queryClient = useQueryClient(); return useCallback(() => { - queryClient.invalidateQueries([TAGS_QUERY_KEY], { + queryClient.invalidateQueries(TAGS_QUERY_KEY, { refetchType: 'active', }); }, [queryClient]); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_find_rules_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_find_rules_query.ts index ad50ab471a7fd..35b6430a51172 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_find_rules_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_find_rules_query.ts @@ -8,6 +8,7 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; +import { DETECTION_ENGINE_RULES_URL_FIND } from '../../../../../common/constants'; import type { FilterOptions, PaginationOptions, Rule, SortingOptions } from '../../logic'; import { fetchRules } from '../api'; import { DEFAULT_QUERY_OPTIONS } from './constants'; @@ -18,7 +19,7 @@ export interface FindRulesQueryArgs { pagination?: Pick; } -const FIND_RULES_QUERY_KEY = 'findRules'; +const FIND_RULES_QUERY_KEY = ['GET', DETECTION_ENGINE_RULES_URL_FIND]; export interface RulesQueryResponse { rules: Rule[]; @@ -37,7 +38,7 @@ export interface RulesQueryResponse { */ export const useFindRulesQuery = ( queryArgs: FindRulesQueryArgs, - queryOptions: UseQueryOptions< + queryOptions?: UseQueryOptions< RulesQueryResponse, Error, RulesQueryResponse, @@ -45,7 +46,7 @@ export const useFindRulesQuery = ( > ) => { return useQuery( - [FIND_RULES_QUERY_KEY, queryArgs], + [...FIND_RULES_QUERY_KEY, queryArgs], async ({ signal }) => { const response = await fetchRules({ signal, ...queryArgs }); @@ -73,7 +74,7 @@ export const useInvalidateFindRulesQuery = () => { * Invalidate all queries that start with FIND_RULES_QUERY_KEY. This * includes the in-memory query cache and paged query cache. */ - queryClient.invalidateQueries([FIND_RULES_QUERY_KEY], { + queryClient.invalidateQueries(FIND_RULES_QUERY_KEY, { refetchType: 'active', }); }, [queryClient]); @@ -98,7 +99,7 @@ export const useUpdateRulesCache = () => { return useCallback( (newRules: Rule[]) => { queryClient.setQueriesData['data']>( - [FIND_RULES_QUERY_KEY], + FIND_RULES_QUERY_KEY, (currentData) => currentData ? { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts index 6f15fb4fdd8ce..d0b60ccb9b89a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts @@ -15,6 +15,9 @@ import { updateRule } from '../api'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; import { useInvalidateFetchTagsQuery } from './use_fetch_tags_query'; import { useInvalidateFetchRuleByIdQuery } from './use_fetch_rule_by_id_query'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; + +export const UPDATE_RULE_MUTATION_KEY = ['PUT', DETECTION_ENGINE_RULES_URL]; export const useUpdateRuleMutation = ( options?: UseMutationOptions @@ -27,6 +30,7 @@ export const useUpdateRuleMutation = ( (rule: RuleUpdateProps) => updateRule({ rule: transformOutput(rule) }), { ...options, + mutationKey: UPDATE_RULE_MUTATION_KEY, onSuccess: (...args) => { invalidateFindRulesQuery(); invalidateFetchRuleByIdQuery(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx new file mode 100644 index 0000000000000..1fcb56a009edb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiTourActions, EuiTourStepProps } from '@elastic/eui'; +import { EuiTourStep } from '@elastic/eui'; +import { noop } from 'lodash'; +import React, { useEffect, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useFindRulesQuery } from '../../../rule_management/api/hooks/use_find_rules_query'; +import * as i18n from './translations'; +import { useIsElementMounted } from './use_is_element_mounted'; + +export const INSTALL_PREBUILT_RULES_ANCHOR = 'install-prebuilt-rules-anchor'; +export const SEARCH_FIRST_RULE_ANCHOR = 'search-first-rule-anchor'; + +export interface RulesFeatureTourContextType { + steps: EuiTourStepProps[]; + actions: EuiTourActions; +} + +const GUIDED_ONBOARDING_RULES_FILTER = { + filter: '', + showCustomRules: false, + showElasticRules: true, + tags: ['Guided Onboarding'], +}; + +export enum GuidedOnboardingRulesStatus { + 'inactive' = 'inactive', + 'installRules' = 'installRules', + 'activateRules' = 'activateRules', + 'completed' = 'completed', +} + +export const RulesManagementTour = () => { + const { guidedOnboardingApi } = useKibana().services.guidedOnboarding; + + const isRulesStepActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', 'rules') ?? of(false), + false + ); + + const { data: onboardingRules } = useFindRulesQuery( + { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, + { enabled: isRulesStepActive } + ); + + const tourStatus = useMemo(() => { + if (!isRulesStepActive || !onboardingRules) { + return GuidedOnboardingRulesStatus.inactive; + } + + if (onboardingRules.total === 0) { + // Onboarding rules are not installed - show the install/update rules step + return GuidedOnboardingRulesStatus.installRules; + } + + if (!onboardingRules.rules.some((rule) => rule.enabled)) { + // None of the onboarding rules is active - show the activate step + return GuidedOnboardingRulesStatus.activateRules; + } + + // Rules are installed and enabled - the tour is completed + return GuidedOnboardingRulesStatus.completed; + }, [isRulesStepActive, onboardingRules]); + + // Synchronize the current "internal" tour step with the global one + useEffect(() => { + if (isRulesStepActive && tourStatus === GuidedOnboardingRulesStatus.completed) { + guidedOnboardingApi?.completeGuideStep('security', 'rules'); + } + }, [guidedOnboardingApi, isRulesStepActive, tourStatus]); + + /** + * Wait until the tour target elements are visible on the page and mount + * EuiTourStep components only after that. Otherwise, the tours would never + * show up on the page. + */ + const isInstallRulesAnchorMounted = useIsElementMounted(INSTALL_PREBUILT_RULES_ANCHOR); + const isSearchFirstRuleAnchorMounted = useIsElementMounted(SEARCH_FIRST_RULE_ANCHOR); + + return ( + <> + {isInstallRulesAnchorMounted && ( + } // Replace "Skip tour" with an empty element + /> + )} + {isSearchFirstRuleAnchorMounted && ( + } // Replace "Skip tour" with an empty element + /> + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts new file mode 100644 index 0000000000000..6c8a2880801a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts @@ -0,0 +1,36 @@ +/* + * 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 INSTALL_PREBUILT_RULES_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', + { + defaultMessage: 'Load the Elastic prebuilt rules', + } +); + +export const INSTALL_PREBUILT_RULES_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', + { + defaultMessage: 'To get started you need to load the Elastic prebuilt rules.', + } +); + +export const SEARCH_FIRST_RULE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', + { + defaultMessage: 'Search for Elastic Defend rules', + } +); + +export const SEARCH_FIRST_RULE_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', + { + defaultMessage: 'Find the My First Alert rule and enable it.', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts new file mode 100644 index 0000000000000..b3be0184e1a3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.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 { useEffect, useState } from 'react'; + +export const useIsElementMounted = (elementId: string) => { + const [isElementMounted, setIsElementMounted] = useState(false); + + useEffect(() => { + const observer = new MutationObserver(() => { + const isElementFound = !!document.getElementById(elementId); + + if (isElementFound && !isElementMounted) { + setIsElementMounted(true); + } + + if (!isElementFound && isElementMounted) { + setIsElementMounted(false); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [isElementMounted, elementId]); + + return isElementMounted; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx index 784d3dfc62427..143ae37a694d1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx @@ -22,6 +22,7 @@ import * as i18n from '../../../../../detections/pages/detection_engine/rules/tr import { useRulesTableContext } from '../rules_table/rules_table_context'; import { TagsFilterPopover } from './tags_filter_popover'; import { useTags } from '../../../../rule_management/logic/use_tags'; +import { SEARCH_FIRST_RULE_ANCHOR } from '../../guided_onboarding/rules_management_tour'; const FilterWrapper = styled(EuiFlexGroup)` margin-bottom: ${({ theme }) => theme.eui.euiSizeXS}; @@ -85,6 +86,7 @@ const RulesTableFiltersComponent = () => { { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); @@ -85,6 +86,7 @@ const RulesPageComponent: React.FC = () => { + - {getLoadRulesOrTimelinesButtonTitle(prePackagedAssetsStatus, prePackagedTimelineStatus)} - +
+ + {getLoadRulesOrTimelinesButtonTitle(prePackagedAssetsStatus, prePackagedTimelineStatus)} + +
); } @@ -81,20 +88,26 @@ export const LoadPrePackagedRulesButton = ({ prePackagedTimelineStatus === 'someTimelineUninstall'; if (showUpdateButton) { + // Without the outer div EuiStepTour crashes with Uncaught DOMException: + // Failed to execute 'removeChild' on 'Node': The node to be removed is not + // a child of this node. return ( - - {getMissingRulesOrTimelinesButtonTitle( - prePackagedRulesStatus?.rules_not_installed ?? 0, - prePackagedRulesStatus?.timelines_not_installed ?? 0 - )} - +
+ + {getMissingRulesOrTimelinesButtonTitle( + prePackagedRulesStatus?.rules_not_installed ?? 0, + prePackagedRulesStatus?.timelines_not_installed ?? 0 + )} + +
); } From 7fd258c38c8add026e7eba18f318d988c32b2e4f Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Fri, 28 Oct 2022 05:22:02 -0600 Subject: [PATCH 2/3] [Security solution] Guided onboarding rules (#1) --- .../guided_onboarding_tour/tour.tsx | 11 +- .../guided_onboarding_tour/tour_config.ts | 35 +++++- .../guided_onboarding_tour/use_rules_tour.tsx | 50 ++++++++ .../rules_management_tour.tsx | 118 ------------------ .../guided_onboarding/translations.ts | 36 ------ .../use_is_element_mounted.ts | 35 ------ .../rules_table_filters.tsx | 20 +-- .../pages/rule_management/index.tsx | 5 +- .../load_prepackaged_rules_button.tsx | 19 +-- 9 files changed, 113 insertions(+), 216 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx index 43f6ca15b33cb..1056285ec646f 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx @@ -19,7 +19,8 @@ import { securityTourConfig, SecurityStepId } from './tour_config'; export interface TourContextValue { activeStep: number; endTourStep: (stepId: SecurityStepId) => void; - incrementStep: (stepId: SecurityStepId, step?: number) => void; + incrementStep: (stepId: SecurityStepId) => void; + setActiveStep: (stepId: SecurityStepId, step: number) => void; isTourShown: (stepId: SecurityStepId) => boolean; } @@ -27,6 +28,7 @@ const initialState: TourContextValue = { activeStep: 0, endTourStep: () => {}, incrementStep: () => {}, + setActiveStep: () => {}, isTourShown: () => false, }; @@ -63,6 +65,12 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) const isTourShown = useCallback((stepId: SecurityStepId) => tourStatus[stepId], [tourStatus]); const [activeStep, _setActiveStep] = useState(1); + const setActiveStep = useCallback((stepId: SecurityStepId, step: number) => { + if (step <= securityTourConfig[stepId].length) { + _setActiveStep(step); + } + }, []); + const incrementStep = useCallback((stepId: SecurityStepId) => { _setActiveStep( (prevState) => (prevState >= securityTourConfig[stepId].length ? 0 : prevState) + 1 @@ -105,6 +113,7 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) endTourStep, incrementStep, isTourShown, + setActiveStep, }; return {children}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts index f7ed05be4c418..7c506024e4ecd 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts @@ -125,6 +125,39 @@ const alertsCasesConfig: StepConfig[] = [ }, ]; +const rulesConfig: StepConfig[] = [ + { + ...defaultConfig, + title: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', + { defaultMessage: 'Load the Elastic prebuilt rules' } + ), + content: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', + { defaultMessage: 'To get started you need to load the Elastic prebuilt rules.' } + ), + step: 1, + anchorPosition: 'downCenter', + hideNextButton: true, + dataTestSubj: getTourAnchor(1, SecurityStepId.rules), + }, + { + ...defaultConfig, + title: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', + { defaultMessage: 'Search for Elastic Defend rules' } + ), + content: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', + { defaultMessage: 'Find the rules you want and enable them.' } + ), + step: 2, + anchorPosition: 'upCenter', + hideNextButton: true, + dataTestSubj: getTourAnchor(2, SecurityStepId.rules), + }, +]; + interface SecurityTourConfig { [SecurityStepId.rules]: StepConfig[]; [SecurityStepId.alertsCases]: StepConfig[]; @@ -134,6 +167,6 @@ export const securityTourConfig: SecurityTourConfig = { /** * D&R team implement your tour config here */ - [SecurityStepId.rules]: [], + [SecurityStepId.rules]: rulesConfig, [SecurityStepId.alertsCases]: alertsCasesConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx new file mode 100644 index 0000000000000..934eccd618594 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect } from 'react'; +import { useTourContext } from './tour'; +import type { RulesQueryResponse } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { SecurityStepId } from './tour_config'; +const GUIDED_ONBOARDING_RULES_FILTER = { + filter: '', + showCustomRules: false, + showElasticRules: true, + tags: ['Guided Onboarding'], +}; +export const useRulesTour = () => { + const { isTourShown, endTourStep, incrementStep, activeStep, setActiveStep } = useTourContext(); + const { data: onboardingRules } = useFindRulesQuery( + { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, + { retry: false, enabled: isTourShown(SecurityStepId.rules) } + ); + + const manageRulesTour = useCallback( + ({ rules }: RulesQueryResponse) => { + if (rules && rules.length === 0 && isTourShown(SecurityStepId.rules) && activeStep === 2) { + // reset to 1 if they are on step 2 but have no onboarding rules + setActiveStep(SecurityStepId.rules, 1); + } + if (rules && rules.length > 0 && isTourShown(SecurityStepId.rules) && activeStep === 1) { + // There are onboarding rules now, advance to step 2 if on step 1 + incrementStep(SecurityStepId.rules); + } + if (rules.some((rule) => rule.enabled)) { + // The onboarding rule is enabled, end the tour + endTourStep(SecurityStepId.rules); + } + }, + [activeStep, endTourStep, incrementStep, isTourShown, setActiveStep] + ); + + useEffect(() => { + if (onboardingRules) { + manageRulesTour(onboardingRules); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onboardingRules]); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx deleted file mode 100644 index 1fcb56a009edb..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.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 type { EuiTourActions, EuiTourStepProps } from '@elastic/eui'; -import { EuiTourStep } from '@elastic/eui'; -import { noop } from 'lodash'; -import React, { useEffect, useMemo } from 'react'; -import useObservable from 'react-use/lib/useObservable'; -import { of } from 'rxjs'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useFindRulesQuery } from '../../../rule_management/api/hooks/use_find_rules_query'; -import * as i18n from './translations'; -import { useIsElementMounted } from './use_is_element_mounted'; - -export const INSTALL_PREBUILT_RULES_ANCHOR = 'install-prebuilt-rules-anchor'; -export const SEARCH_FIRST_RULE_ANCHOR = 'search-first-rule-anchor'; - -export interface RulesFeatureTourContextType { - steps: EuiTourStepProps[]; - actions: EuiTourActions; -} - -const GUIDED_ONBOARDING_RULES_FILTER = { - filter: '', - showCustomRules: false, - showElasticRules: true, - tags: ['Guided Onboarding'], -}; - -export enum GuidedOnboardingRulesStatus { - 'inactive' = 'inactive', - 'installRules' = 'installRules', - 'activateRules' = 'activateRules', - 'completed' = 'completed', -} - -export const RulesManagementTour = () => { - const { guidedOnboardingApi } = useKibana().services.guidedOnboarding; - - const isRulesStepActive = useObservable( - guidedOnboardingApi?.isGuideStepActive$('security', 'rules') ?? of(false), - false - ); - - const { data: onboardingRules } = useFindRulesQuery( - { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, - { enabled: isRulesStepActive } - ); - - const tourStatus = useMemo(() => { - if (!isRulesStepActive || !onboardingRules) { - return GuidedOnboardingRulesStatus.inactive; - } - - if (onboardingRules.total === 0) { - // Onboarding rules are not installed - show the install/update rules step - return GuidedOnboardingRulesStatus.installRules; - } - - if (!onboardingRules.rules.some((rule) => rule.enabled)) { - // None of the onboarding rules is active - show the activate step - return GuidedOnboardingRulesStatus.activateRules; - } - - // Rules are installed and enabled - the tour is completed - return GuidedOnboardingRulesStatus.completed; - }, [isRulesStepActive, onboardingRules]); - - // Synchronize the current "internal" tour step with the global one - useEffect(() => { - if (isRulesStepActive && tourStatus === GuidedOnboardingRulesStatus.completed) { - guidedOnboardingApi?.completeGuideStep('security', 'rules'); - } - }, [guidedOnboardingApi, isRulesStepActive, tourStatus]); - - /** - * Wait until the tour target elements are visible on the page and mount - * EuiTourStep components only after that. Otherwise, the tours would never - * show up on the page. - */ - const isInstallRulesAnchorMounted = useIsElementMounted(INSTALL_PREBUILT_RULES_ANCHOR); - const isSearchFirstRuleAnchorMounted = useIsElementMounted(SEARCH_FIRST_RULE_ANCHOR); - - return ( - <> - {isInstallRulesAnchorMounted && ( - } // Replace "Skip tour" with an empty element - /> - )} - {isSearchFirstRuleAnchorMounted && ( - } // Replace "Skip tour" with an empty element - /> - )} - - ); -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts deleted file mode 100644 index 6c8a2880801a6..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts +++ /dev/null @@ -1,36 +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 { i18n } from '@kbn/i18n'; - -export const INSTALL_PREBUILT_RULES_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', - { - defaultMessage: 'Load the Elastic prebuilt rules', - } -); - -export const INSTALL_PREBUILT_RULES_CONTENT = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', - { - defaultMessage: 'To get started you need to load the Elastic prebuilt rules.', - } -); - -export const SEARCH_FIRST_RULE_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', - { - defaultMessage: 'Search for Elastic Defend rules', - } -); - -export const SEARCH_FIRST_RULE_CONTENT = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', - { - defaultMessage: 'Find the My First Alert rule and enable it.', - } -); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts deleted file mode 100644 index b3be0184e1a3e..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts +++ /dev/null @@ -1,35 +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 { useEffect, useState } from 'react'; - -export const useIsElementMounted = (elementId: string) => { - const [isElementMounted, setIsElementMounted] = useState(false); - - useEffect(() => { - const observer = new MutationObserver(() => { - const isElementFound = !!document.getElementById(elementId); - - if (isElementFound && !isElementMounted) { - setIsElementMounted(true); - } - - if (!isElementFound && isElementMounted) { - setIsElementMounted(false); - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - - return () => observer.disconnect(); - }, [isElementMounted, elementId]); - - return isElementMounted; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx index 143ae37a694d1..c32f00b1b53a3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx @@ -15,6 +15,8 @@ import { import { isEqual } from 'lodash/fp'; import React, { useCallback } from 'react'; import styled from 'styled-components'; +import { GuidedOnboardingTourStep } from '../../../../../common/components/guided_onboarding_tour/tour_step'; +import { SecurityStepId } from '../../../../../common/components/guided_onboarding_tour/tour_config'; import { RULES_TABLE_ACTIONS } from '../../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction'; import { usePrePackagedRulesStatus } from '../../../../rule_management/logic/use_pre_packaged_rules_status'; @@ -22,7 +24,6 @@ import * as i18n from '../../../../../detections/pages/detection_engine/rules/tr import { useRulesTableContext } from '../rules_table/rules_table_context'; import { TagsFilterPopover } from './tags_filter_popover'; import { useTags } from '../../../../rule_management/logic/use_tags'; -import { SEARCH_FIRST_RULE_ANCHOR } from '../../guided_onboarding/rules_management_tour'; const FilterWrapper = styled(EuiFlexGroup)` margin-bottom: ${({ theme }) => theme.eui.euiSizeXS}; @@ -85,14 +86,15 @@ const RulesTableFiltersComponent = () => { return ( - + + + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index e49c6b61a0019..848653c1a95e7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { useRulesTour } from '../../../../common/components/guided_onboarding_tour/use_rules_tour'; import { APP_UI_ID } from '../../../../../common/constants'; import { SecurityPageName } from '../../../../app/types'; import { HeaderPage } from '../../../../common/components/header_page'; @@ -40,7 +41,6 @@ import { AllRules } from '../../components/rules_table'; import { RulesTableContextProvider } from '../../components/rules_table/rules_table/rules_table_context'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; -import { RulesManagementTour } from '../../components/guided_onboarding/rules_management_tour'; const RulesPageComponent: React.FC = () => { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); @@ -65,7 +65,7 @@ const RulesPageComponent: React.FC = () => { const loading = userInfoLoading || listsConfigLoading; const prePackagedRuleStatus = usePrePackagedRulesInstallationStatus(); const prePackagedTimelineStatus = usePrePackagedTimelinesInstallationStatus(); - + useRulesTour(); if ( redirectToDetections( isSignalIndexExists, @@ -86,7 +86,6 @@ const RulesPageComponent: React.FC = () => { - + {getLoadRulesOrTimelinesButtonTitle(prePackagedAssetsStatus, prePackagedTimelineStatus)} - + ); } @@ -88,13 +85,9 @@ export const LoadPrePackagedRulesButton = ({ prePackagedTimelineStatus === 'someTimelineUninstall'; if (showUpdateButton) { - // Without the outer div EuiStepTour crashes with Uncaught DOMException: - // Failed to execute 'removeChild' on 'Node': The node to be removed is not - // a child of this node. return ( -
+ -
+ ); } From 37bbf51e0bd401e470acee06999164e9453afc12 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Fri, 28 Oct 2022 15:01:57 +0200 Subject: [PATCH 3/3] Revert "[Security solution] Guided onboarding rules (#1)" This reverts commit 2bb0b7d56412480af04065912d2be7b8471637df. --- .../guided_onboarding_tour/tour.tsx | 11 +- .../guided_onboarding_tour/tour_config.ts | 35 +----- .../guided_onboarding_tour/use_rules_tour.tsx | 50 -------- .../rules_management_tour.tsx | 118 ++++++++++++++++++ .../guided_onboarding/translations.ts | 36 ++++++ .../use_is_element_mounted.ts | 35 ++++++ .../rules_table_filters.tsx | 20 ++- .../pages/rule_management/index.tsx | 5 +- .../load_prepackaged_rules_button.tsx | 19 ++- 9 files changed, 216 insertions(+), 113 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx index 1056285ec646f..43f6ca15b33cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx @@ -19,8 +19,7 @@ import { securityTourConfig, SecurityStepId } from './tour_config'; export interface TourContextValue { activeStep: number; endTourStep: (stepId: SecurityStepId) => void; - incrementStep: (stepId: SecurityStepId) => void; - setActiveStep: (stepId: SecurityStepId, step: number) => void; + incrementStep: (stepId: SecurityStepId, step?: number) => void; isTourShown: (stepId: SecurityStepId) => boolean; } @@ -28,7 +27,6 @@ const initialState: TourContextValue = { activeStep: 0, endTourStep: () => {}, incrementStep: () => {}, - setActiveStep: () => {}, isTourShown: () => false, }; @@ -65,12 +63,6 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) const isTourShown = useCallback((stepId: SecurityStepId) => tourStatus[stepId], [tourStatus]); const [activeStep, _setActiveStep] = useState(1); - const setActiveStep = useCallback((stepId: SecurityStepId, step: number) => { - if (step <= securityTourConfig[stepId].length) { - _setActiveStep(step); - } - }, []); - const incrementStep = useCallback((stepId: SecurityStepId) => { _setActiveStep( (prevState) => (prevState >= securityTourConfig[stepId].length ? 0 : prevState) + 1 @@ -113,7 +105,6 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) endTourStep, incrementStep, isTourShown, - setActiveStep, }; return {children}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts index 7c506024e4ecd..f7ed05be4c418 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts @@ -125,39 +125,6 @@ const alertsCasesConfig: StepConfig[] = [ }, ]; -const rulesConfig: StepConfig[] = [ - { - ...defaultConfig, - title: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', - { defaultMessage: 'Load the Elastic prebuilt rules' } - ), - content: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', - { defaultMessage: 'To get started you need to load the Elastic prebuilt rules.' } - ), - step: 1, - anchorPosition: 'downCenter', - hideNextButton: true, - dataTestSubj: getTourAnchor(1, SecurityStepId.rules), - }, - { - ...defaultConfig, - title: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', - { defaultMessage: 'Search for Elastic Defend rules' } - ), - content: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', - { defaultMessage: 'Find the rules you want and enable them.' } - ), - step: 2, - anchorPosition: 'upCenter', - hideNextButton: true, - dataTestSubj: getTourAnchor(2, SecurityStepId.rules), - }, -]; - interface SecurityTourConfig { [SecurityStepId.rules]: StepConfig[]; [SecurityStepId.alertsCases]: StepConfig[]; @@ -167,6 +134,6 @@ export const securityTourConfig: SecurityTourConfig = { /** * D&R team implement your tour config here */ - [SecurityStepId.rules]: rulesConfig, + [SecurityStepId.rules]: [], [SecurityStepId.alertsCases]: alertsCasesConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx deleted file mode 100644 index 934eccd618594..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx +++ /dev/null @@ -1,50 +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 { useCallback, useEffect } from 'react'; -import { useTourContext } from './tour'; -import type { RulesQueryResponse } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; -import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; -import { SecurityStepId } from './tour_config'; -const GUIDED_ONBOARDING_RULES_FILTER = { - filter: '', - showCustomRules: false, - showElasticRules: true, - tags: ['Guided Onboarding'], -}; -export const useRulesTour = () => { - const { isTourShown, endTourStep, incrementStep, activeStep, setActiveStep } = useTourContext(); - const { data: onboardingRules } = useFindRulesQuery( - { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, - { retry: false, enabled: isTourShown(SecurityStepId.rules) } - ); - - const manageRulesTour = useCallback( - ({ rules }: RulesQueryResponse) => { - if (rules && rules.length === 0 && isTourShown(SecurityStepId.rules) && activeStep === 2) { - // reset to 1 if they are on step 2 but have no onboarding rules - setActiveStep(SecurityStepId.rules, 1); - } - if (rules && rules.length > 0 && isTourShown(SecurityStepId.rules) && activeStep === 1) { - // There are onboarding rules now, advance to step 2 if on step 1 - incrementStep(SecurityStepId.rules); - } - if (rules.some((rule) => rule.enabled)) { - // The onboarding rule is enabled, end the tour - endTourStep(SecurityStepId.rules); - } - }, - [activeStep, endTourStep, incrementStep, isTourShown, setActiveStep] - ); - - useEffect(() => { - if (onboardingRules) { - manageRulesTour(onboardingRules); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onboardingRules]); -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx new file mode 100644 index 0000000000000..1fcb56a009edb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiTourActions, EuiTourStepProps } from '@elastic/eui'; +import { EuiTourStep } from '@elastic/eui'; +import { noop } from 'lodash'; +import React, { useEffect, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useFindRulesQuery } from '../../../rule_management/api/hooks/use_find_rules_query'; +import * as i18n from './translations'; +import { useIsElementMounted } from './use_is_element_mounted'; + +export const INSTALL_PREBUILT_RULES_ANCHOR = 'install-prebuilt-rules-anchor'; +export const SEARCH_FIRST_RULE_ANCHOR = 'search-first-rule-anchor'; + +export interface RulesFeatureTourContextType { + steps: EuiTourStepProps[]; + actions: EuiTourActions; +} + +const GUIDED_ONBOARDING_RULES_FILTER = { + filter: '', + showCustomRules: false, + showElasticRules: true, + tags: ['Guided Onboarding'], +}; + +export enum GuidedOnboardingRulesStatus { + 'inactive' = 'inactive', + 'installRules' = 'installRules', + 'activateRules' = 'activateRules', + 'completed' = 'completed', +} + +export const RulesManagementTour = () => { + const { guidedOnboardingApi } = useKibana().services.guidedOnboarding; + + const isRulesStepActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', 'rules') ?? of(false), + false + ); + + const { data: onboardingRules } = useFindRulesQuery( + { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, + { enabled: isRulesStepActive } + ); + + const tourStatus = useMemo(() => { + if (!isRulesStepActive || !onboardingRules) { + return GuidedOnboardingRulesStatus.inactive; + } + + if (onboardingRules.total === 0) { + // Onboarding rules are not installed - show the install/update rules step + return GuidedOnboardingRulesStatus.installRules; + } + + if (!onboardingRules.rules.some((rule) => rule.enabled)) { + // None of the onboarding rules is active - show the activate step + return GuidedOnboardingRulesStatus.activateRules; + } + + // Rules are installed and enabled - the tour is completed + return GuidedOnboardingRulesStatus.completed; + }, [isRulesStepActive, onboardingRules]); + + // Synchronize the current "internal" tour step with the global one + useEffect(() => { + if (isRulesStepActive && tourStatus === GuidedOnboardingRulesStatus.completed) { + guidedOnboardingApi?.completeGuideStep('security', 'rules'); + } + }, [guidedOnboardingApi, isRulesStepActive, tourStatus]); + + /** + * Wait until the tour target elements are visible on the page and mount + * EuiTourStep components only after that. Otherwise, the tours would never + * show up on the page. + */ + const isInstallRulesAnchorMounted = useIsElementMounted(INSTALL_PREBUILT_RULES_ANCHOR); + const isSearchFirstRuleAnchorMounted = useIsElementMounted(SEARCH_FIRST_RULE_ANCHOR); + + return ( + <> + {isInstallRulesAnchorMounted && ( + } // Replace "Skip tour" with an empty element + /> + )} + {isSearchFirstRuleAnchorMounted && ( + } // Replace "Skip tour" with an empty element + /> + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts new file mode 100644 index 0000000000000..6c8a2880801a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts @@ -0,0 +1,36 @@ +/* + * 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 INSTALL_PREBUILT_RULES_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', + { + defaultMessage: 'Load the Elastic prebuilt rules', + } +); + +export const INSTALL_PREBUILT_RULES_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', + { + defaultMessage: 'To get started you need to load the Elastic prebuilt rules.', + } +); + +export const SEARCH_FIRST_RULE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', + { + defaultMessage: 'Search for Elastic Defend rules', + } +); + +export const SEARCH_FIRST_RULE_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', + { + defaultMessage: 'Find the My First Alert rule and enable it.', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts new file mode 100644 index 0000000000000..b3be0184e1a3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.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 { useEffect, useState } from 'react'; + +export const useIsElementMounted = (elementId: string) => { + const [isElementMounted, setIsElementMounted] = useState(false); + + useEffect(() => { + const observer = new MutationObserver(() => { + const isElementFound = !!document.getElementById(elementId); + + if (isElementFound && !isElementMounted) { + setIsElementMounted(true); + } + + if (!isElementFound && isElementMounted) { + setIsElementMounted(false); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [isElementMounted, elementId]); + + return isElementMounted; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx index c32f00b1b53a3..143ae37a694d1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx @@ -15,8 +15,6 @@ import { import { isEqual } from 'lodash/fp'; import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { GuidedOnboardingTourStep } from '../../../../../common/components/guided_onboarding_tour/tour_step'; -import { SecurityStepId } from '../../../../../common/components/guided_onboarding_tour/tour_config'; import { RULES_TABLE_ACTIONS } from '../../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction'; import { usePrePackagedRulesStatus } from '../../../../rule_management/logic/use_pre_packaged_rules_status'; @@ -24,6 +22,7 @@ import * as i18n from '../../../../../detections/pages/detection_engine/rules/tr import { useRulesTableContext } from '../rules_table/rules_table_context'; import { TagsFilterPopover } from './tags_filter_popover'; import { useTags } from '../../../../rule_management/logic/use_tags'; +import { SEARCH_FIRST_RULE_ANCHOR } from '../../guided_onboarding/rules_management_tour'; const FilterWrapper = styled(EuiFlexGroup)` margin-bottom: ${({ theme }) => theme.eui.euiSizeXS}; @@ -86,15 +85,14 @@ const RulesTableFiltersComponent = () => { return ( - - - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index 848653c1a95e7..e49c6b61a0019 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { useRulesTour } from '../../../../common/components/guided_onboarding_tour/use_rules_tour'; import { APP_UI_ID } from '../../../../../common/constants'; import { SecurityPageName } from '../../../../app/types'; import { HeaderPage } from '../../../../common/components/header_page'; @@ -41,6 +40,7 @@ import { AllRules } from '../../components/rules_table'; import { RulesTableContextProvider } from '../../components/rules_table/rules_table/rules_table_context'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; +import { RulesManagementTour } from '../../components/guided_onboarding/rules_management_tour'; const RulesPageComponent: React.FC = () => { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); @@ -65,7 +65,7 @@ const RulesPageComponent: React.FC = () => { const loading = userInfoLoading || listsConfigLoading; const prePackagedRuleStatus = usePrePackagedRulesInstallationStatus(); const prePackagedTimelineStatus = usePrePackagedTimelinesInstallationStatus(); - useRulesTour(); + if ( redirectToDetections( isSignalIndexExists, @@ -86,6 +86,7 @@ const RulesPageComponent: React.FC = () => { + +
{getLoadRulesOrTimelinesButtonTitle(prePackagedAssetsStatus, prePackagedTimelineStatus)} - +
); } @@ -85,9 +88,13 @@ export const LoadPrePackagedRulesButton = ({ prePackagedTimelineStatus === 'someTimelineUninstall'; if (showUpdateButton) { + // Without the outer div EuiStepTour crashes with Uncaught DOMException: + // Failed to execute 'removeChild' on 'Node': The node to be removed is not + // a child of this node. return ( - +
- +
); }