diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav.test.tsx b/x-pack/packages/security-solution/side_nav/src/solution_side_nav.test.tsx index b5db8e66f10dd..91ba09202a13e 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav.test.tsx +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav.test.tsx @@ -167,5 +167,11 @@ describe('SolutionSideNav', () => { expect(result.queryByTestId('solutionSideNavPanel')).toBeInTheDocument(); expect(result.getByText('Users')).toBeInTheDocument(); }); + + it('should call onMount when function is provided', () => { + const onMount = jest.fn(); + renderNav({ onMount }); + expect(onMount).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav.tsx b/x-pack/packages/security-solution/side_nav/src/solution_side_nav.tsx index d146985aadf7a..1615df71011f7 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav.tsx +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiListGroup, EuiFlexGroup, @@ -50,6 +50,7 @@ export interface SolutionSideNavProps { * e.g.: usageCollection?.reportUiCounter?.bind(null, appId) * */ tracker?: Tracker; + onMount?: () => void; } type ActivePanelNav = string | null; /** @@ -62,6 +63,7 @@ export const SolutionSideNav: React.FC = React.memo(functi panelBottomOffset, panelTopOffset, tracker, + onMount, }) { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); @@ -73,6 +75,10 @@ export const SolutionSideNav: React.FC = React.memo(functi setActivePanelNavId(id); }; + useEffect(() => { + if (onMount) onMount(); + }, [onMount]); + const onClosePanelNav = useCallback(() => { activePanelNavIdRef.current = null; setActivePanelNavId(null); @@ -290,6 +296,7 @@ const SolutionSideNavItem: React.FC = React.memo( className={itemClassNames} color="text" size="s" + id={`solutionSideNavItemLink-${id}`} data-test-subj={`solutionSideNavItemLink-${id}`} /> diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index ceff046d8c678..5be5462a805f9 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -428,6 +428,7 @@ export const NEW_FEATURES_TOUR_STORAGE_KEYS = { TIMELINES: 'securitySolution.security.timelineFlyoutHeader.saveTimelineTour', TIMELINE: 'securitySolution.timeline.newFeaturesTour.v8.12', FLYOUT: 'securitySolution.documentDetails.newFeaturesTour.v8.14', + ATTACK_DISCOVERY: 'securitySolution.attackDiscovery.newFeaturesTour.v8.14', }; export const RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY = diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 19e8d55aa2dd5..b6492ef97cae7 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import React, { type ReactNode, useMemo } from 'react'; +import React, { type ReactNode, useMemo, useState, useCallback } from 'react'; import styled from 'styled-components'; import { EuiThemeProvider, useEuiTheme, type EuiThemeComputed } from '@elastic/eui'; import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template'; import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout'; +import { AttackDiscoveryTour } from '../../../attack_discovery/tour'; import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state'; import { SecuritySolutionFlyout, TimelineFlyout } from '../../../flyout'; import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation'; @@ -55,7 +56,11 @@ export type SecuritySolutionTemplateWrapperProps = Omit = React.memo(({ children, ...rest }) => { - const solutionNavProps = useSecuritySolutionNavigation(); + const [didMount, setDidMount] = useState(false); + const onMount = useCallback(() => { + setDidMount(true); + }, []); + const solutionNavProps = useSecuritySolutionNavigation(onMount); const [isTimelineBottomBarVisible] = useShowTimeline(); const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); const { show: isShowingTimelineOverlay } = useDeepEqualSelector((state) => @@ -102,6 +107,8 @@ export const SecuritySolutionTemplateWrapper: React.FC + + {didMount && } {isTimelineBottomBarVisible && ( diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/tour/index.test.tsx new file mode 100644 index 0000000000000..fc3f2a2344a14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/tour/index.test.tsx @@ -0,0 +1,137 @@ +/* + * 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 { useIsElementMounted } from '../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted'; +import { render, screen } from '@testing-library/react'; +import { + createMockStore, + createSecuritySolutionStorageMock, + TestProviders, +} from '../../common/mock'; +import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__'; +import { useKibana } from '../../common/lib/kibana'; +import { AttackDiscoveryTour } from '.'; +import { ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS } from './step_config'; +import { NEW_FEATURES_TOUR_STORAGE_KEYS, SecurityPageName } from '../../../common/constants'; +import type { RouteSpyState } from '../../common/utils/route/types'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; + +const mockRouteSpy: RouteSpyState = { + pageName: SecurityPageName.overview, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/', +}; +jest.mock( + '../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted' +); +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/utils/route/use_route_spy'); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + EuiTourStep: () =>
, + }; +}); +const mockedUseKibana = mockUseKibana(); + +const { storage: storageMock } = createSecuritySolutionStorageMock(); +const mockStore = createMockStore(undefined, undefined, undefined, storageMock); + +const TestComponent = () => { + return ( + +
+ + + ); +}; + +describe('Attack discovery tour', () => { + beforeAll(() => { + (useIsElementMounted as jest.Mock).mockReturnValue(true); + (useRouteSpy as jest.Mock).mockReturnValue([mockRouteSpy]); + }); + + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + storage: storageMock, + }, + }); + + storageMock.clear(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not render tour step 1 when element is not mounted', () => { + (useIsElementMounted as jest.Mock).mockReturnValueOnce(false); + render(); + expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull(); + }); + + it('should not render any tour steps when tour is not activated', () => { + storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, { + currentTourStep: 1, + isTourActive: false, + }); + render(); + expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull(); + expect(screen.queryByTestId('attackDiscovery-tour-step-2')).toBeNull(); + }); + + it('should not render any tour steps when tour is on step 2 and page is not attack discovery', () => { + storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, { + currentTourStep: 2, + isTourActive: true, + }); + const { debug } = render(); + expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull(); + debug(); + }); + + it('should render tour step 1 when element is mounted', async () => { + const { getByTestId } = render(); + + expect(getByTestId('attackDiscovery-tour-step-1')).toBeInTheDocument(); + }); + + it('should render tour video when tour is on step 2 and page is attack discovery', () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { ...mockRouteSpy, pageName: SecurityPageName.attackDiscovery }, + ]); + storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, { + currentTourStep: 2, + isTourActive: true, + }); + const { getByTestId } = render(); + expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull(); + expect(getByTestId('attackDiscovery-tour-step-2')).toBeInTheDocument(); + }); + + it('should advance to tour step 2 when page is attack discovery', () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { ...mockRouteSpy, pageName: SecurityPageName.attackDiscovery }, + ]); + storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, { + currentTourStep: 1, + isTourActive: true, + }); + render(); + expect( + storageMock.get(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY).currentTourStep + ).toEqual(2); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/tour/index.tsx new file mode 100644 index 0000000000000..ed43c5bebd30a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/tour/index.tsx @@ -0,0 +1,128 @@ +/* + * 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. + */ + +/* + * The attack discovery tour for 8.14 + * + * */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiTourStep } from '@elastic/eui'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; +import { VideoToast } from './video_toast'; +import { useIsElementMounted } from '../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted'; +import { NEW_FEATURES_TOUR_STORAGE_KEYS, SecurityPageName } from '../../../common/constants'; +import { useKibana, useNavigation } from '../../common/lib/kibana'; +import { attackDiscoveryTourStepOne, tourConfig } from './step_config'; +import * as i18n from './translations'; + +interface TourState { + currentTourStep: number; + isTourActive: boolean; +} + +const AttackDiscoveryTourComp = () => { + const { + services: { storage }, + } = useKibana(); + + const { navigateTo } = useNavigation(); + const [{ pageName }] = useRouteSpy(); + const [tourState, setTourState] = useState( + storage.get(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY) ?? tourConfig + ); + + const advanceToVideoStep = useCallback(() => { + setTourState((prev) => { + storage.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, { + ...prev, + currentTourStep: 2, + }); + return { + ...prev, + currentTourStep: 2, + }; + }); + }, [storage]); + + useEffect(() => { + if (tourState.isTourActive && pageName === SecurityPageName.attackDiscovery) { + advanceToVideoStep(); + } + }, [advanceToVideoStep, pageName, tourState.isTourActive]); + + const finishTour = useCallback(() => { + setTourState((prev) => { + storage.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, { + ...prev, + isTourActive: false, + }); + return { + ...prev, + isTourActive: false, + }; + }); + }, [storage]); + + const navigateToAttackDiscovery = useCallback(() => { + navigateTo({ + deepLinkId: SecurityPageName.attackDiscovery, + }); + }, [navigateTo]); + + const nextStep = useCallback(() => { + if (tourState.currentTourStep === 1) { + navigateToAttackDiscovery(); + advanceToVideoStep(); + } + }, [tourState.currentTourStep, navigateToAttackDiscovery, advanceToVideoStep]); + + const footerAction = useMemo( + () => [ + // if exit, set tour to the video step without navigating to the page + + {i18n.ATTACK_DISCOVERY_TOUR_EXIT} + , + // if next, set tour to the video step and navigate to the page + + {i18n.ATTACK_DISCOVERY_TRY_IT} + , + ], + [advanceToVideoStep, nextStep] + ); + + const isElementAtCurrentStepMounted = useIsElementMounted(attackDiscoveryTourStepOne?.anchor); + + if ( + !tourState.isTourActive || + (tourState.currentTourStep === 1 && !isElementAtCurrentStepMounted) + ) { + return null; + } + + return tourState.currentTourStep === 1 ? ( + + ) : pageName === SecurityPageName.attackDiscovery ? ( + + ) : null; +}; + +export const AttackDiscoveryTour = React.memo(AttackDiscoveryTourComp); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/overview.gif b/x-pack/plugins/security_solution/public/attack_discovery/tour/overview.gif new file mode 100644 index 0000000000000..4cf07dfecd6a9 Binary files /dev/null and b/x-pack/plugins/security_solution/public/attack_discovery/tour/overview.gif differ diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/step_config.tsx b/x-pack/plugins/security_solution/public/attack_discovery/tour/step_config.tsx new file mode 100644 index 0000000000000..6984cbd8542df --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/tour/step_config.tsx @@ -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 * as i18n from './translations'; + +export const ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS = { + NAV_LINK: 'solutionSideNavItemLink-attack_discovery', +}; + +export const attackDiscoveryTourStepOne = { + title: i18n.ATTACK_DISCOVERY_TOUR_ATTACK_DISCOVERY_TITLE, + content: i18n.ATTACK_DISCOVERY_TOUR_ATTACK_DISCOVERY_DESC, + anchor: ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS.NAV_LINK, +}; + +export const tourConfig = { + currentTourStep: 1, + isTourActive: true, +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/tour/translations.ts new file mode 100644 index 0000000000000..eadfb384297c0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/tour/translations.ts @@ -0,0 +1,59 @@ +/* + * 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 ATTACK_DISCOVERY_TOUR_ATTACK_DISCOVERY_TITLE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.navStep.title', + { + defaultMessage: 'Introducing attack discovery', + } +); + +export const ATTACK_DISCOVERY_TOUR_ATTACK_DISCOVERY_DESC = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.navStep.desc', + { + defaultMessage: + 'Leverage Generative AI to find relationships among your alerts and describe attack chains.', + } +); + +export const ATTACK_DISCOVERY_TOUR_VIDEO_STEP_TITLE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.videoStep.title', + { + defaultMessage: 'Start discovering attacks', + } +); + +export const ATTACK_DISCOVERY_TOUR_VIDEO_STEP_DESC = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.videoStep.desc', + { + defaultMessage: + 'Dive into data-driven attack discoveries and streamline your workflow with our intuitive AI technology, designed to elevate your productivity instantly.', + } +); + +export const ATTACK_DISCOVERY_TOUR_EXIT = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.exit', + { + defaultMessage: 'Close', + } +); + +export const ATTACK_DISCOVERY_TRY_IT = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.tryIt', + { + defaultMessage: 'Try it', + } +); + +export const WATCH_OVERVIEW_VIDEO = i18n.translate( + 'xpack.securitySolution.attackDiscovery.tour.video', + { + defaultMessage: 'Watch overview video', + } +); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/video_toast.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/tour/video_toast.test.tsx new file mode 100644 index 0000000000000..810577cdce5d4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/tour/video_toast.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { VideoToast } from './video_toast'; + +describe('VideoToast', () => { + const onCloseMock = jest.fn(); + beforeEach(() => { + jest.spyOn(window, 'open').mockImplementation(() => null); + render(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should render the video toast', () => { + const videoToast = screen.getByTestId('attackDiscovery-tour-step-2'); + expect(videoToast).toBeInTheDocument(); + }); + + it('should render the video gif', () => { + const videoGif = screen.getByTestId('video-gif'); + expect(videoGif).toBeInTheDocument(); + }); + + it('should open the video in a new tab when the gif is clicked', () => { + const videoGif = screen.getByTestId('video-gif'); + userEvent.click(videoGif); + expect(window.open).toHaveBeenCalledWith( + 'https://videos.elastic.co/watch/BrDaDBAAvdygvemFKNAkBW', + '_blank' + ); + }); + + it('should open the video in a new tab when the "Watch overview video" button is clicked', () => { + const watchVideoButton = screen.getByRole('button', { name: 'Watch overview video' }); + userEvent.click(watchVideoButton); + expect(window.open).toHaveBeenCalledWith( + 'https://videos.elastic.co/watch/BrDaDBAAvdygvemFKNAkBW', + '_blank' + ); + }); + + it('should call the onClose callback when the close button is clicked', () => { + const closeButton = screen.getByTestId('toastCloseButton'); + userEvent.click(closeButton); + expect(onCloseMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/tour/video_toast.tsx b/x-pack/plugins/security_solution/public/attack_discovery/tour/video_toast.tsx new file mode 100644 index 0000000000000..ae44027fa801d --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/tour/video_toast.tsx @@ -0,0 +1,59 @@ +/* + * 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 { + EuiButton, + EuiIcon, + EuiImage, + EuiToast, + EuiPortal, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import * as i18n from './translations'; +import theGif from './overview.gif'; + +const VIDEO_CONTENT_WIDTH = 250; +const VIDEO_PAGE = `https://videos.elastic.co/watch/BrDaDBAAvdygvemFKNAkBW`; + +const VideoComponent: React.FC<{ onClose: () => void }> = ({ onClose }) => { + const openVideoInNewTab = useCallback(() => { + window.open(VIDEO_PAGE, '_blank'); + }, []); + + return ( + +
+ + + +

+ {i18n.ATTACK_DISCOVERY_TOUR_VIDEO_STEP_TITLE} +

+

{i18n.ATTACK_DISCOVERY_TOUR_VIDEO_STEP_DESC}

+
+ + + {i18n.WATCH_OVERVIEW_VIDEO} + +
+
+
+ ); +}; + +export const VideoToast = React.memo(VideoComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx index 47ff3e6e4fad1..6b327df2a847c 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -137,7 +137,7 @@ const usePanelBottomOffset = (): string | undefined => { * Main security navigation component. * It takes the links to render from the generic application `links` configs. */ -export const SecuritySideNav: React.FC = () => { +export const SecuritySideNav: React.FC<{ onMount?: () => void }> = ({ onMount }) => { const items = useSolutionSideNavItems(); const selectedId = useSelectedId(); const panelTopOffset = usePanelTopOffset(); @@ -151,6 +151,7 @@ export const SecuritySideNav: React.FC = () => { { +export const useSecuritySolutionNavigation = ( + onMount: () => void +): KibanaPageTemplateProps['solutionNav'] => { const { chrome } = useKibana().services; const chromeStyle$ = useMemo(() => chrome.getChromeStyle$(), [chrome]); const chromeStyle = useObservable(chromeStyle$, 'classic'); @@ -39,7 +41,7 @@ export const useSecuritySolutionNavigation = (): KibanaPageTemplateProps['soluti canBeCollapsed: true, name: translatedNavTitle, icon: 'logoSecurity', - children: , + children: , closeFlyoutButtonPosition: 'inside', }; };