diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index c58dab3350695..536b3f883cac6 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -62,7 +62,7 @@ pageLoadAssetSize: files: 22673 filesManagement: 18683 fileUpload: 25664 - fleet: 142263 + fleet: 158438 globalSearch: 29696 globalSearchBar: 50403 globalSearchProviders: 25554 diff --git a/x-pack/packages/security-solution/navigation/index.ts b/x-pack/packages/security-solution/navigation/index.ts index e2acac541a014..6088006869153 100644 --- a/x-pack/packages/security-solution/navigation/index.ts +++ b/x-pack/packages/security-solution/navigation/index.ts @@ -9,12 +9,4 @@ export { useGetAppUrl, useNavigateTo, useNavigation } from './src/navigation'; export type { GetAppUrl, NavigateTo } from './src/navigation'; export { NavigationProvider } from './src/context'; export { SecurityPageName, LinkCategoryType } from './src/constants'; -export type { - NavigationLink, - LinkCategories, - LinkCategory, - TitleLinkCategory, - SeparatorLinkCategory, - AccordionLinkCategory, -} from './src/types'; -export { isAccordionLinkCategory, isSeparatorLinkCategory, isTitleLinkCategory } from './src/types'; +export * from './src/types'; diff --git a/x-pack/packages/security-solution/navigation/links.ts b/x-pack/packages/security-solution/navigation/links.ts index cbbe676fcd4ac..e2c4d1766e6b0 100644 --- a/x-pack/packages/security-solution/navigation/links.ts +++ b/x-pack/packages/security-solution/navigation/links.ts @@ -11,6 +11,6 @@ export { withLink, LinkButton, LinkAnchor, - isExternalId, + isSecurityId, } from './src/links'; export type { GetLinkUrl, GetLinkProps, LinkProps } from './src/links'; diff --git a/x-pack/packages/security-solution/navigation/src/constants.ts b/x-pack/packages/security-solution/navigation/src/constants.ts index 6bdef7bb30e51..8698a5dc8ac2a 100644 --- a/x-pack/packages/security-solution/navigation/src/constants.ts +++ b/x-pack/packages/security-solution/navigation/src/constants.ts @@ -10,6 +10,7 @@ export const SECURITY_UI_APP_ID = 'securitySolutionUI' as const; export enum SecurityPageName { administration = 'administration', alerts = 'alerts', + assets = 'assets', blocklist = 'blocklist', /* * Warning: Computed values are not permitted in an enum with string valued members @@ -30,6 +31,7 @@ export enum SecurityPageName { * Warning: Computed values are not permitted in an enum with string valued members * All cloud defend page names must match `CloudDefendPageId` in x-pack/plugins/cloud_defend/public/common/navigation/types.ts */ + cloudDefend = 'cloud_defend', cloudDefendPolicies = 'cloud_defend-policies', dashboards = 'dashboards', dataQuality = 'data_quality', @@ -44,7 +46,7 @@ export enum SecurityPageName { hostsAnomalies = 'hosts-anomalies', hostsRisk = 'hosts-risk', hostsEvents = 'hosts-events', - investigate = 'investigate', + investigations = 'investigations', kubernetes = 'kubernetes', landing = 'get_started', mlLanding = 'machine_learning-landing', // serverless only @@ -57,6 +59,7 @@ export enum SecurityPageName { noPage = '', overview = 'overview', policies = 'policy', + projectSettings = 'project_settings', responseActionsHistory = 'response_actions_history', rules = 'rules', rulesAdd = 'rules-add', diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/index.ts b/x-pack/packages/security-solution/navigation/src/landing_links/index.ts index 31fcb32783062..9a362ee74fdc2 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/index.ts +++ b/x-pack/packages/security-solution/navigation/src/landing_links/index.ts @@ -4,12 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +export type { LandingLinksIconsCategoriesGroupsProps } from './landing_links_icons_categories_groups'; export type { LandingLinksIconsProps } from './landing_links_icons'; export type { LandingLinksIconsCategoriesProps } from './landing_links_icons_categories'; +export type { LandingLinksIconsGroupsProps } from './landing_links_icons_groups'; export type { LandingLinksImagesProps } from './landing_links_images'; export { + LandingLinksIconsCategoriesGroups, LandingLinksIcons, LandingLinksIconsCategories, + LandingLinksIconsGroups, LandingLinksImages, LandingLinksImageCards, } from './lazy'; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links.test.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links.test.tsx new file mode 100644 index 0000000000000..ee543b759d83a --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 } from '@testing-library/react'; +import { SecurityPageName } from '../constants'; +import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation'; +import { LandingColumnLinks } from './landing_links'; +import type { NavigationLink } from '../types'; + +jest.mock('../navigation'); + +mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`); +const mockOnLinkClick = jest.fn(); + +const NAV_ITEM: NavigationLink = { + id: SecurityPageName.dashboards, + title: 'TEST LABEL', + description: 'TEST DESCRIPTION', + landingIcon: 'myTestIcon', +}; +const NAV_ITEM_2: NavigationLink = { + id: SecurityPageName.alerts, + title: 'TEST LABEL 2', + description: 'TEST DESCRIPTION 2', + landingIcon: 'myTestIcon', +}; + +describe('LandingColumnLinks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render items', () => { + const { queryByText } = render(); + + expect(queryByText(NAV_ITEM.title)).toBeInTheDocument(); + expect(queryByText(NAV_ITEM_2.title)).toBeInTheDocument(); + }); + + it('should navigate link', () => { + const { getByText } = render(); + + getByText(NAV_ITEM.title).click(); + + expect(mockGetAppUrl).toHaveBeenCalledWith({ + deepLinkId: NAV_ITEM.id, + absolute: false, + path: '', + }); + expect(mockNavigateTo).toHaveBeenCalled(); + }); + + it('should add urlState to link', () => { + const testUrlState = '?some=parameter&and=another'; + const { getByText } = render(); + + getByText(NAV_ITEM.title).click(); + + expect(mockGetAppUrl).toHaveBeenCalledWith({ + deepLinkId: NAV_ITEM.id, + absolute: false, + path: testUrlState, + }); + expect(mockNavigateTo).toHaveBeenCalled(); + }); + + it('should call onLinkClick', () => { + const id = SecurityPageName.administration; + const title = 'myTestLabel'; + + const { getByText } = render( + + ); + + getByText(title).click(); + + expect(mockOnLinkClick).toHaveBeenCalledWith(id); + }); +}); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links.tsx new file mode 100644 index 0000000000000..ef0753dfa1440 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links.tsx @@ -0,0 +1,92 @@ +/* + * 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 { + EuiLink, + EuiFlexGroup, + EuiFlexItem, + useEuiTheme, + type EuiLinkButtonProps, + type EuiLinkAnchorProps, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { LinkAnchor } from '../links'; +import type { NavigationLink } from '../types'; +import { getKibanaLinkProps } from './utils'; + +type LandingLinkProps = EuiLinkAnchorProps & + EuiLinkButtonProps & { + item: NavigationLink; + urlState?: string; + onLinkClick?: (id: string) => void; + }; + +// Renders a link to either an external URL or an internal Kibana URL +export const LandingLink: React.FC = React.memo(function LandingLink({ + item, + urlState, + onLinkClick, + children, + ...rest +}) { + if (item.externalUrl != null) { + // Link to outside Kibana + const linkProps: EuiLinkAnchorProps = { + target: '_blank', + external: true, + href: item.externalUrl, + ...(onLinkClick && !item.disabled && { onClick: () => onLinkClick(item.id) }), + ...rest, + }; + return {children}; + } else { + // Kibana link + const linkProps = { + ...getKibanaLinkProps({ item, urlState, onLinkClick }), + ...rest, + }; + return {children}; + } +}); + +interface LandingLinksProps { + items: NavigationLink[]; + urlState?: string; + onLinkClick?: (id: string) => void; +} + +const useSubLinkStyles = () => { + const { euiTheme } = useEuiTheme(); + return { + container: css` + margin-top: ${euiTheme.size.base}; + `, + }; +}; + +// Renders a list of links in a column layout +export const LandingColumnLinks: React.FC = React.memo( + function LandingColumnLinks({ items, urlState, onLinkClick }) { + const subLinkStyles = useSubLinkStyles(); + return ( + + {items.map((subItem) => ( + + + {subItem.title} + + + ))} + + ); + } +); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons.tsx index fc5943f645bd7..b81b25144fcd0 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons.tsx @@ -7,25 +7,28 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { isExternalId, LinkAnchor, type WrappedLinkProps } from '../links'; import type { NavigationLink } from '../types'; import { BetaBadge } from './beta_badge'; +import { LandingLink } from './landing_links'; export interface LandingLinksIconsProps { - items: NavigationLink[]; + items: Readonly; + urlState?: string; + onLinkClick?: (id: string) => void; +} +export interface LandingLinkIconProps { + item: NavigationLink; urlState?: string; onLinkClick?: (id: string) => void; } -const useStyles = () => { +const useLinkIconStyles = () => { const { euiTheme } = useEuiTheme(); return { - container: css` - min-width: 22em; - `, title: css` + min-height: ${euiTheme.size.l}; margin-top: ${euiTheme.size.m}; - margin-bottom: ${euiTheme.size.s}; + margin-bottom: ${euiTheme.size.xs}; `, description: css` max-width: 22em; @@ -33,63 +36,75 @@ const useStyles = () => { }; }; +export const LandingLinkIcon: React.FC = React.memo(function LandingLinkIcon({ + item, + urlState, + onLinkClick, + children, +}) { + const styles = useLinkIconStyles(); + const { title, description, landingIcon, isBeta, betaOptions } = item; + + return ( + + + + + + + + + + + {title} + + + {isBeta && ( + + + + )} + + + + + + {description} + + + {children} + + ); +}); + +const linkIconContainerStyles = css` + min-width: 22em; +`; export const LandingLinksIcons: React.FC = ({ items, urlState, onLinkClick, }) => { - const styles = useStyles(); return ( - {items.map(({ id, title, description, landingIcon, isBeta, betaOptions, skipUrlState }) => { - const linkProps: WrappedLinkProps = { - id, - ...(!isExternalId(id) && !skipUrlState && { urlState }), - ...(onLinkClick && { onClick: () => onLinkClick(id) }), - }; - return ( - - - - - - - - - - - -

{title}

-
-
- {isBeta && ( - - - - )} -
-
-
- - - {description} - - -
-
- ); - })} + {items.map((item) => ( + + + + ))}
); }; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories.tsx index a999d51b2b107..a5bd198e3749c 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories.tsx @@ -7,13 +7,14 @@ import React, { useMemo } from 'react'; import { css } from '@emotion/react'; import { EuiHorizontalRule, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; -import type { NavigationLink, LinkCategory } from '../types'; +import type { NavigationLink, LinkCategories } from '../types'; import { LandingLinksIcons } from './landing_links_icons'; import { LinkCategoryType } from '../constants'; export interface LandingLinksIconsCategoriesProps { links: Readonly; - categories: Readonly; + /** Only `title` and `separator` category types supported */ + categories: Readonly; urlState?: string; onLinkClick?: (id: string) => void; } @@ -36,13 +37,13 @@ export const LandingLinksIconsCategories: React.FC [link.id, link])); return categories.reduce((acc, { label, linkIds, type }) => { - const linksItem = linkIds.reduce((linksAcc, linkId) => { + const linksItem = linkIds?.reduce((linksAcc, linkId) => { if (linksById[linkId]) { linksAcc.push(linksById[linkId]); } return linksAcc; }, []); - if (linksItem.length > 0) { + if (linksItem?.length) { acc.push({ type, label, links: linksItem }); } return acc; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_goups.test.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_goups.test.tsx new file mode 100644 index 0000000000000..e18b8b65e7bf9 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_goups.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 } from '@testing-library/react'; +import { LinkCategoryType, SecurityPageName } from '../constants'; +import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation'; +import type { AccordionLinkCategory, NavigationLink, TitleLinkCategory } from '../types'; +import { LandingLinksIconsCategoriesGroups } from './landing_links_icons_categories_groups'; + +jest.mock('../navigation'); + +mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`); +const mockOnLinkClick = jest.fn(); + +const rulesLink = { + id: SecurityPageName.rules, + title: '', + description: '', + landingIcon: 'testIcon1', +}; +const exceptionsLink = { + id: SecurityPageName.exceptions, + title: '', + description: '', + landingIcon: 'testIcon2', +}; +const hostsLink = { + id: SecurityPageName.hosts, + title: '', + description: '', + landingIcon: 'testIcon3', +}; +const rulesSubCategory: TitleLinkCategory = { + label: '', + iconType: 'categoryIcon1', + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], +}; +const hostsSubCategory: TitleLinkCategory = { + label: '', + iconType: 'categoryIcon2', + linkIds: [SecurityPageName.hosts], +}; +const category: AccordionLinkCategory = { + label: '', + type: LinkCategoryType.accordion, + categories: [rulesSubCategory, hostsSubCategory], +}; + +const categories = [category]; +const links: NavigationLink[] = [rulesLink, exceptionsLink, hostsLink]; + +describe('LandingLinksIconsCategoriesGroups', () => { + it('should render items', () => { + const { queryByText } = render( + + ); + + expect(queryByText(category.label)).toBeInTheDocument(); + expect(queryByText(rulesSubCategory.label)).toBeInTheDocument(); + expect(queryByText(rulesLink.title)).toBeInTheDocument(); + expect(queryByText(exceptionsLink.title)).toBeInTheDocument(); + expect(queryByText(hostsSubCategory.label)).toBeInTheDocument(); + expect(queryByText(hostsLink.title)).toBeInTheDocument(); + }); + + it('should render categories', () => { + const { queryByText } = render( + + ); + expect(queryByText(rulesSubCategory.label)).toBeInTheDocument(); + expect(queryByText(hostsSubCategory.label)).toBeInTheDocument(); + }); + + it('should render items in the same order as defined', () => { + const { queryAllByTestId } = render( + + ); + + const renderedItems = queryAllByTestId('LandingSubItem'); + + expect(renderedItems[0]).toHaveTextContent(rulesLink.title); + expect(renderedItems[1]).toHaveTextContent(exceptionsLink.title); + expect(renderedItems[2]).toHaveTextContent(hostsLink.title); + }); + + it('should not render category items that are not present in links', () => { + const testLinks = [links[0], links[1]]; // no hosts + const { queryByText } = render( + + ); + + expect(queryByText(exceptionsLink.title)).toBeInTheDocument(); + expect(queryByText(rulesLink.title)).toBeInTheDocument(); + expect(queryByText(hostsLink.title)).not.toBeInTheDocument(); + }); + + it('should not render category if all items filtered', () => { + const testLinks = [rulesLink, exceptionsLink]; // no hosts + const { queryByText } = render( + + ); + + expect(queryByText(rulesSubCategory.label)).toBeInTheDocument(); + expect(queryByText(rulesLink.title)).toBeInTheDocument(); + expect(queryByText(exceptionsLink.title)).toBeInTheDocument(); + + expect(queryByText(hostsSubCategory.label)).not.toBeInTheDocument(); + expect(queryByText(hostsLink.title)).not.toBeInTheDocument(); + }); + + it('should navigate link', () => { + const { getByText } = render(); + + getByText(rulesLink.title).click(); + + expect(mockGetAppUrl).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.rules, + absolute: false, + path: '', + }); + expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/rules' }); + }); + + it('should call onLinkClick', () => { + const { getByText } = render( + + ); + getByText(rulesLink.title).click(); + expect(mockOnLinkClick).toHaveBeenCalledWith(SecurityPageName.rules); + }); +}); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_groups.stories.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_groups.stories.tsx new file mode 100644 index 0000000000000..b65b6ab3aa7f9 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_groups.stories.tsx @@ -0,0 +1,141 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import type { AccordionLinkCategory, NavigationLink } from '../types'; +import type { LandingLinksIconsCategoriesGroupsProps } from './landing_links_icons_categories_groups'; +import { LandingLinksIconsCategoriesGroups as LandingLinksIconsCategoriesGroupsComponent } from './landing_links_icons_categories_groups'; +import { NavigationProvider } from '../context'; +import { LinkCategoryType } from '../constants'; + +const items: NavigationLink[] = [ + { + id: 'link1', + title: 'link #1', + description: 'This is the description of the link #1', + landingIcon: 'addDataApp', + }, + { + id: 'link2', + title: 'link #2', + description: 'This is the description of the link #2', + isBeta: true, + landingIcon: 'securityAnalyticsApp', + }, + { + id: 'link3', + title: 'link #3', + description: 'This is the description of the link #3', + landingIcon: 'spacesApp', + }, + { + id: 'link4', + title: 'link #4', + description: 'This is the description of the link #4', + landingIcon: 'appSearchApp', + }, + { + id: 'link5', + title: 'link #5', + description: 'This is the description of the link #5', + landingIcon: 'heartbeatApp', + }, + { + id: 'link6', + title: 'link #6', + description: 'This is the description of the link #6', + landingIcon: 'lensApp', + }, + { + id: 'link7', + title: 'link #7', + description: 'This is the description of the link #7', + landingIcon: 'timelionApp', + }, + { + id: 'link8', + title: 'link #8', + description: 'This is the description of the link #8', + landingIcon: 'managementApp', + }, +]; + +const categories: AccordionLinkCategory[] = [ + { + type: LinkCategoryType.accordion, + label: 'Main accordion category', + categories: [ + { + label: 'First subcategory', + iconType: 'logoAppSearch', + linkIds: ['link1', 'link2', 'link3'], + }, + { + label: 'Second subcategory', + iconType: 'logoUptime', + linkIds: ['link4'], + }, + { + label: 'Third subcategory', + iconType: 'logoLogstash', + linkIds: ['link5', 'link6', 'link7', 'link8'], + }, + ], + }, +]; + +export default { + title: 'Landing Links/Landing Links Icons Categories Groups', + description: + 'Renders collapsible categories with the links grouped by nested categories with icons.', + decorators: [ + (storyFn: Function) => ( +
+ {storyFn()} +
+ ), + ], +}; + +const mockCore = { + application: { + navigateToApp: () => {}, + getUrlForApp: () => '#', + }, +} as unknown as CoreStart; + +export const LandingLinksIconsCategoriesGroups = ( + params: LandingLinksIconsCategoriesGroupsProps +) => ( +
+ + + +
+); + +LandingLinksIconsCategoriesGroups.argTypes = { + links: { + control: 'object', + defaultValue: items, + }, + categories: { + control: 'object', + defaultValue: categories, + }, +}; + +LandingLinksIconsCategoriesGroups.parameters = { + layout: 'fullscreen', +}; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_groups.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_groups.tsx new file mode 100644 index 0000000000000..0ad998ceebfe2 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_categories_groups.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, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import { + useEuiTheme, + useEuiFontSize, + EuiAccordion, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTitle, + type IconType, +} from '@elastic/eui'; +import type { NavigationLink, TitleLinkCategory, AccordionLinkCategory } from '../types'; +import { LandingColumnLinks } from './landing_links'; + +export interface LandingLinksIconsCategoriesGroupsProps { + links: Readonly; + /** Only accordion category type supported */ + categories: Readonly; + urlState?: string; + onLinkClick?: (id: string) => void; +} + +const stackManagementButtonClassName = 'stackManagementSection__button'; +const useStyle = () => { + const { euiTheme } = useEuiTheme(); + const accordionFontSize = useEuiFontSize('xs'); + return { + accordionButton: css` + .${stackManagementButtonClassName} { + font-weight: ${euiTheme.font.weight.bold}; + ${accordionFontSize} + }} +`, + }; +}; + +export const LandingLinksIconsCategoriesGroups: React.FC = + React.memo(function LandingLinksIconsCategoriesGroups({ + links, + categories: accordionCategories, + urlState, + onLinkClick, + }) { + const style = useStyle(); + return ( + <> + {accordionCategories.map(({ label, categories }, index) => ( + + + + {categories && ( + + )} + {/* This component can be extended to render LandingLinksIcons when `linkIds` is defined in the accordionCategory */} + + + ))} + + ); + }); + +interface LandingLinksIconsCategoryGroupsProps { + links: Readonly; + categories: Readonly; + urlState?: string; + onLinkClick?: (id: string) => void; +} + +type CategoriesLinks = Array< + Pick & { links: NavigationLink[] } +>; + +const useGroupStyles = () => { + return { + container: css` + min-width: 22em; + `, + }; +}; +const LandingLinksIconsCategoryGroups: React.FC = React.memo( + function LandingLinksIconsCategoryGroups({ links, categories, urlState, onLinkClick }) { + const styles = useGroupStyles(); + + const categoriesLinks = useMemo(() => { + const linksById = Object.fromEntries(links.map((link) => [link.id, link])); + + return categories.reduce((acc, { label, linkIds, type, iconType }) => { + const linksItem = linkIds.reduce((linksAcc, linkId) => { + if (linksById[linkId]) { + linksAcc.push(linksById[linkId]); + } + return linksAcc; + }, []); + if (linksItem.length > 0) { + acc.push({ type, label, iconType, links: linksItem }); + } + return acc; + }, []); + }, [links, categories]); + + return ( + + {categoriesLinks.map(({ label, links: categoryLinks, iconType }, index) => ( + + + + + + ))} + + ); + } +); + +const LandingColumnHeading: React.FC<{ + label?: string; + iconType?: IconType; +}> = React.memo(function LandingColumnHeading({ label, iconType }) { + return ( + + {iconType && ( + + + + )} + + +

{label}

+
+
+
+ ); +}); + +// eslint-disable-next-line import/no-default-export +export default LandingLinksIconsCategoriesGroups; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.stories.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.stories.tsx new file mode 100644 index 0000000000000..d751bae4983d9 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.stories.tsx @@ -0,0 +1,113 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import type { NavigationLink } from '../types'; +import type { LandingLinksIconsGroupsProps } from './landing_links_icons_groups'; +import { LandingLinksIconsGroups as LandingLinksIconsGroupsComponent } from './landing_links_icons_groups'; +import { NavigationProvider } from '../context'; + +const items: NavigationLink[] = [ + { + id: 'link1', + title: 'link #1', + description: 'This is the description of the link #1', + landingIcon: 'addDataApp', + }, + { + id: 'link2', + title: 'link #2', + description: 'This is the description of the link #2', + isBeta: true, + landingIcon: 'securityAnalyticsApp', + links: [ + { + id: 'link3', + title: 'link #3', + description: 'This is the description of the link #3', + landingIcon: 'spacesApp', + }, + { + id: 'link4', + title: 'link #4', + description: 'This is the description of the link #4', + landingIcon: 'appSearchApp', + }, + ], + }, + { + id: 'link5', + title: 'link #5', + description: 'This is the description of the link #5', + landingIcon: 'heartbeatApp', + links: [ + { + id: 'link6', + title: 'link #6', + description: 'This is the description of the link #6', + landingIcon: 'lensApp', + }, + { + id: 'link7', + title: 'link #7', + description: 'This is the description of the link #7', + landingIcon: 'timelionApp', + }, + { + id: 'link8', + title: 'link #8', + description: 'This is the description of the link #8', + landingIcon: 'managementApp', + }, + ], + }, +]; + +export default { + title: 'Landing Links/Landing Links Icons Groups', + description: 'Renders the links with icons with links grouped.', + decorators: [ + (storyFn: Function) => ( +
+ {storyFn()} +
+ ), + ], +}; + +const mockCore = { + application: { + navigateToApp: () => {}, + getUrlForApp: () => '#', + }, +} as unknown as CoreStart; + +export const LandingLinksIconsGroups = (params: LandingLinksIconsGroupsProps) => ( +
+ + + +
+); + +LandingLinksIconsGroups.argTypes = { + items: { + control: 'object', + defaultValue: items, + }, +}; + +LandingLinksIconsGroups.parameters = { + layout: 'fullscreen', +}; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.test.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.test.tsx new file mode 100644 index 0000000000000..bb0d6f9494923 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.test.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 React from 'react'; +import { render } from '@testing-library/react'; +import { SecurityPageName } from '../constants'; +import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation'; +import type { NavigationLink } from '../types'; +import { LandingLinksIconsGroups } from './landing_links_icons_groups'; + +jest.mock('../navigation'); + +mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`); +const mockOnLinkClick = jest.fn(); + +const items: NavigationLink[] = [ + { + id: SecurityPageName.dashboards, + title: 'dashboards title', + description: 'dashboards description', + landingIcon: 'testIcon1', + }, + { + id: SecurityPageName.rules, + title: 'rules title', + description: 'rules description', + landingIcon: 'testIcon1', + links: [ + { + id: SecurityPageName.exceptions, + title: 'exceptions title', + description: 'exceptions description', + landingIcon: 'testIcon2', + }, + ], + }, + { + id: SecurityPageName.network, + title: 'network title', + description: 'network description', + landingIcon: 'testIcon3', + links: [ + { + id: SecurityPageName.hosts, + title: 'hosts title', + description: 'hosts description', + }, + { + id: SecurityPageName.users, + title: 'users title', + description: 'users description', + }, + ], + }, +]; + +describe('LandingLinksIconsGroups', () => { + it('should render main items with description', () => { + const { queryByText } = render(); + + expect(queryByText('rules title')).toBeInTheDocument(); + expect(queryByText('rules description')).toBeInTheDocument(); + expect(queryByText('network title')).toBeInTheDocument(); + expect(queryByText('network description')).toBeInTheDocument(); + expect(queryByText('dashboards title')).toBeInTheDocument(); + expect(queryByText('dashboards description')).toBeInTheDocument(); + }); + + it('should render grouped single links', () => { + const { queryByText } = render(); + + expect(queryByText('exceptions title')).toBeInTheDocument(); + expect(queryByText('exceptions description')).not.toBeInTheDocument(); + expect(queryByText('hosts title')).toBeInTheDocument(); + expect(queryByText('hosts description')).not.toBeInTheDocument(); + expect(queryByText('users title')).toBeInTheDocument(); + expect(queryByText('users description')).not.toBeInTheDocument(); + }); + + it('should render items in the same order as defined', () => { + const { queryAllByTestId } = render(); + + const renderedItems = queryAllByTestId('LandingItem'); + expect(renderedItems[0]).toHaveTextContent('dashboards title'); + expect(renderedItems[1]).toHaveTextContent('rules title'); + expect(renderedItems[2]).toHaveTextContent('network title'); + + const renderedSubItems = queryAllByTestId('LandingSubItem'); + expect(renderedSubItems[0]).toHaveTextContent('exceptions title'); + expect(renderedSubItems[1]).toHaveTextContent('hosts title'); + expect(renderedSubItems[2]).toHaveTextContent('users title'); + }); + + it('should navigate link', () => { + const { getByText } = render(); + + getByText('rules title').click(); + + expect(mockGetAppUrl).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.rules, + absolute: false, + path: '', + }); + expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/rules' }); + }); + + it('should call onLinkClick', () => { + const { getByText } = render( + + ); + getByText('rules title').click(); + expect(mockOnLinkClick).toHaveBeenCalledWith(SecurityPageName.rules); + }); +}); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.tsx new file mode 100644 index 0000000000000..836ce6b29df82 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_icons_groups.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; +import type { NavigationLink } from '../types'; +import { LandingLinkIcon } from './landing_links_icons'; +import { LandingColumnLinks } from './landing_links'; + +export interface LandingLinksIconsGroupsProps { + items: NavigationLink[]; + urlState?: string; + onLinkClick?: (id: string) => void; +} + +export interface LandingSubLinkProps { + item: NavigationLink; + urlState?: string; + onLinkClick?: (id: string) => void; +} + +export const LandingLinksIconsGroups: React.FC = React.memo( + function LandingLinksIconsGroups({ items, urlState, onLinkClick }) { + return ( + + {items.map(({ links, ...link }) => ( + + {links?.length && ( + + )} + + ))} + + ); + } +); + +// eslint-disable-next-line import/no-default-export +export default LandingLinksIconsGroups; diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images.tsx index 30afd857d4483..18c8ef07c10cb 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images.tsx @@ -15,10 +15,12 @@ import { } from '@elastic/eui'; import React from 'react'; import { css } from '@emotion/react'; -import { isExternalId, LinkAnchor, type WrappedLinkProps } from '../links'; -import { BetaBadge } from './beta_badge'; +import { LinkAnchor } from '../links'; import type { NavigationLink } from '../types'; +import { BetaBadge } from './beta_badge'; +import { getKibanaLinkProps } from './utils'; +const noop = () => {}; export interface LandingLinksImagesProps { items: NavigationLink[]; urlState?: string; @@ -60,48 +62,43 @@ export const LandingLinksImages: React.FC = React.memo( const styles = useStyles(); return ( - {items.map( - ({ id, title, description, landingImage, isBeta, betaOptions, skipUrlState }) => { - const linkProps: WrappedLinkProps = { - id, - ...(!isExternalId(id) && !skipUrlState && { urlState }), - ...(onLinkClick && { onClick: () => onLinkClick(id) }), - }; - return ( - - - {/* Empty onClick is to force hover style on `EuiPanel` */} - {}}> - - - {landingImage && ( - - )} - - -
- -

{title}

-
- {isBeta && } -
- - {description} - -
-
-
-
-
- ); - } - )} + {items.map((item) => { + const linkProps = getKibanaLinkProps({ item, urlState, onLinkClick }); + const { id, title, description, landingImage, isBeta, betaOptions } = item; + return ( + + + {/* Empty onClick is to force hover style on `EuiPanel` */} + + + + {landingImage && ( + + )} + + +
+ +

{title}

+
+ {isBeta && } +
+ + {description} + +
+
+
+
+
+ ); + })}
); } diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx index 6dd999c6fdcf3..b7faa54202c74 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/landing_links_images_cards.tsx @@ -15,9 +15,10 @@ import { } from '@elastic/eui'; import React from 'react'; import { css } from '@emotion/react'; -import { isExternalId, withLink, type WrappedLinkProps } from '../links'; -import { BetaBadge } from './beta_badge'; +import { withLink } from '../links'; import type { NavigationLink } from '../types'; +import { BetaBadge } from './beta_badge'; +import { getKibanaLinkProps } from './utils'; export interface LandingLinksImagesProps { items: NavigationLink[]; @@ -60,55 +61,50 @@ export const LandingLinksImageCards: React.FC = React.m const styles = useStyles(); return ( - {items.map( - ({ id, landingImage, title, description, isBeta, betaOptions, skipUrlState }) => { - const linkProps: WrappedLinkProps = { - id, - ...(!isExternalId(id) && !skipUrlState && { urlState }), - ...(onLinkClick && { onClick: () => onLinkClick(id) }), - }; - return ( - - - ) - } - title={ -
- -

{title}

-
- {isBeta && } -
- } - description={ - - {description} - - } - /> -
- ); - } - )} + {items.map((item) => { + const linkProps = getKibanaLinkProps({ item, urlState, onLinkClick }); + const { id, landingImage, title, description, isBeta, betaOptions } = item; + return ( + + + ) + } + title={ +
+ +

{title}

+
+ {isBeta && } +
+ } + description={ + + {description} + + } + /> +
+ ); + })}
); } diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/lazy.tsx b/x-pack/packages/security-solution/navigation/src/landing_links/lazy.tsx index 1321fa9cfa445..2a18324749ecf 100644 --- a/x-pack/packages/security-solution/navigation/src/landing_links/lazy.tsx +++ b/x-pack/packages/security-solution/navigation/src/landing_links/lazy.tsx @@ -26,8 +26,18 @@ export const LandingLinksIconsCategories = withSuspense(LandingLinksIconsCategor const LandingLinksIconsLazy = lazy(() => import('./landing_links_icons')); export const LandingLinksIcons = withSuspense(LandingLinksIconsLazy); +const LandingLinksIconsGroupsLazy = lazy(() => import('./landing_links_icons_groups')); +export const LandingLinksIconsGroups = withSuspense(LandingLinksIconsGroupsLazy); + const LandingLinksImagesLazy = lazy(() => import('./landing_links_images')); export const LandingLinksImages = withSuspense(LandingLinksImagesLazy); const LandingLinksImageCardsLazy = lazy(() => import('./landing_links_images_cards')); export const LandingLinksImageCards = withSuspense(LandingLinksImageCardsLazy); + +const LandingLinksIconsCategoriesGroupsLazy = lazy( + () => import('./landing_links_icons_categories_groups') +); +export const LandingLinksIconsCategoriesGroups = withSuspense( + LandingLinksIconsCategoriesGroupsLazy +); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/utils.test.ts b/x-pack/packages/security-solution/navigation/src/landing_links/utils.test.ts new file mode 100644 index 0000000000000..b82b3457a2e37 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/utils.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getKibanaLinkProps } from './utils'; +import * as links from '../links'; +import type { NavigationLink } from '../types'; + +const item: NavigationLink = { + id: 'internal-id', + title: 'some title', + skipUrlState: false, +}; + +const urlState = 'example-url-state'; +const onLinkClick = jest.fn(); + +describe('getWrappedLinkProps', () => { + let isSecurityIdSpy: jest.SpyInstance; + + beforeEach(() => { + // Create a spy on the isSecurityId function before each test + isSecurityIdSpy = jest.spyOn(links, 'isSecurityId'); + }); + + afterEach(() => { + isSecurityIdSpy.mockRestore(); + jest.clearAllMocks(); + }); + + it('returns the correct WrappedLinkProps when id is not external and skipUrlState is false', () => { + const result = getKibanaLinkProps({ item, urlState, onLinkClick }); + + expect(result).toEqual({ + id: item.id, + urlState, + onClick: expect.any(Function), + }); + + expect(isSecurityIdSpy).toHaveBeenCalledWith(item.id); + expect(onLinkClick).not.toHaveBeenCalled(); + + result.onClick?.({} as unknown as React.MouseEvent); + expect(onLinkClick).toHaveBeenCalledWith(item.id); + }); + + it('returns the correct WrappedLinkProps when id is external', () => { + const id = 'external:id'; + const result = getKibanaLinkProps({ item: { ...item, id }, urlState }); + + expect(result).toEqual({ id }); + expect(isSecurityIdSpy).toHaveBeenCalledWith(id); + }); + + it('returns the correct WrappedLinkProps when skipUrlState is true', () => { + const id = 'internal-id'; + const result = getKibanaLinkProps({ item: { ...item, skipUrlState: true }, urlState }); + + expect(result).toEqual({ id }); + expect(isSecurityIdSpy).toHaveBeenCalledWith(id); + }); +}); diff --git a/x-pack/packages/security-solution/navigation/src/landing_links/utils.ts b/x-pack/packages/security-solution/navigation/src/landing_links/utils.ts new file mode 100644 index 0000000000000..8549a0777b2b0 --- /dev/null +++ b/x-pack/packages/security-solution/navigation/src/landing_links/utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { isSecurityId, type WrappedLinkProps } from '../links'; +import type { NavigationLink } from '../types'; + +export const getKibanaLinkProps = ({ + item, + urlState, + onLinkClick, +}: { + item: NavigationLink; + urlState?: string; + onLinkClick?: (id: string) => void; +}): WrappedLinkProps => ({ + id: item.id, + ...(isSecurityId(item.id) && !item.skipUrlState && { urlState }), + ...(onLinkClick && { onClick: () => onLinkClick(item.id) }), +}); diff --git a/x-pack/packages/security-solution/navigation/src/links.test.tsx b/x-pack/packages/security-solution/navigation/src/links.test.tsx index a1ad8b03f1064..8c9bd983e837e 100644 --- a/x-pack/packages/security-solution/navigation/src/links.test.tsx +++ b/x-pack/packages/security-solution/navigation/src/links.test.tsx @@ -11,10 +11,11 @@ import { useGetLinkUrl, useGetLinkProps, withLink, - isExternalId, + isSecurityId, getAppIdsFromId, formatPath, isModified, + concatPaths, } from './links'; import { mockGetAppUrl, mockNavigateTo } from '../mocks/navigation'; @@ -102,23 +103,23 @@ describe('links', () => { }); }); - describe('isExternalId', () => { - it('should return true for an external id', () => { + describe('isSecurityId', () => { + it('should return false for an external id', () => { const id = 'externalAppId:12345'; - const result = isExternalId(id); - expect(result).toBe(true); + const result = isSecurityId(id); + expect(result).toBe(false); }); - it('should return false for an internal id', () => { + it('should return true for an internal id', () => { const id = 'internalId'; - const result = isExternalId(id); - expect(result).toBe(false); + const result = isSecurityId(id); + expect(result).toBe(true); }); - it('should return true for a root external id', () => { + it('should return false for a root external id', () => { const id = 'externalAppId:'; - const result = isExternalId(id); - expect(result).toBe(true); + const result = isSecurityId(id); + expect(result).toBe(false); }); }); @@ -126,19 +127,62 @@ describe('links', () => { it('should return the correct app and deep link ids for an external id', () => { const id = 'externalAppId:12345'; const result = getAppIdsFromId(id); - expect(result).toEqual({ appId: 'externalAppId', deepLinkId: '12345' }); + expect(result).toEqual( + expect.objectContaining({ appId: 'externalAppId', deepLinkId: '12345' }) + ); }); it('should return the correct deep link id for an internal id', () => { const id = 'internalId'; const result = getAppIdsFromId(id); - expect(result).toEqual({ deepLinkId: 'internalId' }); + expect(result).toEqual(expect.objectContaining({ deepLinkId: 'internalId' })); }); it('should return the correct app id for a root external id', () => { const id = 'externalAppId:'; const result = getAppIdsFromId(id); - expect(result).toEqual({ appId: 'externalAppId', deepLinkId: '' }); + expect(result).toEqual(expect.objectContaining({ appId: 'externalAppId', deepLinkId: '' })); + }); + + it('should return the correct path', () => { + expect(getAppIdsFromId('externalAppId:12345')).toEqual({ + appId: 'externalAppId', + deepLinkId: '12345', + path: '', + }); + + expect(getAppIdsFromId('externalAppId:/some/path')).toEqual({ + appId: 'externalAppId', + deepLinkId: '', + path: '/some/path', + }); + + expect(getAppIdsFromId('externalAppId:12345/some/path')).toEqual({ + appId: 'externalAppId', + deepLinkId: '12345', + path: '/some/path', + }); + }); + }); + + describe('concatPaths', () => { + it('should return empty path for undefined or empty paths', () => { + expect(concatPaths(undefined, undefined)).toEqual(''); + expect(concatPaths('', '')).toEqual(''); + }); + it('should return path if sub-path not defined or empty', () => { + expect(concatPaths('/main/path', undefined)).toEqual('/main/path'); + expect(concatPaths('/main/path', '')).toEqual('/main/path'); + }); + it('should return sub-path if path not defined or empty', () => { + expect(concatPaths(undefined, '/some/sub-path')).toEqual('/some/sub-path'); + expect(concatPaths('', '/some/sub-path')).toEqual('/some/sub-path'); + }); + it('should concatenate path and sub-path if defined', () => { + expect(concatPaths('/main/path', '/some/sub-path')).toEqual('/main/path/some/sub-path'); + }); + it('should clean path before merging', () => { + expect(concatPaths('/main/path/', '/some/sub-path')).toEqual('/main/path/some/sub-path'); }); }); diff --git a/x-pack/packages/security-solution/navigation/src/links.tsx b/x-pack/packages/security-solution/navigation/src/links.tsx index 104158b471f6b..97434162b9879 100644 --- a/x-pack/packages/security-solution/navigation/src/links.tsx +++ b/x-pack/packages/security-solution/navigation/src/links.tsx @@ -9,35 +9,30 @@ import React, { type MouseEventHandler, type MouseEvent, useCallback } from 'rea import { EuiButton, EuiLink, type EuiLinkProps } from '@elastic/eui'; import { useGetAppUrl, useNavigateTo } from './navigation'; -export interface WrappedLinkProps { +export interface BaseLinkProps { id: string; path?: string; urlState?: string; } +export type GetLinkUrlProps = BaseLinkProps & { absolute?: boolean }; +export type GetLinkUrl = (params: GetLinkUrlProps) => string; + +export type WrappedLinkProps = BaseLinkProps & { + /** + * Optional `onClick` callback prop. + * It is composed within the returned `onClick` function to perform extra actions when the link is clicked. + * It does not override the navigation action. + **/ + onClick?: MouseEventHandler; +}; +export type GetLinkProps = (params: WrappedLinkProps) => LinkProps; + export interface LinkProps { onClick: MouseEventHandler; href: string; } -export type GetLinkUrl = ( - params: WrappedLinkProps & { - absolute?: boolean; - urlState?: string; - } -) => string; - -export type GetLinkProps = ( - params: WrappedLinkProps & { - /** - * Optional `onClick` callback prop. - * It is composed within the returned `onClick` function to perform extra actions when the link is clicked. - * It does not override the navigation operation. - **/ - onClick?: MouseEventHandler; - } -) => LinkProps; - /** * It returns the `url` to use in link `href`. */ @@ -45,9 +40,10 @@ export const useGetLinkUrl = () => { const { getAppUrl } = useGetAppUrl(); const getLinkUrl = useCallback( - ({ id, path = '', absolute = false, urlState }) => { + ({ id, path: subPath = '', absolute = false, urlState }) => { + const { appId, deepLinkId, path: mainPath = '' } = getAppIdsFromId(id); + const path = concatPaths(mainPath, subPath); const formattedPath = urlState ? formatPath(path, urlState) : path; - const { appId, deepLinkId } = getAppIdsFromId(id); return getAppUrl({ deepLinkId, appId, path: formattedPath, absolute }); }, [getAppUrl] @@ -91,9 +87,8 @@ export const useGetLinkProps = (): GetLinkProps => { */ export const withLink = >( Component: React.ComponentType -): React.FC> => - // eslint-disable-next-line react/display-name - React.memo(function ({ id, path, urlState, onClick: _onClick, ...rest }) { +): React.FC & WrappedLinkProps> => + React.memo(function WithLink({ id, path, urlState, onClick: _onClick, ...rest }) { const getLink = useGetLinkProps(); const { onClick, href } = getLink({ id, path, urlState, onClick: _onClick }); return ; @@ -115,14 +110,29 @@ export const LinkAnchor = withLink(EuiLink); // Utils -export const isExternalId = (id: string): boolean => id.includes(':'); +// External IDs are in the format `appId:deepLinkId` to match the Chrome NavLinks format. +// Internal Security Solution ids are the deepLinkId, the appId is omitted for convenience. +export const isSecurityId = (id: string): boolean => !id.includes(':'); + +// External links may contain an optional `path` in addition to the `appId` and `deepLinkId`. +// Format: `:/` +export const getAppIdsFromId = ( + id: string +): { appId?: string; deepLinkId?: string; path?: string } => { + const [linkId, strippedPath] = id.split(/\/(.*)/); // split by the first `/` character + const path = strippedPath ? `/${strippedPath}` : ''; + if (!isSecurityId(linkId)) { + const [appId, deepLinkId] = linkId.split(':'); + return { appId, deepLinkId, path }; + } + return { deepLinkId: linkId, path }; // undefined `appId` for internal Security Solution links +}; -export const getAppIdsFromId = (id: string): { appId?: string; deepLinkId?: string } => { - if (isExternalId(id)) { - const [appId, deepLinkId] = id.split(':'); - return { appId, deepLinkId }; +export const concatPaths = (path: string | undefined, subPath: string | undefined) => { + if (path && subPath) { + return `${path.replace(/\/$/, '')}/${subPath.replace(/^\//, '')}`; } - return { deepLinkId: id }; // undefined `appId` for internal Security Solution links + return path || subPath || ''; }; export const formatPath = (path: string, urlState: string) => { diff --git a/x-pack/packages/security-solution/navigation/src/types.ts b/x-pack/packages/security-solution/navigation/src/types.ts index 655320fbb5757..fb6d84203bccd 100644 --- a/x-pack/packages/security-solution/navigation/src/types.ts +++ b/x-pack/packages/security-solution/navigation/src/types.ts @@ -12,6 +12,7 @@ export interface NavigationLink { categories?: LinkCategories; description?: string; disabled?: boolean; + externalUrl?: string; id: T; landingIcon?: IconType; landingImage?: string; @@ -27,23 +28,28 @@ export interface NavigationLink { } export interface LinkCategory { - linkIds: readonly T[]; + linkIds?: readonly T[]; label?: string; type?: LinkCategoryType; + iconType?: IconType; + categories?: Array>; // nested categories are only supported by accordion type } export interface TitleLinkCategory extends LinkCategory { type?: LinkCategoryType.title; + linkIds: readonly T[]; label: string; } export interface AccordionLinkCategory extends LinkCategory { type: LinkCategoryType.accordion; label: string; + categories?: Array>; } export interface SeparatorLinkCategory extends LinkCategory { type: LinkCategoryType.separator; + linkIds: readonly T[]; } export type LinkCategories = Readonly>>; diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav.stories.tsx b/x-pack/packages/security-solution/side_nav/src/solution_side_nav.stories.tsx index 8378d4f491f9c..4ac035f717c83 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav.stories.tsx +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav.stories.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { SolutionNav } from '@kbn/shared-ux-page-solution-nav'; +import { LinkCategoryType } from '@kbn/security-solution-navigation'; import readme from '../../README.mdx'; import { SolutionSideNav as SolutionSideNavComponent, @@ -35,6 +36,13 @@ const items: SolutionSideNavItem[] = [ }, { id: 'panelLink2', + label: 'I am an external link that opens in a new tab', + href: '#', + openInNewTab: true, + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + { + id: 'panelLink3', label: 'I have an icon', iconType: 'logoVulnerabilityManagement', href: '#', @@ -57,6 +65,15 @@ const items: SolutionSideNavItem[] = [ text: 'Technical Preview', }, }, + { + id: 'panelLinkAll', + label: 'I have all things', + href: '#', + iconType: 'logoSiteSearch', + openInNewTab: true, + isBeta: true, + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, ], }, { @@ -64,31 +81,55 @@ const items: SolutionSideNavItem[] = [ label: 'I have categories', href: '#', categories: [ - { label: 'First Category', linkIds: ['panelCatLink1', 'panelCatLink2'] }, - { label: 'Second Category', linkIds: ['panelCatLink3', 'panelCatLink4'] }, + { type: LinkCategoryType.separator, linkIds: ['panelCatLink1'] }, + { + type: LinkCategoryType.title, + label: 'Title Category', + linkIds: ['panelCatLink2', 'panelCatLink3'], + }, + { + type: LinkCategoryType.accordion, + label: 'ACCORDION CATEGORY', + categories: [ + { label: 'Nested Category', linkIds: ['panelCatLink4', 'panelCatLink5'] }, + { label: 'Second Nested', linkIds: ['panelCatLink6'] }, + ], + }, ], items: [ { id: 'panelCatLink1', - label: 'I am the first nested', + label: 'I am in a separator category', href: '#', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', }, { id: 'panelCatLink2', - label: 'I am the second nested', + label: 'I am in a title category', href: '#', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', }, { id: 'panelCatLink3', - label: 'I am the third nested', + label: 'Me too', href: '#', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', }, { id: 'panelCatLink4', - label: 'I am the fourth nested', + label: 'I am in an accordion category', + href: '#', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + { + id: 'panelCatLink5', + label: 'Me too', + href: '#', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + { + id: 'panelCatLink6', + label: 'I am another nested sub-category', href: '#', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', }, 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 dfae852660332..d146985aadf7a 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 @@ -22,7 +22,7 @@ import partition from 'lodash/fp/partition'; import classNames from 'classnames'; import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; -import type { LinkCategories, SeparatorLinkCategory } from '@kbn/security-solution-navigation'; +import type { SeparatorLinkCategory } from '@kbn/security-solution-navigation'; import { SolutionSideNavPanel } from './solution_side_nav_panel'; import { SolutionSideNavItemPosition } from './types'; import type { SolutionSideNavItem, Tracker } from './types'; @@ -148,7 +148,7 @@ interface SolutionSideNavItemsProps { activePanelNavId: ActivePanelNav; isMobileSize: boolean; onOpenPanelNav: (id: string) => void; - categories?: LinkCategories; + categories?: SeparatorLinkCategory[]; } /** * The Solution side navigation items component. diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.styles.ts b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.styles.ts index 360078fff26eb..ca0f592f96a43 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.styles.ts +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.styles.ts @@ -12,8 +12,7 @@ const EUI_HEADER_HEIGHT = '96px'; const PANEL_LEFT_OFFSET = '249px'; const PANEL_WIDTH = '270px'; -export const panelClass = 'solutionSideNavPanel'; - +export const panelClassName = 'solutionSideNavPanel'; export const SolutionSideNavPanelStyles = ( euiTheme: EuiThemeComputed<{}>, { $bottomOffset, $topOffset }: { $bottomOffset?: string; $topOffset?: string } = {} @@ -75,6 +74,10 @@ export const SolutionSideNavPanelLinksGroupStyles = (euiTheme: EuiThemeComputed< padding-right: 0; `; +export const accordionButtonClassName = 'solutionSideNavPanelAccordion__button'; export const SolutionSideNavCategoryAccordionStyles = (euiTheme: EuiThemeComputed<{}>) => css` - margin-bottom: ${euiTheme.size.s}; + .${accordionButtonClassName} { + font-weight: ${euiTheme.font.weight.bold}; + ${euiFontSize({ euiTheme } as UseEuiTheme<{}>, 'xs')} + }} `; diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.test.tsx b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.test.tsx index 1e532340a26a8..e5ec6efc41d72 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.test.tsx +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.test.tsx @@ -106,7 +106,7 @@ describe('SolutionSideNavPanel', () => { mockCategories.forEach((mockCategory) => { if (!mockCategory.label) return; // omit separator categories - if (mockCategory.linkIds.length) { + if (mockCategory.linkIds?.length) { expect(result.getByText(mockCategory.label)).toBeInTheDocument(); } else { expect(result.queryByText(mockCategory.label)).not.toBeInTheDocument(); @@ -118,7 +118,7 @@ describe('SolutionSideNavPanel', () => { const result = renderNavPanel({ categories: mockCategories }); mockCategories.forEach((mockCategory) => { if (mockCategory.type !== LinkCategoryType.separator) return; // omit non-separator categories - mockCategory.linkIds.forEach((linkId) => { + mockCategory.linkIds?.forEach((linkId) => { expect(result.queryByTestId(`solutionSideNavPanelLink-${linkId}`)).toBeInTheDocument(); }); }); diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx index eaa3027015b79..e04f042f02960 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx @@ -5,12 +5,14 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiFocusTrap, + EuiHorizontalRule, + EuiIcon, EuiListGroup, EuiListGroupItem, EuiOutsideClickDetector, @@ -26,10 +28,13 @@ import { import classNames from 'classnames'; import { METRIC_TYPE } from '@kbn/analytics'; import { - type LinkCategories, isAccordionLinkCategory, isTitleLinkCategory, isSeparatorLinkCategory, + type LinkCategories, + type TitleLinkCategory, + type AccordionLinkCategory, + type SeparatorLinkCategory, } from '@kbn/security-solution-navigation'; import type { SolutionSideNavItem } from './types'; import { BetaBadge } from './beta_badge'; @@ -37,11 +42,12 @@ import { TELEMETRY_EVENT } from './telemetry/const'; import { useTelemetryContext } from './telemetry/telemetry_context'; import { SolutionSideNavPanelStyles, - panelClass, SolutionSideNavCategoryTitleStyles, SolutionSideNavTitleStyles, SolutionSideNavCategoryAccordionStyles, SolutionSideNavPanelLinksGroupStyles, + panelClassName, + accordionButtonClassName, } from './solution_side_nav_panel.styles'; export interface SolutionSideNavPanelProps { @@ -78,7 +84,7 @@ export const SolutionSideNavPanel: React.FC = React.m $bottomOffset, $topOffset, }); - const panelClasses = classNames(panelClass, 'eui-yScroll', solutionSideNavPanelStyles); + const panelClasses = classNames(panelClassName, 'eui-yScroll', solutionSideNavPanelStyles); const titleClasses = classNames(SolutionSideNavTitleStyles(euiTheme)); // ESC key closes PanelNav @@ -145,42 +151,32 @@ const SolutionSideNavPanelCategories: React.FC {categories.map((category, index) => { - const categoryItems = category.linkIds.reduce((acc, linkId) => { - const link = items.find((item) => item.id === linkId); - if (link) { - acc.push(link); - } - return acc; - }, []); - - if (!categoryItems.length) { - return null; - } - if (isTitleLinkCategory(category)) { return ( ); } if (isAccordionLinkCategory(category)) { return ( ); } if (isSeparatorLinkCategory(category)) { return ( @@ -193,50 +189,101 @@ const SolutionSideNavPanelCategories: React.FC { + return useMemo( + () => + linkIds.reduce((acc, linkId) => { + const link = items.find((item) => item.id === linkId); + if (link) { + acc.push(link); + } + return acc; + }, []), + [items, linkIds] + ); +}; + interface SolutionSideNavPanelTitleCategoryProps { - label: string; items: SolutionSideNavItem[]; + category: TitleLinkCategory; onClose: () => void; } /** * Renders a title category for the secondary navigation panel. */ const SolutionSideNavPanelTitleCategory: React.FC = - React.memo(function SolutionSideNavPanelTitleCategory({ label, onClose, items }) { + React.memo(function SolutionSideNavPanelTitleCategory({ + category: { linkIds, label }, + items, + onClose, + }) { const { euiTheme } = useEuiTheme(); const titleClasses = classNames(SolutionSideNavCategoryTitleStyles(euiTheme)); + const categoryItems = useCategoryItems({ items, linkIds }); + if (!categoryItems?.length) { + return null; + } return ( <> - +

{label}

- - + ); }); interface SolutionSideNavPanelAccordionCategoryProps { - label: string; + category: AccordionLinkCategory; items: SolutionSideNavItem[]; onClose: () => void; + index: number; } /** * Renders an accordion category for the secondary navigation panel. */ const SolutionSideNavPanelAccordionCategory: React.FC = - React.memo(function SolutionSideNavPanelAccordionCategory({ label, onClose, items }) { + React.memo(function SolutionSideNavPanelAccordionCategory({ + category: { label, categories }, + items, + onClose, + index, + }) { const { euiTheme } = useEuiTheme(); const accordionClasses = classNames(SolutionSideNavCategoryAccordionStyles(euiTheme)); return ( - - - + <> + {index > 0 && } + + + {categories && ( + + )} + {/* This component can be extended to render SolutionSideNavPanelItems when `linkIds` is defined in the category */} + + ); }); interface SolutionSideNavPanelSeparatorCategoryProps { + category: SeparatorLinkCategory; items: SolutionSideNavItem[]; onClose: () => void; } @@ -244,12 +291,19 @@ interface SolutionSideNavPanelSeparatorCategoryProps { * Renders a separator category for the secondary navigation panel. */ const SolutionSideNavPanelSeparatorCategory: React.FC = - React.memo(function SolutionSideNavPanelSeparatorCategory({ onClose, items }) { + React.memo(function SolutionSideNavPanelSeparatorCategory({ + category: { linkIds }, + items, + onClose, + }) { + const categoryItems = useCategoryItems({ items, linkIds }); + if (!categoryItems?.length) { + return null; + } return ( <> - - + ); }); @@ -265,38 +319,64 @@ const SolutionSideNavPanelItems: React.FC = Reac function SolutionSideNavPanelItems({ items, onClose }) { const { euiTheme } = useEuiTheme(); const panelLinksGroupClassNames = classNames(SolutionSideNavPanelLinksGroupStyles(euiTheme)); - const panelLinkClassNames = classNames('solutionSideNavPanelLink'); - const { tracker } = useTelemetryContext(); return ( - {items.map(({ id, href, onClick, label, iconType, isBeta, betaOptions }) => { - const itemLabel = !isBeta ? ( - label - ) : ( - <> - {label} - - ); - - return ( - { - tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.PANEL_NAVIGATION}${id}`); - onClose(); - onClick?.(ev); - }} - /> - ); - })} + {items.map((item) => ( + + ))} ); } ); + +interface SolutionSideNavPanelItemProps { + item: SolutionSideNavItem; + onClose: () => void; +} +/** + * Renders one item for the secondary navigation panel. + * */ +const SolutionSideNavPanelItem: React.FC = React.memo( + function SolutionSideNavPanelItem({ item, onClose }) { + const { tracker } = useTelemetryContext(); + const panelLinkClassNames = classNames('solutionSideNavPanelLink'); + const { id, href, onClick, iconType, openInNewTab } = item; + const onClickHandler = useCallback( + (ev) => { + tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.PANEL_NAVIGATION}${id}`); + onClose(); + onClick?.(ev); + }, + [id, onClick, onClose, tracker] + ); + + return ( + } + wrapText + className={panelLinkClassNames} + size="s" + data-test-subj={`solutionSideNavPanelLink-${id}`} + href={href} + iconType={iconType} + onClick={onClickHandler} + target={openInNewTab ? '_blank' : undefined} + /> + ); + } +); + +/** + * Renders the navigation item label + **/ +const ItemLabel: React.FC<{ item: SolutionSideNavItem }> = React.memo(function ItemLabel({ + item: { label, openInNewTab, isBeta, betaOptions }, +}) { + return ( + <> + {label} {openInNewTab && } + {isBeta && } + + ); +}); diff --git a/x-pack/packages/security-solution/side_nav/src/types.ts b/x-pack/packages/security-solution/side_nav/src/types.ts index 97c83b368c10b..ff7714d618ada 100644 --- a/x-pack/packages/security-solution/side_nav/src/types.ts +++ b/x-pack/packages/security-solution/side_nav/src/types.ts @@ -20,12 +20,14 @@ export interface SolutionSideNavItem { label: string; href: string; onClick?: React.MouseEventHandler; + openInNewTab?: boolean; description?: string; items?: Array>; categories?: LinkCategories; iconType?: IconType; appendSeparator?: boolean; position?: SolutionSideNavItemPosition; + disabled?: boolean; isBeta?: boolean; betaOptions?: { text: string; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index 3a5aa2e82a047..ff88d9c38707f 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -27,7 +27,7 @@ const NAV_ITEMS_NAMES = { defaultMessage: 'Findings', }), BENCHMARKS: i18n.translate('xpack.csp.navigation.myBenchmarksNavItemLabel', { - defaultMessage: 'Cloud Posture Benchmarks', + defaultMessage: 'Benchmark rules', }), RULES: i18n.translate('xpack.csp.navigation.rulesNavItemLabel', { defaultMessage: 'Rules', diff --git a/x-pack/plugins/fleet/public/deep_links.ts b/x-pack/plugins/fleet/public/deep_links.ts new file mode 100644 index 0000000000000..9f325918156e1 --- /dev/null +++ b/x-pack/plugins/fleet/public/deep_links.ts @@ -0,0 +1,63 @@ +/* + * 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 type { AppDeepLink } from '@kbn/core/public'; + +import { FLEET_ROUTING_PATHS } from './constants/page_paths'; + +export enum FleetDeepLinkId { + agents = 'agents', + policies = 'policies', + enrollmentTokens = 'enrollment_tokens', + uninstallTokens = 'uninstall_tokens', + dataStreams = 'data_streams', + settings = 'settings', +} + +export const fleetDeepLinks: AppDeepLink[] = [ + { + id: FleetDeepLinkId.agents, + title: i18n.translate('xpack.fleet.deepLinks.agents.title', { defaultMessage: 'Agents' }), + path: FLEET_ROUTING_PATHS.agents, + }, + { + id: FleetDeepLinkId.policies, + title: i18n.translate('xpack.fleet.deepLinks.policies.title', { + defaultMessage: 'Agent policies', + }), + path: FLEET_ROUTING_PATHS.policies, + }, + { + id: FleetDeepLinkId.enrollmentTokens, + title: i18n.translate('xpack.fleet.deepLinks.enrollmentTokens.title', { + defaultMessage: 'Enrollment tokens', + }), + path: FLEET_ROUTING_PATHS.enrollment_tokens, + }, + { + id: FleetDeepLinkId.uninstallTokens, + title: i18n.translate('xpack.fleet.deepLinks.uninstallTokens.title', { + defaultMessage: 'Uninstall tokens', + }), + path: FLEET_ROUTING_PATHS.uninstall_tokens, + }, + { + id: FleetDeepLinkId.dataStreams, + title: i18n.translate('xpack.fleet.deepLinks.dataStreams.title', { + defaultMessage: 'Data streams', + }), + path: FLEET_ROUTING_PATHS.data_streams, + }, + { + id: FleetDeepLinkId.settings, + title: i18n.translate('xpack.fleet.deepLinks.settings.title', { + defaultMessage: 'Settings', + }), + path: FLEET_ROUTING_PATHS.settings, + }, +]; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index a433ba3fde50d..bc35914238b58 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -79,6 +79,7 @@ import { setCustomIntegrations, setCustomIntegrationsStart } from './services/cu import type { RequestError } from './hooks'; import { sendGetBulkAssets } from './hooks'; +import { fleetDeepLinks } from './deep_links'; // We need to provide an object instead of void so that dependent plugins know when Fleet // is disabled. @@ -211,6 +212,7 @@ export class FleetPlugin implements Plugin { const [coreStartServices, startDepsServices, fleetStart] = await core.getStartServices(); const cloud = diff --git a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts index 154ad302b9d34..d9b3f23f13873 100644 --- a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts +++ b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts @@ -26,7 +26,7 @@ export const THREAT_INTELLIGENCE_PAGE = '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Intelligence"]'; export const MANAGE_PAGE = - '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Settings"]'; + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Manage"]'; export const KIBANA_NAVIGATION_TOGGLE = '[data-test-subj="toggleNavButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index 4d9f24ba6c180..057ff0ab16a2a 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -140,7 +140,7 @@ export const THREAT_TECHNIQUE = '[data-test-subj="threatTechniqueLink"]'; export const THREAT_SUBTECHNIQUE = '[data-test-subj="threatSubtechniqueLink"]'; -export const BACK_TO_RULES_TABLE = '[data-test-subj="breadcrumb"][title="SIEM Rules"]'; +export const BACK_TO_RULES_TABLE = '[data-test-subj="breadcrumb"][title="Detection rules (SIEM)"]'; export const HIGHLIGHTED_ROWS_IN_TABLE = '[data-test-subj="euiDataGridBody"] .alertsTableHighlightedRow'; diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index f6c1fc6867670..1ea363673f441 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -78,8 +78,8 @@ export const RULES = i18n.translate('xpack.securitySolution.navigation.rules', { defaultMessage: 'Rules', }); -export const SIEM_RULES = i18n.translate('xpack.securitySolution.navigation.siemRules', { - defaultMessage: 'SIEM Rules', +export const SIEM_RULES = i18n.translate('xpack.securitySolution.navigation.detectionRules', { + defaultMessage: 'Detection rules (SIEM)', }); export const ADD_RULES = i18n.translate('xpack.securitySolution.navigation.addRules', { @@ -87,7 +87,7 @@ export const ADD_RULES = i18n.translate('xpack.securitySolution.navigation.addRu }); export const EXCEPTIONS = i18n.translate('xpack.securitySolution.navigation.exceptions', { - defaultMessage: 'Shared Exception Lists', + defaultMessage: 'Shared exception lists', }); export const ALERTS = i18n.translate('xpack.securitySolution.navigation.alerts', { @@ -142,11 +142,8 @@ export const FINDINGS = i18n.translate('xpack.securitySolution.navigation.findin export const EXPLORE = i18n.translate('xpack.securitySolution.navigation.explore', { defaultMessage: 'Explore', }); -export const INVESTIGATE = i18n.translate('xpack.securitySolution.navigation.investigate', { - defaultMessage: 'Investigate', -}); -export const SETTINGS = i18n.translate('xpack.securitySolution.navigation.settings', { - defaultMessage: 'Settings', +export const MANAGE = i18n.translate('xpack.securitySolution.navigation.manage', { + defaultMessage: 'Manage', }); export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.blocklist', { diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts index 4dde9497adc27..9b1483174adc7 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts @@ -51,7 +51,7 @@ export const benchmarksLink: LinkItem = { description: i18n.translate( 'xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarksDescription', { - defaultMessage: 'View benchmark rules.', + defaultMessage: 'View benchmark rules for Cloud Security Posture management.', } ), landingIcon: IconEndpoints, diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index 8a4a10b97d004..016a6a6e4fa19 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import type { SecurityPageName } from '../../../common/constants'; import { hasCapabilities } from '../lib/capabilities'; import type { @@ -19,52 +19,27 @@ import type { } from './types'; /** - * Main app links updater, it stores the `mainAppLinksUpdater` recursive hierarchy and keeps + * App links updater, it stores the links recursive hierarchy and keeps * the value of the app links in sync with all application components. * It can be updated using `updateAppLinks`. */ -const mainAppLinksUpdater$ = new BehaviorSubject([]); - -/** - * Extra App links updater, it stores the `extraAppLinksUpdater` - * that can be added externally to the app links. - * It can be updated using `updatePublicAppLinks`. - */ -const extraAppLinksUpdater$ = new BehaviorSubject([]); - -// Combines internal and external appLinks, changes on any of them will trigger a new value const appLinksUpdater$ = new BehaviorSubject([]); export const appLinks$ = appLinksUpdater$.asObservable(); // stores a flatten normalized appLinkItems object for internal direct id access const normalizedAppLinksUpdater$ = new BehaviorSubject({}); -// Setup the appLinksUpdater$ to combine the internal and external appLinks -combineLatest([mainAppLinksUpdater$, extraAppLinksUpdater$]).subscribe( - ([mainAppLinks, extraAppLinks]) => { - appLinksUpdater$.next(Object.freeze([...mainAppLinks, ...extraAppLinks])); - } -); -// Setup the normalizedAppLinksUpdater$ to update the normalized appLinks -appLinks$.subscribe((appLinks) => { - normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(appLinks))); -}); - /** * Updates the internal app links applying the filter by permissions */ export const updateAppLinks = ( appLinksToUpdate: AppLinkItems, linksPermissions: LinksPermissions -) => mainAppLinksUpdater$.next(Object.freeze(processAppLinks(appLinksToUpdate, linksPermissions))); - -/** - * Updates the app links applying the filter by permissions - */ -export const updateExtraAppLinks = ( - appLinksToUpdate: AppLinkItems, - linksPermissions: LinksPermissions -) => extraAppLinksUpdater$.next(Object.freeze(processAppLinks(appLinksToUpdate, linksPermissions))); +) => { + const processedAppLinks = processAppLinks(appLinksToUpdate, linksPermissions); + appLinksUpdater$.next(Object.freeze(processedAppLinks)); + normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(processedAppLinks))); +}; /** * Hook to get the app links updated value diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index f83e5ad6f7797..7e87d1d0bc098 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -136,6 +136,7 @@ export interface LinkItem { } export type AppLinkItems = Readonly; +export type AppLinksSwitcher = (appLinks: AppLinkItems) => AppLinkItems; export type LinkInfo = Omit; export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/automated_response_actions/history_log.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/automated_response_actions/history_log.cy.ts index 369ee507206b8..e6b7887c5d6f7 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/automated_response_actions/history_log.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/automated_response_actions/history_log.cy.ts @@ -85,6 +85,6 @@ describe('Response actions history page', () => { cy.get('tbody .euiTableRow').eq(0).contains('Triggered by rule').click(); }); // check if we were moved to Rules app after clicking Triggered by rule - cy.getByTestSubj('breadcrumb last').contains('Rules'); + cy.getByTestSubj('breadcrumb last').contains('Detection rules (SIEM)'); }); }); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 1c2651de4e074..72884ca71dd7a 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -31,7 +31,7 @@ import { ENDPOINTS, EVENT_FILTERS, HOST_ISOLATION_EXCEPTIONS, - SETTINGS, + MANAGE, POLICIES, RESPONSE_ACTIONS_HISTORY, TRUSTED_APPLICATIONS, @@ -76,21 +76,21 @@ const categories = [ label: i18n.translate('xpack.securitySolution.appLinks.category.cloudSecurity', { defaultMessage: 'Cloud Security', }), - linkIds: [cloudDefendLink.id], + linkIds: [SecurityPageName.cloudDefendPolicies], }, ]; export const links: LinkItem = { id: SecurityPageName.administration, - title: SETTINGS, + title: MANAGE, path: MANAGE_PATH, skipUrlState: true, hideTimeline: true, globalNavPosition: 8, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.settings', { - defaultMessage: 'Settings', + i18n.translate('xpack.securitySolution.appLinks.manage', { + defaultMessage: 'Manage', }), ], categories, @@ -178,6 +178,7 @@ export const links: LinkItem = { hideTimeline: true, capabilities: [`${SERVER_APP_ID}.entity-analytics`], experimentalKey: 'riskScoringRoutesEnabled', + licenseType: 'platinum', }, { id: SecurityPageName.responseActionsHistory, @@ -218,7 +219,7 @@ export const getManagementFilteredLinks = async ( fleetAuthz && currentUser ? calculateEndpointAuthz(licenseService, fleetAuthz, currentUser.roles) : getEndpointAuthzInitialState(); - const showEntityAnalytics = licenseService.isPlatinumPlus(); + const showHostIsolationExceptions = canAccessHostIsolationExceptions || // access host isolation exceptions is a paid feature, always show the link. // read host isolation exceptions is not a paid feature, to allow deleting exceptions after a downgrade scenario. @@ -256,9 +257,5 @@ export const getManagementFilteredLinks = async ( linksToExclude.push(SecurityPageName.blocklist); } - if (!showEntityAnalytics) { - linksToExclude.push(SecurityPageName.entityAnalyticsManagement); - } - return excludeLinks(linksToExclude); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/landing.tsx b/x-pack/plugins/security_solution/public/management/pages/landing.tsx index a6030f0f94ec2..99cd9142d022f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/landing.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/landing.tsx @@ -16,8 +16,8 @@ import { useRootNavLink } from '../../common/links/nav_links'; import { useGlobalQueryString } from '../../common/utils/global_query_string'; import { trackLandingLinkClick } from '../../common/lib/telemetry/trackers'; -const PAGE_TITLE = i18n.translate('xpack.securitySolution.management.landing.settingsTitle', { - defaultMessage: 'Settings', +const PAGE_TITLE = i18n.translate('xpack.securitySolution.management.landing.title', { + defaultMessage: 'Manage', }); export const ManageLandingPage = () => { diff --git a/x-pack/plugins/security_solution/public/mocks.ts b/x-pack/plugins/security_solution/public/mocks.ts index c255bb6383ce5..85b99420907ea 100644 --- a/x-pack/plugins/security_solution/public/mocks.ts +++ b/x-pack/plugins/security_solution/public/mocks.ts @@ -14,6 +14,7 @@ import type { PluginStart, PluginSetup } from './types'; const setupMock = (): PluginSetup => ({ resolver: jest.fn(), upselling: new UpsellingService(), + setAppLinksSwitcher: jest.fn(), }); const startMock = (): PluginStart => ({ @@ -23,7 +24,6 @@ const startMock = (): PluginStart => ({ getBreadcrumbsNav$: jest.fn( () => new BehaviorSubject({ leading: [], trailing: [] }) ), - setExtraAppLinks: jest.fn(), setExtraRoutes: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 2ebe399c64f6a..cd647ef499260 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { combineLatest, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import type * as H from 'history'; import type { AppMountParameters, @@ -37,7 +37,7 @@ import { SOLUTION_NAME } from './common/translations'; import { APP_ID, APP_UI_ID, APP_PATH, APP_ICON_SOLUTION } from '../common/constants'; -import { updateAppLinks, updateExtraAppLinks, type LinksPermissions } from './common/links'; +import { updateAppLinks, type LinksPermissions } from './common/links'; import { registerDeepLinksUpdater } from './common/links/deep_links'; import { licenseService } from './common/hooks/use_license'; import type { SecuritySolutionUiConfigType } from './common/types'; @@ -511,7 +511,7 @@ export class Plugin implements IPlugin { - updateExtraAppLinks(extraAppLinks, { - ...baseLinksPermissions, - ...(license.type != null && { license }), - }); + updateAppLinks(appLinksSwitcher(filteredLinks), linksPermissions); }); } } diff --git a/x-pack/plugins/security_solution/public/plugin_contract.ts b/x-pack/plugins/security_solution/public/plugin_contract.ts index 06fb9f2938498..77a08cda77c9b 100644 --- a/x-pack/plugins/security_solution/public/plugin_contract.ts +++ b/x-pack/plugins/security_solution/public/plugin_contract.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import type { RouteProps } from 'react-router-dom'; import { UpsellingService } from './common/lib/upsellings'; import type { ContractStartServices, PluginSetup, PluginStart } from './types'; -import type { AppLinkItems } from './common/links'; +import type { AppLinksSwitcher } from './common/links'; import { navLinks$ } from './common/links/nav_links'; import { breadcrumbsNav$ } from './common/breadcrumbs'; @@ -17,15 +17,15 @@ export class PluginContract { public isSidebarEnabled$: BehaviorSubject; public getStartedComponent$: BehaviorSubject; public upsellingService: UpsellingService; - public extraAppLinks$: BehaviorSubject; public extraRoutes$: BehaviorSubject; + public appLinksSwitcher: AppLinksSwitcher; constructor() { - this.extraAppLinks$ = new BehaviorSubject([]); this.extraRoutes$ = new BehaviorSubject([]); this.isSidebarEnabled$ = new BehaviorSubject(true); this.getStartedComponent$ = new BehaviorSubject(null); this.upsellingService = new UpsellingService(); + this.appLinksSwitcher = (appLinks) => appLinks; } public getStartServices(): ContractStartServices { @@ -41,13 +41,15 @@ export class PluginContract { return { resolver: lazyResolver, upselling: this.upsellingService, + setAppLinksSwitcher: (appLinksSwitcher) => { + this.appLinksSwitcher = appLinksSwitcher; + }, }; } public getStartContract(): PluginStart { return { getNavLinks$: () => navLinks$, - setExtraAppLinks: (extraAppLinks) => this.extraAppLinks$.next(extraAppLinks), setExtraRoutes: (extraRoutes) => this.extraRoutes$.next(extraRoutes), setIsSidebarEnabled: (isSidebarEnabled: boolean) => this.isSidebarEnabled$.next(isSidebarEnabled), diff --git a/x-pack/plugins/security_solution/public/rules/links.ts b/x-pack/plugins/security_solution/public/rules/links.ts index d466a847f8def..fd32b2804e370 100644 --- a/x-pack/plugins/security_solution/public/rules/links.ts +++ b/x-pack/plugins/security_solution/public/rules/links.ts @@ -12,6 +12,7 @@ import { EXCEPTIONS_PATH, RULES_LANDING_PATH, RULES_ADD_PATH, + SERVER_APP_ID, } from '../../common/constants'; import { ADD_RULES, CREATE_NEW_RULE, EXCEPTIONS, RULES, SIEM_RULES } from '../app/translations'; import { SecurityPageName } from '../app/types'; @@ -26,19 +27,19 @@ export const links: LinkItem = { path: RULES_LANDING_PATH, hideTimeline: true, skipUrlState: true, + capabilities: [`${SERVER_APP_ID}.show`], links: [ { id: SecurityPageName.rules, title: SIEM_RULES, description: i18n.translate('xpack.securitySolution.appLinks.rulesDescription', { - defaultMessage: - "Create and manage rules to check for suspicious source events, and create alerts when a rule's conditions are met.", + defaultMessage: 'Create and manage detection rules for threat detection and monitoring.', }), landingIcon: IconRollup, path: RULES_PATH, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.rules', { - defaultMessage: 'Rules', + defaultMessage: 'SIEM Rules', }), ], links: [ @@ -79,16 +80,14 @@ export const links: LinkItem = { ], categories: [ { - label: i18n.translate('xpack.securitySolution.appLinks.category.siemRules', { - defaultMessage: 'Security Detection Rules', + label: i18n.translate('xpack.securitySolution.appLinks.category.management', { + defaultMessage: 'Management', }), - linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.cspRules', { - defaultMessage: 'Cloud Security Rules', - }), - linkIds: [SecurityPageName.cloudSecurityPostureBenchmarks], + linkIds: [ + SecurityPageName.rules, + SecurityPageName.cloudSecurityPostureBenchmarks, + SecurityPageName.exceptions, + ], }, ], }; diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 4718c257cec07..8f920bb10f1b6 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -69,7 +69,7 @@ import type { CloudDefend } from './cloud_defend'; import type { ThreatIntelligence } from './threat_intelligence'; import type { SecuritySolutionTemplateWrapper } from './app/home/template_wrapper'; import type { Explore } from './explore'; -import type { AppLinkItems, NavigationLink } from './common/links'; +import type { AppLinksSwitcher, NavigationLink } from './common/links'; import type { EntityAnalytics } from './entity_analytics'; import type { TelemetryClientStart } from './common/lib/telemetry'; @@ -170,11 +170,11 @@ export type StartServices = CoreStart & export interface PluginSetup { resolver: () => Promise; upselling: UpsellingService; + setAppLinksSwitcher: (appLinksSwitcher: AppLinksSwitcher) => void; } export interface PluginStart { getNavLinks$: () => Observable; - setExtraAppLinks: (extraAppLinks: AppLinkItems) => void; setExtraRoutes: (extraRoutes: RouteProps[]) => void; setIsSidebarEnabled: (isSidebarEnabled: boolean) => void; setGetStartedPage: (getStartedComponent: React.ComponentType) => void; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/ecctl.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/ecctl.tsx new file mode 100644 index 0000000000000..994ca883ed2c4 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/ecctl.tsx @@ -0,0 +1,41 @@ +/* + * 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 { SVGProps } from 'react'; +import React from 'react'; +export const IconEcctl: React.FC> = ({ ...props }) => ( + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconEcctl; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/graph.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/graph.tsx new file mode 100644 index 0000000000000..9223de9461975 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/graph.tsx @@ -0,0 +1,46 @@ +/* + * 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 { SVGProps } from 'react'; +import React from 'react'; +export const IconGraph: React.FC> = ({ ...props }) => ( + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconGraph; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/logging.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/logging.tsx new file mode 100644 index 0000000000000..baab149c29412 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/logging.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SVGProps } from 'react'; +import React from 'react'; +export const IconLogging: React.FC> = ({ ...props }) => ( + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconLogging; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/map_services.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/map_services.tsx new file mode 100644 index 0000000000000..a9004b486228c --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/map_services.tsx @@ -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 type { SVGProps } from 'react'; +import React from 'react'; +export const IconMapServices: React.FC> = ({ ...props }) => ( + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconMapServices; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/osquery.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/osquery.tsx new file mode 100644 index 0000000000000..86e36ef90bd53 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/osquery.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SVGProps } from 'react'; +import React from 'react'; +export const IconOsquery: React.FC> = ({ ...props }) => ( + + + + + + + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconOsquery; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/product_features_alerting.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/product_features_alerting.tsx new file mode 100644 index 0000000000000..f856b7a7494f4 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/product_features_alerting.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 type { SVGProps } from 'react'; +import React from 'react'; +export const IconProductFeaturesAlerting: React.FC> = ({ ...props }) => ( + + + + + + + + + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconProductFeaturesAlerting; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/security_shield.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/security_shield.tsx new file mode 100644 index 0000000000000..355718d77d1a0 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/security_shield.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SVGProps } from 'react'; +import React from 'react'; +export const IconSecurityShield: React.FC> = ({ ...props }) => ( + + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconSecurityShield; diff --git a/x-pack/plugins/security_solution_serverless/public/common/icons/timeline.tsx b/x-pack/plugins/security_solution_serverless/public/common/icons/timeline.tsx new file mode 100644 index 0000000000000..4948984f6fda5 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/common/icons/timeline.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SVGProps } from 'react'; +import React from 'react'; +export const IconTimeline: React.FC> = ({ ...props }) => ( + + + + +); + +// eslint-disable-next-line import/no-default-export +export default IconTimeline; diff --git a/x-pack/plugins/security_solution_serverless/public/common/lazy_icons.tsx b/x-pack/plugins/security_solution_serverless/public/common/lazy_icons.tsx index 18c5afc9397a2..2143eee79ebe9 100644 --- a/x-pack/plugins/security_solution_serverless/public/common/lazy_icons.tsx +++ b/x-pack/plugins/security_solution_serverless/public/common/lazy_icons.tsx @@ -30,3 +30,17 @@ export const IconDevToolsLazy = withSuspenseIcon(React.lazy(() => import('./icon export const IconFleetLazy = withSuspenseIcon(React.lazy(() => import('./icons/fleet'))); export const IconAuditbeatLazy = withSuspenseIcon(React.lazy(() => import('./icons/auditbeat'))); export const IconSiemLazy = withSuspenseIcon(React.lazy(() => import('./icons/siem'))); +export const IconEcctlLazy = withSuspenseIcon(React.lazy(() => import('./icons/ecctl'))); +export const IconGraphLazy = withSuspenseIcon(React.lazy(() => import('./icons/graph'))); +export const IconLoggingLazy = withSuspenseIcon(React.lazy(() => import('./icons/logging'))); +export const IconMapServicesLazy = withSuspenseIcon( + React.lazy(() => import('./icons/map_services')) +); +export const IconSecurityShieldLazy = withSuspenseIcon( + React.lazy(() => import('./icons/security_shield')) +); +export const IconProductFeaturesAlertingLazy = withSuspenseIcon( + React.lazy(() => import('./icons/product_features_alerting')) +); +export const IconTimelineLazy = withSuspenseIcon(React.lazy(() => import('./icons/timeline'))); +export const IconOsqueryLazy = withSuspenseIcon(React.lazy(() => import('./icons/osquery'))); diff --git a/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx b/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx index 29ed99cb9fc74..87e22e80a59b1 100644 --- a/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx +++ b/x-pack/plugins/security_solution_serverless/public/common/services/__mocks__/services.mock.tsx @@ -4,12 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { BehaviorSubject } from 'rxjs'; import { coreMock } from '@kbn/core/public/mocks'; import { serverlessMock } from '@kbn/serverless/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { securitySolutionMock } from '@kbn/security-solution-plugin/public/mocks'; -import { BehaviorSubject } from 'rxjs'; import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; +import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import type { ProjectNavigationLink } from '../../../navigation/links/types'; import type { Services } from '..'; @@ -22,4 +23,5 @@ export const mockServices: Services = { securitySolution: securitySolutionMock.createStart(), getProjectNavLinks$: jest.fn(() => new BehaviorSubject(mockProjectNavLinks())), management: managementPluginMock.createStartContract(), + cloud: cloudMock.createStart(), }; diff --git a/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts b/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts index e4213de19e1fe..9a16ebe31ff08 100644 --- a/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts +++ b/x-pack/plugins/security_solution_serverless/public/common/services/create_services.ts @@ -6,15 +6,19 @@ */ import type { CoreStart } from '@kbn/core/public'; -import { getProjectNavLinks$ } from '../../navigation/links/nav_links'; +import { createProjectNavLinks$ } from '../../navigation/links/nav_links'; import type { SecuritySolutionServerlessPluginStartDeps } from '../../types'; import type { Services } from './types'; +/** + * Creates the services for the plugin components to consume. + * It should be created only once and stored in the ServicesProvider for general access + * */ export const createServices = ( core: CoreStart, pluginsStart: SecuritySolutionServerlessPluginStartDeps ): Services => { - const { securitySolution } = pluginsStart; - const projectNavLinks$ = getProjectNavLinks$(securitySolution.getNavLinks$(), core); + const { securitySolution, cloud } = pluginsStart; + const projectNavLinks$ = createProjectNavLinks$(securitySolution.getNavLinks$(), core, cloud); return { ...core, ...pluginsStart, getProjectNavLinks$: () => projectNavLinks$ }; }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/index.ts b/x-pack/plugins/security_solution_serverless/public/navigation/index.ts index 3fc71b4d1d1f9..84842a90e1f74 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/index.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/index.ts @@ -5,15 +5,17 @@ * 2.0. */ -import { APP_PATH, MANAGE_PATH } from '@kbn/security-solution-plugin/common'; +import { APP_PATH, SecurityPageName } from '@kbn/security-solution-plugin/common'; import type { ServerlessSecurityPublicConfig } from '../types'; import type { Services } from '../common/services'; import { subscribeBreadcrumbs } from './breadcrumbs'; -import { setAppLinks } from './links/app_links'; +import { SecurityPagePath } from './links/constants'; import { subscribeNavigationTree } from './navigation_tree'; import { getSecuritySideNavComponent } from './side_navigation'; -const SECURITY_MANAGE_PATH = `${APP_PATH}${MANAGE_PATH}`; +const SECURITY_PROJECT_SETTINGS_PATH = `${APP_PATH}${ + SecurityPagePath[SecurityPageName.projectSettings] +}`; export const configureNavigation = ( services: Services, @@ -23,13 +25,12 @@ export const configureNavigation = ( securitySolution.setIsSidebarEnabled(false); if (!serverConfig.developer.disableManagementUrlRedirect) { - management.setLandingPageRedirect(SECURITY_MANAGE_PATH); + management.setLandingPageRedirect(SECURITY_PROJECT_SETTINGS_PATH); } serverless.setProjectHome(APP_PATH); serverless.setSideNavComponent(getSecuritySideNavComponent(services)); - setAppLinks(services); subscribeNavigationTree(services); subscribeBreadcrumbs(services); }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts index 536d07066fa7c..49c79ac12d8f6 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/app_links.ts @@ -5,11 +5,45 @@ * 2.0. */ -import type { Services } from '../../common/services'; +import type { + AppLinksSwitcher, + LinkItem, +} from '@kbn/security-solution-plugin/public/common/links/types'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { cloneDeep, remove } from 'lodash'; +import { createInvestigationsLinkFromTimeline } from './sections/investigations_links'; import { mlAppLink } from './sections/ml_links'; +import { createAssetsLinkFromManage } from './sections/assets_links'; +import { createProjectSettingsLinkFromManage } from './sections/project_settings_links'; -export const setAppLinks = (services: Services) => { - services.securitySolution.setExtraAppLinks([ - mlAppLink, // ML landing page app link - ]); +// This function is called by the security_solution plugin to alter the app links +// that will be registered to the Security Solution application on Serverless projects. +// The capabilities filtering is done after this function is called by the security_solution plugin. +export const projectAppLinksSwitcher: AppLinksSwitcher = (appLinks) => { + const projectAppLinks = cloneDeep(appLinks) as LinkItem[]; + + // Remove timeline link + const [timelineLinkItem] = remove(projectAppLinks, { id: SecurityPageName.timelines }); + if (timelineLinkItem) { + // Add investigations link + projectAppLinks.push(createInvestigationsLinkFromTimeline(timelineLinkItem)); + } + + // Remove manage link + const [manageLinkItem] = remove(projectAppLinks, { id: SecurityPageName.administration }); + + if (manageLinkItem) { + // Add assets link + projectAppLinks.push(createAssetsLinkFromManage(manageLinkItem)); + } + + // Add ML link + projectAppLinks.push(mlAppLink); + + if (manageLinkItem) { + // Add project settings link + projectAppLinks.push(createProjectSettingsLinkFromManage(manageLinkItem)); + } + + return projectAppLinks; }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/constants.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/constants.ts index a4edec4bac6f6..0d0b606976bfe 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/constants.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/constants.ts @@ -7,11 +7,31 @@ import { SecurityPageName } from '@kbn/security-solution-navigation'; +// Paths for internal Security pages that only exist in serverless projects and do not exist on ESS export const SecurityPagePath = { + [SecurityPageName.investigations]: '/investigations', [SecurityPageName.mlLanding]: '/ml', + [SecurityPageName.assets]: '/assets', + [SecurityPageName.cloudDefend]: '/cloud_defend', + [SecurityPageName.projectSettings]: '/project_settings', } as const; +/** + * External (non-Security) page names that need to be linked in the Security nav for serverless + * Format: `:/`. + * + * `pluginId`: is the id of the plugin that owns the deep link + * + * `deepLinkId`: is the id of the deep link inside the plugin. + * Keep empty for the root page of the plugin, e.g. `osquery:` + * + * `path`: is the path to append to the plugin and deep link. + * This is optional and only needed if the path is not registered in the plugin's `deepLinks`. e.g. `integrations:/browse/security` + * The path should not be used for links displayed in the main left navigation, since highlighting won't work. + **/ export enum ExternalPageName { + // Osquery + osquery = 'osquery:', // Machine Learning // Ref: packages/default-nav/ml/default_navigation.ts mlOverview = 'ml:overview', @@ -32,5 +52,42 @@ export enum ExternalPageName { mlChangePointDetections = 'ml:changePointDetections', // Dev Tools // Ref: packages/default-nav/devtools/default_navigation.ts - devToolsRoot = 'dev_tools:', + devTools = 'dev_tools:', + // Fleet + // Ref: x-pack/plugins/fleet/public/deep_links.ts + fleet = 'fleet:', + fleetAgents = 'fleet:agents', + fleetPolicies = 'fleet:policies', + fleetEnrollmentTokens = 'fleet:enrollment_tokens', + fleetUninstallTokens = 'fleet:uninstall_tokens', + fleetDataStreams = 'fleet:data_streams', + fleetSettings = 'fleet:settings', + // Integrations + // No deepLinkId registered, using path for the security search + integrationsSecurity = 'integrations:/browse/security', + // Management + // Ref: packages/default-nav/management/default_navigation.ts + managementIngestPipelines = 'management:ingest_pipelines', + managementPipelines = 'management:pipelines', + managementIndexManagement = 'management:index_management', + managementTransforms = 'management:transform', + managementMaintenanceWindows = 'management:maintenanceWindows', + managementTriggersActions = 'management:triggersActions', + managementCases = 'management:cases', + managementTriggersActionsConnectors = 'management:triggersActionsConnectors', + managementReporting = 'management:reporting', + managementJobsListLink = 'management:jobsListLink', + managementDataViews = 'management:dataViews', + managementObjects = 'management:objects', + managementApiKeys = 'management:api_keys', + managementTags = 'management:tags', + managementFiles = 'management:filesManagement', + managementSpaces = 'management:spaces', + managementSettings = 'management:settings', + // Cloud UI + // These are links to Cloud UI outside Kibana + // Special Format: : + // cloudUrlKey Ref: x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts + cloudUsersAndRoles = 'cloud:usersAndRoles', + cloudBilling = 'cloud:billing', } diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts index 0cb6cbf612342..11764c5a5aea3 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav.links.test.ts @@ -8,13 +8,21 @@ import type { ChromeNavLink } from '@kbn/core/public'; import { APP_UI_ID } from '@kbn/security-solution-plugin/common'; import type { NavigationLink } from '@kbn/security-solution-navigation'; import { SecurityPageName } from '@kbn/security-solution-navigation'; -import { getProjectNavLinks$ } from './nav_links'; +import { createProjectNavLinks$ } from './nav_links'; import { BehaviorSubject, firstValueFrom, take } from 'rxjs'; import { mockServices } from '../../common/services/__mocks__/services.mock'; import { mlNavCategories, mlNavLinks } from './sections/ml_links'; +import { assetsNavLinks } from './sections/assets_links'; import { ExternalPageName } from './constants'; import type { ProjectNavigationLink } from './types'; - +import { investigationsNavLinks } from './sections/investigations_links'; +import { + projectSettingsNavCategories, + projectSettingsNavLinks, +} from './sections/project_settings_links'; +import { isCloudLink } from './util'; + +const mockCloudStart = mockServices.cloud; const mockChromeNavLinks = jest.fn((): ChromeNavLink[] => []); const mockChromeGetNavLinks = jest.fn(() => new BehaviorSubject(mockChromeNavLinks())); const mockChromeNavLinksHas = jest.fn((id: string): boolean => @@ -43,8 +51,8 @@ const linkMlLanding: NavigationLink = { links: [], }; const projectLinkDevTools: ProjectNavigationLink = { - id: ExternalPageName.devToolsRoot, - title: 'Dev Tools', + id: ExternalPageName.devTools, + title: 'Dev tools', }; const chromeNavLink1: ChromeNavLink = { @@ -54,9 +62,9 @@ const chromeNavLink1: ChromeNavLink = { url: '/link1', baseUrl: '', }; -const devToolsNavLink: ChromeNavLink = { +const devToolsChromeNavLink: ChromeNavLink = { id: 'dev_tools', - title: 'Dev Tools', + title: 'Dev tools', href: '/dev_tools', url: '/dev_tools', baseUrl: '', @@ -75,26 +83,38 @@ describe('getProjectNavLinks', () => { mockChromeNavLinksHas.mockReturnValue(false); // no external links exist const testSecurityNavLinks$ = new BehaviorSubject([link1, link2]); - const projectNavLinks$ = getProjectNavLinks$(testSecurityNavLinks$, testServices); + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); expect(value).toEqual([link1, link2]); }); it('should add devTools nav link if chrome nav link exists', async () => { - mockChromeNavLinks.mockReturnValue([devToolsNavLink]); + mockChromeNavLinks.mockReturnValue([devToolsChromeNavLink]); const testSecurityNavLinks$ = new BehaviorSubject([link1]); - const projectNavLinks$ = getProjectNavLinks$(testSecurityNavLinks$, testServices); + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); expect(value).toEqual([link1, projectLinkDevTools]); }); - it('should add machineLearning landing nav link filtering all external links', async () => { + it('should filter all external links not configured in chrome links', async () => { mockChromeNavLinks.mockReturnValue([chromeNavLink1]); const testSecurityNavLinks$ = new BehaviorSubject([link1, link2, linkMlLanding]); - const projectNavLinks$ = getProjectNavLinks$(testSecurityNavLinks$, testServices); + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); expect(value).toEqual([ @@ -104,11 +124,15 @@ describe('getProjectNavLinks', () => { ]); }); - it('should add machineLearning and devTools nav links with all external links present', async () => { - mockChromeNavLinksHas.mockReturnValue(true); // all external links exist + it('should add machineLearning links', async () => { + mockChromeNavLinksHas.mockReturnValue(true); // all links exist const testSecurityNavLinks$ = new BehaviorSubject([link1, link2, linkMlLanding]); - const projectNavLinks$ = getProjectNavLinks$(testSecurityNavLinks$, testServices); + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); expect(value).toEqual([ @@ -118,4 +142,107 @@ describe('getProjectNavLinks', () => { projectLinkDevTools, ]); }); + + it('should add assets links', async () => { + mockChromeNavLinksHas.mockReturnValue(true); // all links exist + const linkAssets: NavigationLink = { + id: SecurityPageName.assets, + title: 'Assets', + links: [link2], + }; + const testSecurityNavLinks$ = new BehaviorSubject([link1, linkAssets]); + + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); + + const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); + expect(value).toEqual([ + link1, + { ...linkAssets, links: [...assetsNavLinks, link2] }, + projectLinkDevTools, + ]); + }); + + it('should add investigations links', async () => { + mockChromeNavLinksHas.mockReturnValue(true); // all links exist + const linkInvestigations: NavigationLink = { + id: SecurityPageName.investigations, + title: 'Investigations', + links: [link2], + }; + const testSecurityNavLinks$ = new BehaviorSubject([link1, linkInvestigations]); + + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); + + const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); + expect(value).toEqual([ + link1, + { ...linkInvestigations, links: [link2, ...investigationsNavLinks] }, + projectLinkDevTools, + ]); + }); + + it('should add project settings links', async () => { + mockChromeNavLinksHas.mockReturnValue(true); // all links exist + const linkProjectSettings: NavigationLink = { + id: SecurityPageName.projectSettings, + title: 'Project settings', + links: [link2], + }; + const testSecurityNavLinks$ = new BehaviorSubject([link1, linkProjectSettings]); + + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); + + const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); + + const expectedProjectSettingsNavLinks = projectSettingsNavLinks.map( + (link) => expect.objectContaining(link) // ignore externalUrl property in cloud links, tested separately + ); + + expect(value).toEqual([ + link1, + { + ...linkProjectSettings, + categories: projectSettingsNavCategories, + links: [...expectedProjectSettingsNavLinks, link2], + }, + projectLinkDevTools, + ]); + }); + + it('should process cloud links', async () => { + mockChromeNavLinksHas.mockReturnValue(true); // all links exist + const linkProjectSettings: NavigationLink = { + id: SecurityPageName.projectSettings, + title: 'Project settings', + links: [link2], + }; + const testSecurityNavLinks$ = new BehaviorSubject([link1, linkProjectSettings]); + + const projectNavLinks$ = createProjectNavLinks$( + testSecurityNavLinks$, + testServices, + mockCloudStart + ); + + const value = await firstValueFrom(projectNavLinks$.pipe(take(1))); + const cloudLinks = + value + .find((link) => link.id === SecurityPageName.projectSettings) + ?.links?.filter((link) => isCloudLink(link.id)) ?? []; + + expect(cloudLinks.length > 0).toBe(true); + expect(cloudLinks.every((cloudLink) => cloudLink.externalUrl)).toBe(true); + }); }); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts index edda1d38f8804..acef7c10d8ca5 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/nav_links.ts @@ -8,15 +8,23 @@ import { map, combineLatest, skipWhile, debounceTime, type Observable } from 'rxjs'; import type { ChromeNavLinks, CoreStart } from '@kbn/core/public'; import { SecurityPageName, type NavigationLink } from '@kbn/security-solution-navigation'; -import { isExternalId } from '@kbn/security-solution-navigation/links'; +import { isSecurityId } from '@kbn/security-solution-navigation/links'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; +import { assetsNavLinks } from './sections/assets_links'; import { mlNavCategories, mlNavLinks } from './sections/ml_links'; +import { + projectSettingsNavCategories, + projectSettingsNavLinks, +} from './sections/project_settings_links'; import { devToolsNavLink } from './sections/dev_tools_links'; import type { ProjectNavigationLink } from './types'; -import { getNavLinkIdFromProjectPageName } from './util'; +import { getCloudLinkKey, getCloudUrl, getNavLinkIdFromProjectPageName, isCloudLink } from './util'; +import { investigationsNavLinks } from './sections/investigations_links'; -export const getProjectNavLinks$ = ( +export const createProjectNavLinks$ = ( securityNavLinks$: Observable>>, - core: CoreStart + core: CoreStart, + cloud: CloudStart ): Observable => { const { chrome } = core; return combineLatest([securityNavLinks$, chrome.navLinks.getNavLinks$()]).pipe( @@ -25,7 +33,7 @@ export const getProjectNavLinks$ = ( ([securityNavLinks, chromeNavLinks]) => securityNavLinks.length === 0 || chromeNavLinks.length === 0 // skip if not initialized ), - map(([securityNavLinks]) => processNavLinks(securityNavLinks, chrome.navLinks)) + map(([securityNavLinks]) => processNavLinks(securityNavLinks, chrome.navLinks, cloud)) ); }; @@ -35,10 +43,23 @@ export const getProjectNavLinks$ = ( */ const processNavLinks = ( securityNavLinks: Array>, - chromeNavLinks: ChromeNavLinks + chromeNavLinks: ChromeNavLinks, + cloud: CloudStart ): ProjectNavigationLink[] => { const projectNavLinks: ProjectNavigationLink[] = [...securityNavLinks]; + // Investigations. injecting external sub-links and categories definition to the landing + const investigationsLinkIndex = projectNavLinks.findIndex( + ({ id }) => id === SecurityPageName.investigations + ); + if (investigationsLinkIndex !== -1) { + const investigationNavLink = projectNavLinks[investigationsLinkIndex]; + projectNavLinks[investigationsLinkIndex] = { + ...investigationNavLink, + links: [...(investigationNavLink.links ?? []), ...investigationsNavLinks], + }; + } + // ML. injecting external sub-links and categories definition to the landing const mlLinkIndex = projectNavLinks.findIndex(({ id }) => id === SecurityPageName.mlLanding); if (mlLinkIndex !== -1) { @@ -49,16 +70,37 @@ const processNavLinks = ( }; } + // Assets, adding fleet external sub-links + const assetsLinkIndex = projectNavLinks.findIndex(({ id }) => id === SecurityPageName.assets); + if (assetsLinkIndex !== -1) { + const assetsNavLink = projectNavLinks[assetsLinkIndex]; + projectNavLinks[assetsLinkIndex] = { + ...assetsNavLink, + links: [...assetsNavLinks, ...(assetsNavLink.links ?? [])], // adds fleet to the existing (endpoints and cloud) links + }; + } + + // Project Settings, adding all external sub-links + const projectSettingsLinkIndex = projectNavLinks.findIndex( + ({ id }) => id === SecurityPageName.projectSettings + ); + if (projectSettingsLinkIndex !== -1) { + const projectSettingsNavLink = projectNavLinks[projectSettingsLinkIndex]; + projectNavLinks[projectSettingsLinkIndex] = { + ...projectSettingsNavLink, + categories: projectSettingsNavCategories, + links: [...projectSettingsNavLinks, ...(projectSettingsNavLink.links ?? [])], + }; + } + // Dev Tools. just pushing it projectNavLinks.push(devToolsNavLink); - // TODO: Project Settings. Override "Settings" link - - return filterDisabled(projectNavLinks, chromeNavLinks); + return processCloudLinks(filterDisabled(projectNavLinks, chromeNavLinks), cloud); }; /** - * Filters out the disabled external nav links from the project nav links. + * Filters out the disabled external kibana nav links from the project nav links. * Internal Security links are already filtered by the security_solution plugin appLinks. */ const filterDisabled = ( @@ -67,7 +109,7 @@ const filterDisabled = ( ): ProjectNavigationLink[] => { return projectNavLinks.reduce((filteredNavLinks, navLink) => { const { id, links } = navLink; - if (isExternalId(id)) { + if (!isSecurityId(id) && !isCloudLink(id)) { const navLinkId = getNavLinkIdFromProjectPageName(id); if (!chromeNavLinks.has(navLinkId)) { return filteredNavLinks; @@ -81,3 +123,23 @@ const filterDisabled = ( return filteredNavLinks; }, []); }; + +const processCloudLinks = ( + links: ProjectNavigationLink[], + cloud: CloudStart +): ProjectNavigationLink[] => { + return links.map((link) => { + const extraProps: Partial = {}; + if (isCloudLink(link.id)) { + const externalUrl = getCloudUrl(getCloudLinkKey(link.id), cloud); + extraProps.externalUrl = externalUrl || '#'; // fallback to # if empty, should only happen in dev + } + if (link.links) { + extraProps.links = processCloudLinks(link.links, cloud); + } + return { + ...link, + ...extraProps, + }; + }); +}; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/assets_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/assets_links.ts new file mode 100644 index 0000000000000..54d593478a042 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/assets_links.ts @@ -0,0 +1,87 @@ +/* + * 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 { SecurityPageName } from '@kbn/security-solution-navigation'; +import { SERVER_APP_ID } from '@kbn/security-solution-plugin/common'; +import type { LinkItem } from '@kbn/security-solution-plugin/public'; +import { ExternalPageName, SecurityPagePath } from '../constants'; +import type { ProjectNavigationLink } from '../types'; +import { IconEcctlLazy, IconFleetLazy } from '../../../common/lazy_icons'; +import * as i18n from './assets_translations'; + +// appLinks configures the Security Solution pages links +const assetsAppLink: LinkItem = { + id: SecurityPageName.assets, + title: i18n.ASSETS_TITLE, + path: SecurityPagePath[SecurityPageName.assets], + capabilities: [`${SERVER_APP_ID}.show`], + hideTimeline: true, + skipUrlState: true, + links: [], // endpoints and cloudDefend links are added in createAssetsLinkFromManage +}; + +// TODO: define this Cloud Defend app link in security_solution plugin +const assetsCloudDefendAppLink: LinkItem = { + id: SecurityPageName.cloudDefend, + title: i18n.CLOUD_DEFEND_TITLE, + description: i18n.CLOUD_DEFEND_DESCRIPTION, + path: SecurityPagePath[SecurityPageName.cloudDefend], + capabilities: [`${SERVER_APP_ID}.show`], + landingIcon: IconEcctlLazy, + isBeta: true, + hideTimeline: true, + links: [], // cloudDefendPolicies link is added in createAssetsLinkFromManage +}; + +export const createAssetsLinkFromManage = (manageLink: LinkItem): LinkItem => { + const assetsSubLinks = []; + + // Get endpoint sub links from the manage categories + const endpointsSubLinkIds = + manageLink.categories + ?.find(({ linkIds }) => linkIds?.includes(SecurityPageName.endpoints)) + ?.linkIds?.filter((linkId) => linkId !== SecurityPageName.endpoints) ?? []; + + const endpointsLink = manageLink.links?.find(({ id }) => id === SecurityPageName.endpoints); + const endpointsSubLinks = + manageLink.links?.filter(({ id }) => endpointsSubLinkIds.includes(id)) ?? []; + if (endpointsLink) { + // Add main endpoints link with all endpoints sub links + assetsSubLinks.push({ ...endpointsLink, links: endpointsSubLinks }); + } + + const cloudPoliciesLink = manageLink.links?.find( + ({ id }) => id === SecurityPageName.cloudDefendPolicies + ); + if (cloudPoliciesLink) { + // Add cloud defend policies link as cloud defend sub link + assetsSubLinks.push({ ...assetsCloudDefendAppLink, links: [cloudPoliciesLink] }); + } + + return { + ...assetsAppLink, + links: assetsSubLinks, + }; +}; + +// navLinks define the navigation links for the Security Solution pages and External pages as well +export const assetsNavLinks: ProjectNavigationLink[] = [ + { + id: ExternalPageName.fleet, + title: i18n.FLEET_TITLE, + landingIcon: IconFleetLazy, + description: i18n.FLEET_DESCRIPTION, + links: [ + { id: ExternalPageName.fleetAgents, title: i18n.FLEET_AGENTS_TITLE }, + { id: ExternalPageName.fleetPolicies, title: i18n.FLEET_POLICIES_TITLE }, + { id: ExternalPageName.fleetEnrollmentTokens, title: i18n.FLEET_ENROLLMENT_TOKENS_TITLE }, + { id: ExternalPageName.fleetUninstallTokens, title: i18n.FLEET_UNINSTALL_TOKENS_TITLE }, + { id: ExternalPageName.fleetDataStreams, title: i18n.FLEET_DATA_STREAMS_TITLE }, + { id: ExternalPageName.fleetSettings, title: i18n.FLEET_SETTINGS_TITLE }, + ], + }, +]; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/assets_translations.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/assets_translations.ts new file mode 100644 index 0000000000000..55a154d56e29d --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/assets_translations.ts @@ -0,0 +1,77 @@ +/* + * 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 ASSETS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.title', + { + defaultMessage: 'Assets', + } +); + +export const CLOUD_DEFEND_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.cloud_defend.title', + { + defaultMessage: 'Cloud', + } +); +export const CLOUD_DEFEND_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.cloud_defend.description', + { + defaultMessage: 'Cloud hosts running Elastic Defend', + } +); + +export const FLEET_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.title', + { + defaultMessage: 'Fleet', + } +); +export const FLEET_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.description', + { + defaultMessage: 'Centralized management for Elastic Agents', + } +); +export const FLEET_AGENTS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.agents.title', + { + defaultMessage: 'Agents', + } +); +export const FLEET_POLICIES_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.policies.title', + { + defaultMessage: 'Policies', + } +); +export const FLEET_ENROLLMENT_TOKENS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.enrollmentTokens.title', + { + defaultMessage: 'Enrollment tokens', + } +); +export const FLEET_UNINSTALL_TOKENS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.uninstallTokens.title', + { + defaultMessage: 'Uninstall tokens', + } +); +export const FLEET_DATA_STREAMS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.dataStreams.title', + { + defaultMessage: 'Data streams', + } +); +export const FLEET_SETTINGS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.assets.fleet.settings.title', + { + defaultMessage: 'Settings', + } +); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_links.ts index 7bf50a3e2452f..684304a665fcc 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_links.ts @@ -7,9 +7,9 @@ import { ExternalPageName } from '../constants'; import type { ProjectNavigationLink } from '../types'; -import { DEV_TOOLS_TITLE } from './translations'; +import { DEV_TOOLS_TITLE } from './dev_tools_translations'; export const devToolsNavLink: ProjectNavigationLink = { - id: ExternalPageName.devToolsRoot, + id: ExternalPageName.devTools, title: DEV_TOOLS_TITLE, }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_translations.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_translations.ts new file mode 100644 index 0000000000000..7a4e94a6cd053 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/dev_tools_translations.ts @@ -0,0 +1,15 @@ +/* + * 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 DEV_TOOLS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.devTools.title', + { + defaultMessage: 'Dev tools', + } +); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/investigations_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/investigations_links.ts new file mode 100644 index 0000000000000..be0e956987e08 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/investigations_links.ts @@ -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 { SecurityPageName } from '@kbn/security-solution-navigation'; +import { SERVER_APP_ID } from '@kbn/security-solution-plugin/common'; +import type { LinkItem } from '@kbn/security-solution-plugin/public'; +import { ExternalPageName, SecurityPagePath } from '../constants'; +import type { ProjectNavigationLink } from '../types'; +import { IconOsqueryLazy, IconTimelineLazy } from '../../../common/lazy_icons'; +import * as i18n from './investigations_translations'; + +// appLinks configures the Security Solution pages links +const investigationsAppLink: LinkItem = { + id: SecurityPageName.investigations, + title: i18n.INVESTIGATIONS_TITLE, + path: SecurityPagePath[SecurityPageName.investigations], + capabilities: [`${SERVER_APP_ID}.show`], + hideTimeline: true, + skipUrlState: true, + links: [], // timeline link are added in createInvestigationsLinkFromTimeline +}; + +export const createInvestigationsLinkFromTimeline = (timelineLink: LinkItem): LinkItem => { + return { + ...investigationsAppLink, + links: [ + { ...timelineLink, description: i18n.TIMELINE_DESCRIPTION, landingIcon: IconTimelineLazy }, + ], + }; +}; + +// navLinks define the navigation links for the Security Solution pages and External pages as well +export const investigationsNavLinks: ProjectNavigationLink[] = [ + { + id: ExternalPageName.osquery, + title: i18n.OSQUERY_TITLE, + landingIcon: IconOsqueryLazy, + description: i18n.OSQUERY_DESCRIPTION, + }, +]; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/investigations_translations.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/investigations_translations.ts new file mode 100644 index 0000000000000..0b7cbb4e9cd10 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/investigations_translations.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 { i18n } from '@kbn/i18n'; + +export const INVESTIGATIONS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.investigations.title', + { + defaultMessage: 'Investigations', + } +); + +export const TIMELINE_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.investigations.timeline.title', + { + defaultMessage: 'Central place for timelines and timeline templates', + } +); + +export const OSQUERY_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.investigations.osquery.title', + { + defaultMessage: 'Osquery', + } +); +export const OSQUERY_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.investigations.osquery.description', + { + defaultMessage: 'Deploy Osquery with Elastic Agent, then run and schedule queries in Kibana', + } +); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_links.ts index d80dd32552d81..ed324cfb6f52c 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_links.ts @@ -10,7 +10,7 @@ import { SERVER_APP_ID } from '@kbn/security-solution-plugin/common'; import type { LinkItem } from '@kbn/security-solution-plugin/public'; import { ExternalPageName, SecurityPagePath } from '../constants'; import type { ProjectLinkCategory, ProjectNavigationLink } from '../types'; -import * as i18n from './translations'; +import * as i18n from './ml_translations'; import { IconLensLazy, IconEndpointLazy, diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/translations.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_translations.ts similarity index 80% rename from x-pack/plugins/security_solution_serverless/public/navigation/links/sections/translations.ts rename to x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_translations.ts index a1e75705516d7..301e5b05bfa67 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/translations.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/ml_translations.ts @@ -8,40 +8,40 @@ import { i18n } from '@kbn/i18n'; export const ML_TITLE = i18n.translate('xpack.securitySolutionServerless.appLinks.ml.title', { - defaultMessage: 'Machine Learning', + defaultMessage: 'Machine learning', }); export const ML_KEYWORD = i18n.translate('xpack.securitySolutionServerless.appLinks.ml.keyword', { - defaultMessage: 'Machine Learning', + defaultMessage: 'Machine learning', }); export const ANOMALY_DETECTION_CATEGORY = i18n.translate( 'xpack.securitySolutionServerless.navCategories.ml.anomalyDetection.title', { - defaultMessage: 'Anomaly Detection', + defaultMessage: 'Anomaly detection', } ); export const DATA_FRAME_ANALYTICS_CATEGORY = i18n.translate( 'xpack.securitySolutionServerless.navCategories.ml.dataFrameAnalyticstitle', { - defaultMessage: 'Data Frame Analytics', + defaultMessage: 'Data frame analytics', } ); export const MODEL_MANAGEMENT_CATEGORY = i18n.translate( 'xpack.securitySolutionServerless.navCategories.ml.modelManagement.title', { - defaultMessage: 'Model Management', + defaultMessage: 'Model management', } ); export const DATA_VISUALIZER_CATEGORY = i18n.translate( 'xpack.securitySolutionServerless.navCategories.ml.dataVisualizer.title', { - defaultMessage: 'Data Visualizer', + defaultMessage: 'Data visualizer', } ); export const AIOPS_LABS_CATEGORY = i18n.translate( 'xpack.securitySolutionServerless.navCategories.ml.aiopsLabs.title', { - defaultMessage: 'Aiops Labs', + defaultMessage: 'Aiops labs', } ); @@ -54,7 +54,7 @@ export const OVERVIEW_TITLE = i18n.translate( export const OVERVIEW_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.overview.desc', { - defaultMessage: 'Overview Page', + defaultMessage: 'Overview page', } ); export const NOTIFICATIONS_TITLE = i18n.translate( @@ -66,7 +66,7 @@ export const NOTIFICATIONS_TITLE = i18n.translate( export const NOTIFICATIONS_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.notifications.desc', { - defaultMessage: 'Notifications Page', + defaultMessage: 'Notifications page', } ); export const ANOMALY_DETECTION_TITLE = i18n.translate( @@ -78,31 +78,31 @@ export const ANOMALY_DETECTION_TITLE = i18n.translate( export const ANOMALY_DETECTION_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.anomalyDetection.desc', { - defaultMessage: 'Jobs Page', + defaultMessage: 'Jobs page', } ); export const ANOMALY_EXPLORER_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.anomalyExplorer.title', { - defaultMessage: 'Anomaly Explorer', + defaultMessage: 'Anomaly explorer', } ); export const ANOMALY_EXPLORER_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.anomalyExplorer.desc', { - defaultMessage: 'Anomaly Explorer Page', + defaultMessage: 'Anomaly explorer Page', } ); export const SINGLE_METRIC_VIEWER_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.singleMetricViewer.title', { - defaultMessage: 'Single Metric Viewer', + defaultMessage: 'Single metric viewer', } ); export const SINGLE_METRIC_VIEWER_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.singleMetricViewer.desc', { - defaultMessage: 'Single Metric Viewer Page', + defaultMessage: 'Single metric viewer page', } ); export const SETTINGS_TITLE = i18n.translate( @@ -114,7 +114,7 @@ export const SETTINGS_TITLE = i18n.translate( export const SETTINGS_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.settings.desc', { - defaultMessage: 'Settings Page', + defaultMessage: 'Settings page', } ); export const DATA_FRAME_ANALYTICS_TITLE = i18n.translate( @@ -126,43 +126,43 @@ export const DATA_FRAME_ANALYTICS_TITLE = i18n.translate( export const DATA_FRAME_ANALYTICS_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.dataFrameAnalytics.desc', { - defaultMessage: 'Jobs Page', + defaultMessage: 'Jobs page', } ); export const RESULT_EXPLORER_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.resultExplorer.title', { - defaultMessage: 'Result Explorer', + defaultMessage: 'Result explorer', } ); export const RESULT_EXPLORER_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.resultExplorer.desc', { - defaultMessage: 'Result Explorer Page', + defaultMessage: 'Result explorer page', } ); export const ANALYTICS_MAP_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.analyticsMap.title', { - defaultMessage: 'Analytics Map', + defaultMessage: 'Analytics map', } ); export const ANALYTICS_MAP_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.analyticsMap.desc', { - defaultMessage: 'Analytics Map Page', + defaultMessage: 'Analytics map page', } ); export const NODES_OVERVIEW_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.nodesOverview.title', { - defaultMessage: 'Nodes Overview', + defaultMessage: 'Nodes overview', } ); export const NODES_OVERVIEW_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.nodesOverview.desc', { - defaultMessage: 'Nodes Overview Page', + defaultMessage: 'Nodes overview page', } ); export const NODES_TITLE = i18n.translate( @@ -174,7 +174,7 @@ export const NODES_TITLE = i18n.translate( export const NODES_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.nodes.desc', { - defaultMessage: 'Nodes Page', + defaultMessage: 'Nodes page', } ); export const FILE_UPLOAD_TITLE = i18n.translate( @@ -186,7 +186,7 @@ export const FILE_UPLOAD_TITLE = i18n.translate( export const FILE_UPLOAD_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.fileUpload.desc', { - defaultMessage: 'File Page', + defaultMessage: 'File page', } ); export const INDEX_DATA_VISUALIZER_TITLE = i18n.translate( @@ -198,49 +198,42 @@ export const INDEX_DATA_VISUALIZER_TITLE = i18n.translate( export const INDEX_DATA_VISUALIZER_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.indexDataVisualizer.desc', { - defaultMessage: 'Data view Page', + defaultMessage: 'Data view page', } ); export const EXPLAIN_LOG_RATE_SPIKES_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.explainLogRateSpikes.title', { - defaultMessage: 'Explain Log Rate Spikes', + defaultMessage: 'Explain log rate spikes', } ); export const EXPLAIN_LOG_RATE_SPIKES_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.explainLogRateSpikes.desc', { - defaultMessage: 'Explain Log Rate Spikes Page', + defaultMessage: 'Explain log rate spikes page', } ); export const LOG_PATTERN_ANALYSIS_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.logPatternAnalysis.title', { - defaultMessage: 'Log Pattern Analysis', + defaultMessage: 'Log pattern analysis', } ); export const LOG_PATTERN_ANALYSIS_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.logPatternAnalysis.desc', { - defaultMessage: 'Log Pattern Analysis Page', + defaultMessage: 'Log pattern analysis page', } ); export const CHANGE_POINT_DETECTIONS_TITLE = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.changePointDetections.title', { - defaultMessage: 'Change Point Detections', + defaultMessage: 'Change point detections', } ); export const CHANGE_POINT_DETECTIONS_DESC = i18n.translate( 'xpack.securitySolutionServerless.navLinks.ml.changePointDetections.desc', { - defaultMessage: 'Change Point Detections Page', - } -); - -export const DEV_TOOLS_TITLE = i18n.translate( - 'xpack.securitySolutionServerless.navLinks.devTools.title', - { - defaultMessage: 'Dev Tools', + defaultMessage: 'Change point detections page', } ); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts new file mode 100644 index 0000000000000..039ffcfda7dd2 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts @@ -0,0 +1,184 @@ +/* + * 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 { LinkCategoryType, SecurityPageName } from '@kbn/security-solution-navigation'; +import { SERVER_APP_ID } from '@kbn/security-solution-plugin/common'; +import type { LinkItem } from '@kbn/security-solution-plugin/public'; +import { ExternalPageName, SecurityPagePath } from '../constants'; +import type { ProjectLinkCategory, ProjectNavigationLink } from '../types'; +import { + IconGraphLazy, + IconLoggingLazy, + IconIndexManagementLazy, + IconSecurityShieldLazy, + IconMapServicesLazy, + IconProductFeaturesAlertingLazy, +} from '../../../common/lazy_icons'; +import * as i18n from './project_settings_translations'; + +// appLinks configures the Security Solution pages links +const projectSettingsAppLink: LinkItem = { + id: SecurityPageName.projectSettings, + title: i18n.PROJECT_SETTINGS_TITLE, + path: SecurityPagePath[SecurityPageName.projectSettings], + capabilities: [`${SERVER_APP_ID}.show`], + hideTimeline: true, + skipUrlState: true, + links: [], // endpoints and cloudDefend links are added in createAssetsLinkFromManage +}; + +export const createProjectSettingsLinkFromManage = (manageLink: LinkItem): LinkItem => { + const projectSettingsSubLinks = []; + + const entityAnalyticsLink = manageLink.links?.find( + ({ id }) => id === SecurityPageName.entityAnalyticsManagement + ); + if (entityAnalyticsLink) { + projectSettingsSubLinks.push(entityAnalyticsLink); + } + + return { + ...projectSettingsAppLink, + links: projectSettingsSubLinks, // cloudDefend and endpoints links are added in the projectAppLinksSwitcher on runtime + }; +}; + +export const projectSettingsNavCategories: ProjectLinkCategory[] = [ + { + type: LinkCategoryType.separator, + linkIds: [ + ExternalPageName.cloudUsersAndRoles, + ExternalPageName.cloudBilling, + ExternalPageName.integrationsSecurity, + SecurityPageName.entityAnalyticsManagement, + ], + }, + { + type: LinkCategoryType.accordion, + label: i18n.MANAGEMENT_CATEGORY_TITLE, + categories: [ + { + label: i18n.DATA_CATEGORY_TITLE, + iconType: IconIndexManagementLazy, + linkIds: [ + ExternalPageName.managementIndexManagement, + ExternalPageName.managementTransforms, + ExternalPageName.managementIngestPipelines, + ExternalPageName.managementDataViews, + ExternalPageName.managementJobsListLink, + ExternalPageName.managementPipelines, + ], + }, + { + label: i18n.ALERTS_INSIGHTS_CATEGORY_TITLE, + iconType: IconProductFeaturesAlertingLazy, + linkIds: [ + ExternalPageName.managementCases, + ExternalPageName.managementTriggersActionsConnectors, + ExternalPageName.managementMaintenanceWindows, + ], + }, + { + label: i18n.CONTENT_CATEGORY_TITLE, + iconType: IconSecurityShieldLazy, + linkIds: [ + ExternalPageName.managementObjects, + ExternalPageName.managementFiles, + ExternalPageName.managementReporting, + ExternalPageName.managementTags, + ], + }, + { + label: i18n.OTHER_CATEGORY_TITLE, + iconType: IconMapServicesLazy, + linkIds: [ExternalPageName.managementApiKeys, ExternalPageName.managementSettings], + }, + ], + }, +]; + +// navLinks define the navigation links for the Security Solution pages and External pages as well +export const projectSettingsNavLinks: ProjectNavigationLink[] = [ + { + id: ExternalPageName.cloudUsersAndRoles, + title: i18n.CLOUD_USERS_ROLES_TITLE, + description: i18n.CLOUD_USERS_ROLES_DESCRIPTION, + landingIcon: IconGraphLazy, + }, + { + id: ExternalPageName.cloudBilling, + title: i18n.CLOUD_BILLING_TITLE, + description: i18n.CLOUD_BILLING_DESCRIPTION, + landingIcon: IconLoggingLazy, + }, + { + id: ExternalPageName.integrationsSecurity, + title: i18n.INTEGRATIONS_TITLE, + description: i18n.INTEGRATIONS_DESCRIPTION, + landingIcon: IconIndexManagementLazy, + }, + { + id: ExternalPageName.managementIndexManagement, + title: i18n.MANAGEMENT_INDEX_MANAGEMENT_TITLE, + }, + { + id: ExternalPageName.managementTransforms, + title: i18n.MANAGEMENT_TRANSFORMS_TITLE, + }, + { + id: ExternalPageName.managementMaintenanceWindows, + title: i18n.MANAGEMENT_MAINTENANCE_WINDOWS_TITLE, + }, + { + id: ExternalPageName.managementIngestPipelines, + title: i18n.MANAGEMENT_INGEST_PIPELINES_TITLE, + }, + { + id: ExternalPageName.managementDataViews, + title: i18n.MANAGEMENT_DATA_VIEWS_TITLE, + }, + { + id: ExternalPageName.managementJobsListLink, + title: i18n.MANAGEMENT_ML_TITLE, + }, + { + id: ExternalPageName.managementPipelines, + title: i18n.MANAGEMENT_LOGSTASH_PIPELINES_TITLE, + }, + { + id: ExternalPageName.managementCases, + title: i18n.MANAGEMENT_CASES_TITLE, + }, + { + id: ExternalPageName.managementTriggersActionsConnectors, + title: i18n.MANAGEMENT_CONNECTORS_TITLE, + }, + { + id: ExternalPageName.managementReporting, + title: i18n.MANAGEMENT_REPORTING_TITLE, + }, + { + id: ExternalPageName.managementObjects, + title: i18n.MANAGEMENT_SAVED_OBJECTS_TITLE, + }, + { + id: ExternalPageName.managementApiKeys, + title: i18n.MANAGEMENT_API_KEYS_TITLE, + }, + { + id: ExternalPageName.managementTags, + title: i18n.MANAGEMENT_TAGS_TITLE, + }, + { + id: ExternalPageName.managementFiles, + title: i18n.MANAGEMENT_FILES_TITLE, + }, + { + id: ExternalPageName.managementSettings, + title: i18n.MANAGEMENT_SETTINGS_TITLE, + }, +]; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_translations.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_translations.ts new file mode 100644 index 0000000000000..036c3a350a74f --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_translations.ts @@ -0,0 +1,176 @@ +/* + * 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 PROJECT_SETTINGS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.title', + { + defaultMessage: 'Project settings', + } +); + +export const INTEGRATIONS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.integrations.title', + { + defaultMessage: 'Integrations', + } +); +export const INTEGRATIONS_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.integrations.description', + { + defaultMessage: 'Security integrations', + } +); + +export const CLOUD_USERS_ROLES_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.usersAndRoles.title', + { + defaultMessage: 'Users & roles', + } +); +export const CLOUD_USERS_ROLES_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.usersAndRoles.description', + { + defaultMessage: 'Users and roles management', + } +); + +export const CLOUD_BILLING_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.billing.title', + { + defaultMessage: 'Billing & consumptions', + } +); +export const CLOUD_BILLING_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.billing.description', + { + defaultMessage: 'Billing & consumption page', + } +); + +export const MANAGEMENT_INDEX_MANAGEMENT_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.indexManagement.title', + { + defaultMessage: 'Index management', + } +); +export const MANAGEMENT_TRANSFORMS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.transforms.title', + { + defaultMessage: 'Transforms', + } +); +export const MANAGEMENT_INGEST_PIPELINES_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.ingestPipelines.title', + { + defaultMessage: 'Ingest pipelines', + } +); +export const MANAGEMENT_DATA_VIEWS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.dataViews.title', + { + defaultMessage: 'Data views', + } +); +export const MANAGEMENT_ML_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.ml.title', + { + defaultMessage: 'Machine learning', + } +); +export const MANAGEMENT_LOGSTASH_PIPELINES_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.logstashPipelines.title', + { + defaultMessage: 'Logstash pipelines', + } +); +export const MANAGEMENT_CASES_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.cases.title', + { + defaultMessage: 'Cases', + } +); +export const MANAGEMENT_CONNECTORS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.connectors.title', + { + defaultMessage: 'Connectors', + } +); +export const MANAGEMENT_SAVED_OBJECTS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.savedObjects.title', + { + defaultMessage: 'Saved objects', + } +); +export const MANAGEMENT_TAGS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.tags.title', + { + defaultMessage: 'Tags', + } +); +export const MANAGEMENT_SETTINGS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.settings.title', + { + defaultMessage: 'Advanced settings', + } +); +export const MANAGEMENT_MAINTENANCE_WINDOWS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.maintenanceWindows.title', + { + defaultMessage: 'Maintenance windows', + } +); +export const MANAGEMENT_REPORTING_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.reporting.title', + { + defaultMessage: 'Reporting', + } +); +export const MANAGEMENT_API_KEYS_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.apiKeys.title', + { + defaultMessage: 'Api keys', + } +); +export const MANAGEMENT_FILES_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.management.files.title', + { + defaultMessage: 'Files', + } +); + +export const MANAGEMENT_CATEGORY_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.category.management', + { + defaultMessage: 'MANAGEMENT', + } +); +export const DATA_CATEGORY_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.subCategory.data', + { + defaultMessage: 'DATA', + } +); +export const ALERTS_INSIGHTS_CATEGORY_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.subCategory.alertsAndInsights', + { + defaultMessage: 'ALERTS AND INSIGHTS', + } +); +export const CONTENT_CATEGORY_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.subCategory.content', + { + defaultMessage: 'CONTENT', + } +); +export const OTHER_CATEGORY_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.navLinks.projectSettings.subCategory.other', + { + defaultMessage: 'OTHER', + } +); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts index 5501c475e68b5..109f28ba04624 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts @@ -6,12 +6,12 @@ */ import { APP_UI_ID } from '@kbn/security-solution-plugin/common'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { ProjectPageName } from './types'; export const getNavLinkIdFromProjectPageName = (projectNavLinkId: ProjectPageName): string => { - const fullId = projectNavLinkId.includes(':') - ? projectNavLinkId - : `${APP_UI_ID}:${projectNavLinkId}`; // add the Security appId if not defined + const cleanId = projectNavLinkId.replace(/\/(.*)$/, ''); // remove any trailing path + const fullId = cleanId.includes(':') ? cleanId : `${APP_UI_ID}:${cleanId}`; // add the Security appId if not defined return fullId.replace(/:$/, ''); // clean trailing separator to app root links to contain the appId alone }; @@ -20,3 +20,24 @@ export const getProjectPageNameFromNavLinkId = (navLinkId: string): ProjectPageN const fullId = cleanId.replace(`${APP_UI_ID}:`, ''); // remove Security appId if present return fullId as ProjectPageName; }; + +export const isCloudLink = (linkId: string): boolean => linkId.startsWith('cloud:'); +export const getCloudLinkKey = (linkId: string): string => linkId.replace('cloud:', ''); +export const getCloudUrl = (cloudUrlKey: string, cloud: CloudStart): string | undefined => { + switch (cloudUrlKey) { + case 'billing': + return cloud.billingUrl; + case 'deployment': + return cloud.deploymentUrl; + case 'organization': + return cloud.organizationUrl; + case 'performance': + return cloud.performanceUrl; + case 'profile': + return cloud.profileUrl; + case 'usersAndRoles': + return cloud.usersAndRolesUrl; + default: + return undefined; + } +}; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts index b7da21cb5e1cd..56a0abacd4d94 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.test.ts @@ -119,18 +119,10 @@ describe('subscribeNavigationTree', () => { expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ navigationTree: [ { - id: 'root', - title: 'Root', - path: ['root'], - breadcrumbStatus: 'hidden', - children: [ - { - id: chromeNavLink1.id, - title: link1.title, - path: ['root', chromeNavLink1.id], - deepLink: chromeNavLink1, - }, - ], + id: chromeNavLink1.id, + title: link1.title, + path: [chromeNavLink1.id], + deepLink: chromeNavLink1, }, ], }); @@ -144,18 +136,10 @@ describe('subscribeNavigationTree', () => { expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ navigationTree: [ { - id: 'root', - title: 'Root', - path: ['root'], - breadcrumbStatus: 'hidden', - children: [ - { - id: chromeNavLink3.id, - title: chromeNavLink3.title, - path: ['root', chromeNavLink3.id], - deepLink: chromeNavLink3, - }, - ], + id: chromeNavLink3.id, + title: chromeNavLink3.title, + path: [chromeNavLink3.id], + deepLink: chromeNavLink3, }, ], }); @@ -169,24 +153,16 @@ describe('subscribeNavigationTree', () => { expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ navigationTree: [ { - id: 'root', - title: 'Root', - path: ['root'], - breadcrumbStatus: 'hidden', + id: chromeNavLink1.id, + title: link1.title, + path: [chromeNavLink1.id], + deepLink: chromeNavLink1, children: [ { - id: chromeNavLink1.id, - title: link1.title, - path: ['root', chromeNavLink1.id], - deepLink: chromeNavLink1, - children: [ - { - id: chromeNavLink2.id, - title: link2.title, - path: ['root', chromeNavLink1.id, chromeNavLink2.id], - deepLink: chromeNavLink2, - }, - ], + id: chromeNavLink2.id, + title: link2.title, + path: [chromeNavLink1.id, chromeNavLink2.id], + deepLink: chromeNavLink2, }, ], }, @@ -207,40 +183,27 @@ describe('subscribeNavigationTree', () => { expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ navigationTree: [ { - id: 'root', - title: 'Root', - path: ['root'], - breadcrumbStatus: 'hidden', + id: chromeNavLinkTest.id, + title: link1.title, + path: [chromeNavLinkTest.id], + deepLink: chromeNavLinkTest, children: [ { - id: chromeNavLinkTest.id, - title: link1.title, - path: ['root', chromeNavLinkTest.id], - deepLink: chromeNavLinkTest, + id: chromeNavLinkMl1.id, + title: chromeNavLinkMl1.title, + path: [chromeNavLinkTest.id, chromeNavLinkMl1.id], + deepLink: chromeNavLinkMl1, + }, + { + id: defaultNavCategory1.id, + title: defaultNavCategory1.title, + path: [chromeNavLinkTest.id, defaultNavCategory1.id], children: [ { - id: chromeNavLinkMl1.id, - title: chromeNavLinkMl1.title, - path: ['root', chromeNavLinkTest.id, chromeNavLinkMl1.id], - deepLink: chromeNavLinkMl1, - }, - { - id: defaultNavCategory1.id, - title: defaultNavCategory1.title, - path: ['root', chromeNavLinkTest.id, defaultNavCategory1.id], - children: [ - { - id: chromeNavLinkMl2.id, - title: 'Overridden ML SubLink 2', - path: [ - 'root', - chromeNavLinkTest.id, - defaultNavCategory1.id, - chromeNavLinkMl2.id, - ], - deepLink: chromeNavLinkMl2, - }, - ], + id: chromeNavLinkMl2.id, + title: 'Overridden ML SubLink 2', + path: [chromeNavLinkTest.id, defaultNavCategory1.id, chromeNavLinkMl2.id], + deepLink: chromeNavLinkMl2, }, ], }, @@ -259,18 +222,10 @@ describe('subscribeNavigationTree', () => { expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ navigationTree: [ { - id: 'root', - title: 'Root', - path: ['root'], - breadcrumbStatus: 'hidden', - children: [ - { - id: chromeNavLink2.id, - title: link2.title, - path: ['root', chromeNavLink2.id], - deepLink: chromeNavLink2, - }, - ], + id: chromeNavLink2.id, + title: link2.title, + path: [chromeNavLink2.id], + deepLink: chromeNavLink2, }, ], }); @@ -292,25 +247,17 @@ describe('subscribeNavigationTree', () => { expect(testServices.serverless.setNavigation).toHaveBeenCalledWith({ navigationTree: [ { - id: 'root', - title: 'Root', - path: ['root'], + id: chromeNavLinkTest.id, + title: link1.title, + path: [chromeNavLinkTest.id], + deepLink: chromeNavLinkTest, breadcrumbStatus: 'hidden', - children: [ - { - id: chromeNavLinkTest.id, - title: link1.title, - path: ['root', chromeNavLinkTest.id], - deepLink: chromeNavLinkTest, - breadcrumbStatus: 'hidden', - }, - { - id: chromeNavLink2.id, - title: link2.title, - path: ['root', chromeNavLink2.id], - deepLink: chromeNavLink2, - }, - ], + }, + { + id: chromeNavLink2.id, + title: link2.title, + path: [chromeNavLink2.id], + deepLink: chromeNavLink2, }, ], }); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts index 5f3f8552ad617..7210498a97d57 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree.ts @@ -42,18 +42,7 @@ export const subscribeNavigationTree = (services: Services): void => { // projectNavLinks$ updates when chrome.navLinks changes, no need to subscribe chrome.navLinks.getNavLinks$() again. getProjectNavLinks$().subscribe((projectNavLinks) => { - // TODO: The root link is temporary until the Platform bug having multiple links at first level is solved. - // Assign using the following line when the issue is solved: - // const navigationTree = formatChromeProjectNavNodes(chrome.navLinks, projectNavLinks), - const navigationTree: ChromeProjectNavigationNode[] = [ - { - id: 'root', - title: 'Root', - path: ['root'], - breadcrumbStatus: 'hidden', - children: formatChromeProjectNavNodes(projectNavLinks, ['root']), - }, - ]; + const navigationTree = formatChromeProjectNavNodes(projectNavLinks); serverless.setNavigation({ navigationTree }); }); }; @@ -98,7 +87,7 @@ export const getFormatChromeProjectNavNodes = (services: Services) => { if (id === SecurityPageName.mlLanding) { return processDefaultNav(mlDefaultNav.children, link.path); } - if (id === ExternalPageName.devToolsRoot) { + if (id === ExternalPageName.devTools) { return processDefaultNav(devToolsDefaultNav.children, link.path); } return undefined; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts index 7cc2ad97bff52..6f2995b27939c 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts @@ -28,17 +28,17 @@ export const CATEGORIES: SeparatorLinkCategory[] = [ { type: LinkCategoryType.separator, linkIds: [ - SecurityPageName.timelines, + SecurityPageName.investigations, SecurityPageName.threatIntelligence, SecurityPageName.exploreLanding, ], }, { type: LinkCategoryType.separator, - linkIds: [SecurityPageName.rulesLanding], + linkIds: [ExternalPageName.fleet, SecurityPageName.assets, SecurityPageName.rulesLanding], }, { type: LinkCategoryType.separator, - linkIds: [SecurityPageName.mlLanding, ExternalPageName.devToolsRoot], + linkIds: [SecurityPageName.mlLanding], }, ]; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.test.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.test.tsx index f12664c02c3ea..532ca9b985d51 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.test.tsx +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.test.tsx @@ -48,15 +48,14 @@ const sideNavItems = [ }, ]; -mockUseSideNavItems.mockReturnValue(sideNavItems); - describe('SecuritySideNavigation', () => { beforeEach(() => { + mockUseSideNavItems.mockReturnValue(sideNavItems); jest.clearAllMocks(); }); it('should render loading when not items received', () => { - mockUseSideNavItems.mockReturnValueOnce([]); + mockUseSideNavItems.mockReturnValue([]); const component = render(, { wrapper: I18nProvider, }); @@ -103,7 +102,7 @@ describe('SecuritySideNavigation', () => { expect(mockSolutionSideNav).toHaveBeenCalledWith( expect.objectContaining({ - selectedId: ExternalPageName.devToolsRoot, + selectedId: ExternalPageName.devTools, }) ); }); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx index 45d31875cc0ea..43b02ab94824d 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx @@ -10,21 +10,32 @@ import { EuiLoadingSpinner, useEuiTheme } from '@elastic/eui'; import type { SideNavComponent } from '@kbn/core-chrome-browser'; import { SolutionNav } from '@kbn/shared-ux-page-solution-nav'; import { SolutionSideNav } from '@kbn/security-solution-side-nav'; +import { useObservable } from 'react-use'; import { useSideNavItems } from './use_side_nav_items'; import { CATEGORIES } from './categories'; import { getProjectPageNameFromNavLinkId } from '../links/util'; +import { useKibana } from '../../common/services'; export const SecuritySideNavigation: SideNavComponent = React.memo(function SecuritySideNavigation({ activeNodes: [activeChromeNodes], }) { + const { hasHeaderBanner$ } = useKibana().services.chrome; const { euiTheme } = useEuiTheme(); const items = useSideNavItems(); + const hasHeaderBanner = useObservable(hasHeaderBanner$()); const isLoading = items.length === 0; + const panelTopOffset = useMemo( + () => + hasHeaderBanner + ? `calc((${euiTheme.size.l} * 2) + ${euiTheme.size.xl})` + : `calc(${euiTheme.size.l} * 2)`, + [hasHeaderBanner, euiTheme] + ); + const selectedId = useMemo(() => { - // TODO: change the following line to `const mainNode = activeChromeNodes[0]` when the root node is no longer present - const mainNode = activeChromeNodes?.find((node) => node.id !== 'root'); + const mainNode = activeChromeNodes?.[0]; // we only care about the first node to highlight a left nav main item return mainNode ? getProjectPageNameFromNavLinkId(mainNode.id) : ''; }, [activeChromeNodes]); @@ -44,7 +55,7 @@ export const SecuritySideNavigation: SideNavComponent = React.memo(function Secu items={items} categories={CATEGORIES} selectedId={selectedId} - panelTopOffset={`calc(${euiTheme.size.l} * 2)`} + panelTopOffset={panelTopOffset} /> ); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.test.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.test.tsx index 9f6f93cd51c93..b9dfb76923d8a 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.test.tsx +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useSideNavItems } from './use_side_nav_items'; import { SecurityPageName } from '@kbn/security-solution-navigation'; import { mockServices, mockProjectNavLinks } from '../../common/services/__mocks__/services.mock'; +import { ExternalPageName } from '../links/constants'; jest.mock('@kbn/security-solution-navigation/src/navigation'); jest.mock('../../common/services'); @@ -107,4 +108,29 @@ describe('useSideNavItems', () => { }, ]); }); + + it('should openInNewTab for external (cloud) links', async () => { + mockProjectNavLinks.mockReturnValueOnce([ + { + id: ExternalPageName.cloudUsersAndRoles, + externalUrl: 'https://cloud.elastic.co/users_roles', + title: 'Users & Roles', + sideNavIcon: 'someicon', + }, + ]); + const { result } = renderHook(useSideNavItems); + + const items = result.current; + + expect(items).toEqual([ + { + id: ExternalPageName.cloudUsersAndRoles, + href: 'https://cloud.elastic.co/users_roles', + label: 'Users & Roles', + openInNewTab: true, + iconType: 'someicon', + position: 'top', + }, + ]); + }); }); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts index 766178dbbb096..9833e6387b485 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts @@ -5,47 +5,56 @@ * 2.0. */ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { SecurityPageName, type NavigationLink } from '@kbn/security-solution-navigation'; -import { useGetLinkProps, type GetLinkProps } from '@kbn/security-solution-navigation/links'; +import { useGetLinkProps } from '@kbn/security-solution-navigation/links'; import { SolutionSideNavItemPosition, type SolutionSideNavItem, } from '@kbn/security-solution-side-nav'; import { useNavLinks } from '../../common/hooks/use_nav_links'; +import { ExternalPageName } from '../links/constants'; + +type GetLinkProps = (link: NavigationLink) => { + href: string & Partial; +}; const isBottomNavItem = (id: string) => - id === SecurityPageName.landing || id === SecurityPageName.administration; + id === SecurityPageName.landing || + id === SecurityPageName.projectSettings || + id === ExternalPageName.devTools; const isGetStartedNavItem = (id: string) => id === SecurityPageName.landing; /** * Formats generic navigation links into the shape expected by the `SolutionSideNav` */ -const formatLink = (navLink: NavigationLink, getLinkProps: GetLinkProps): SolutionSideNavItem => ({ - id: navLink.id, - label: navLink.title, - iconType: navLink.sideNavIcon, - position: isBottomNavItem(navLink.id) - ? SolutionSideNavItemPosition.bottom - : SolutionSideNavItemPosition.top, - ...getLinkProps({ id: navLink.id }), - ...(navLink.categories?.length && { categories: navLink.categories }), - ...(navLink.links?.length && { - items: navLink.links.reduce((acc, current) => { - if (!current.disabled) { - acc.push({ - id: current.id, - label: current.title, - iconType: current.sideNavIcon, - isBeta: current.isBeta, - betaOptions: current.betaOptions, - ...getLinkProps({ id: current.id }), - }); - } - return acc; - }, []), - }), -}); +const formatLink = (navLink: NavigationLink, getLinkProps: GetLinkProps): SolutionSideNavItem => { + const items = navLink.links?.reduce((acc, current) => { + if (!current.disabled) { + acc.push({ + id: current.id, + label: current.title, + iconType: current.sideNavIcon, + isBeta: current.isBeta, + betaOptions: current.betaOptions, + ...getLinkProps(current), + }); + } + return acc; + }, []); + + return { + id: navLink.id, + label: navLink.title, + iconType: navLink.sideNavIcon, + position: isBottomNavItem(navLink.id) + ? SolutionSideNavItemPosition.bottom + : SolutionSideNavItemPosition.top, + ...getLinkProps(navLink), + ...(navLink.categories?.length && { categories: navLink.categories }), + ...(items && { items }), + }; +}; /** * Formats the get started navigation links into the shape expected by the `SolutionSideNav` @@ -58,7 +67,7 @@ const formatGetStartedLink = ( label: navLink.title, iconType: navLink.sideNavIcon, position: SolutionSideNavItemPosition.bottom, - ...getLinkProps({ id: navLink.id }), + ...getLinkProps(navLink), appendSeparator: true, }); @@ -67,7 +76,21 @@ const formatGetStartedLink = ( */ export const useSideNavItems = (): SolutionSideNavItem[] => { const navLinks = useNavLinks(); - const getLinkProps = useGetLinkProps(); + const getKibanaLinkProps = useGetLinkProps(); + + const getLinkProps = useCallback( + (link) => { + if (link.externalUrl) { + return { + href: link.externalUrl, + openInNewTab: true, + }; + } else { + return getKibanaLinkProps({ id: link.id }); + } + }, + [getKibanaLinkProps] + ); return useMemo( () => diff --git a/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx b/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx new file mode 100644 index 0000000000000..1781a52769297 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/pages/assets.tsx @@ -0,0 +1,68 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { LandingLinksIconsGroups } from '@kbn/security-solution-navigation/landing_links'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { EuiCallOut, EuiPageHeader, EuiSpacer, useEuiTheme } from '@elastic/eui'; +import { LinkButton } from '@kbn/security-solution-navigation/links'; +import { useNavLink } from '../common/hooks/use_nav_links'; +import { ExternalPageName } from '../navigation/links/constants'; + +const INTEGRATIONS_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.assets.integrationsCallout.title', + { + defaultMessage: 'Integrations', + } +); +const INTEGRATIONS_CALLOUT_DESCRIPTION = i18n.translate( + 'xpack.securitySolutionServerless.assets.integrationsCallout.content', + { + defaultMessage: 'Choose an integration to start collecting and analyzing your data.', + } +); +const INTEGRATIONS_CALLOUT_BUTTON_TEXT = i18n.translate( + 'xpack.securitySolutionServerless.assets.integrationsCallout.buttonText', + { + defaultMessage: 'Browse integrations', + } +); + +export const AssetsRoute: React.FC = () => { + const { euiTheme } = useEuiTheme(); + const link = useNavLink(SecurityPageName.assets); + const { links = [], title } = link ?? {}; + + return ( + + + + + + + + + +

{INTEGRATIONS_CALLOUT_DESCRIPTION}

+ + {INTEGRATIONS_CALLOUT_BUTTON_TEXT} + +
+
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default AssetsRoute; diff --git a/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx b/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx new file mode 100644 index 0000000000000..0aeedb8133a58 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/pages/investigations.tsx @@ -0,0 +1,32 @@ +/* + * 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 { LandingLinksIcons } from '@kbn/security-solution-navigation/landing_links'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { useNavLink } from '../common/hooks/use_nav_links'; + +export const InvestigationsRoute: React.FC = () => { + const link = useNavLink(SecurityPageName.investigations); + const { links = [], title } = link ?? {}; + + return ( + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default InvestigationsRoute; diff --git a/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx b/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx index 72c8b1c588ebb..84d5841d14a07 100644 --- a/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx +++ b/x-pack/plugins/security_solution_serverless/public/pages/machine_learning.tsx @@ -14,11 +14,13 @@ import { useNavLink } from '../common/hooks/use_nav_links'; export const MachineLearningRoute: React.FC = () => { const link = useNavLink(SecurityPageName.mlLanding); - const { links = [], categories = [], title, landingIcon } = link ?? {}; + const { links = [], categories = [], title } = link ?? {}; + return ( - + + diff --git a/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx b/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx new file mode 100644 index 0000000000000..dc9e81b819411 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/pages/project_settings.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiHorizontalRule, EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { + LandingLinksIcons, + LandingLinksIconsCategoriesGroups, +} from '@kbn/security-solution-navigation/landing_links'; +import type { AccordionLinkCategory } from '@kbn/security-solution-navigation'; +import { + isAccordionLinkCategory, + isSeparatorLinkCategory, + SecurityPageName, +} from '@kbn/security-solution-navigation'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { useNavLink } from '../common/hooks/use_nav_links'; + +export const ProjectSettingsRoute: React.FC = () => { + const projectSettingsLink = useNavLink(SecurityPageName.projectSettings); + const { links = [], categories = [], title } = projectSettingsLink ?? {}; + + const iconLinkIds = + categories.find((category) => isSeparatorLinkCategory(category))?.linkIds ?? []; + const iconLinks = links.filter(({ id }) => iconLinkIds.includes(id)); + + const accordionCategories = (categories.filter((category) => isAccordionLinkCategory(category)) ?? + []) as AccordionLinkCategory[]; + + return ( + + + + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default ProjectSettingsRoute; diff --git a/x-pack/plugins/security_solution_serverless/public/pages/routes.tsx b/x-pack/plugins/security_solution_serverless/public/pages/routes.tsx index e3a79fd3c9221..91df4306de69c 100644 --- a/x-pack/plugins/security_solution_serverless/public/pages/routes.tsx +++ b/x-pack/plugins/security_solution_serverless/public/pages/routes.tsx @@ -20,16 +20,37 @@ const withSuspense = (Component: React.ComponentType): ); }; +const InvestigationsPageLazy = lazy(() => import('./investigations')); +const InvestigationsPage = withSuspense(InvestigationsPageLazy); + +const AssetsPageLazy = lazy(() => import('./assets')); +const AssetsPage = withSuspense(AssetsPageLazy); + const MachineLearningPageLazy = lazy(() => import('./machine_learning')); const MachineLearningPage = withSuspense(MachineLearningPageLazy); +const ProjectSettingsPageLazy = lazy(() => import('./project_settings')); +const ProjectSettingsPage = withSuspense(ProjectSettingsPageLazy); + // Sets the project specific routes for Serverless as extra routes in the Security Solution plugin export const setRoutes = (services: Services) => { const projectRoutes: RouteProps[] = [ + { + path: SecurityPagePath[SecurityPageName.investigations], + component: withServicesProvider(InvestigationsPage, services), + }, + { + path: SecurityPagePath[SecurityPageName.assets], + component: withServicesProvider(AssetsPage, services), + }, { path: SecurityPagePath[SecurityPageName.mlLanding], component: withServicesProvider(MachineLearningPage, services), }, + { + path: SecurityPagePath[SecurityPageName.projectSettings], + component: withServicesProvider(ProjectSettingsPage, services), + }, ]; services.securitySolution.setExtraRoutes(projectRoutes); }; diff --git a/x-pack/plugins/security_solution_serverless/public/plugin.ts b/x-pack/plugins/security_solution_serverless/public/plugin.ts index 6104f73a566e4..f16e7a97c617c 100644 --- a/x-pack/plugins/security_solution_serverless/public/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/public/plugin.ts @@ -19,6 +19,7 @@ import { registerUpsellings } from './upselling'; import { createServices } from './common/services/create_services'; import { configureNavigation } from './navigation'; import { setRoutes } from './pages/routes'; +import { projectAppLinksSwitcher } from './navigation/links/app_links'; export class SecuritySolutionServerlessPlugin implements @@ -40,6 +41,8 @@ export class SecuritySolutionServerlessPlugin setupDeps: SecuritySolutionServerlessPluginSetupDeps ): SecuritySolutionServerlessPluginSetup { registerUpsellings(setupDeps.securitySolution.upselling, this.config.productTypes); + setupDeps.securitySolution.setAppLinksSwitcher(projectAppLinksSwitcher); + return {}; } diff --git a/x-pack/plugins/security_solution_serverless/public/types.ts b/x-pack/plugins/security_solution_serverless/public/types.ts index 2adfed815460a..51d335d5fd3cf 100644 --- a/x-pack/plugins/security_solution_serverless/public/types.ts +++ b/x-pack/plugins/security_solution_serverless/public/types.ts @@ -12,6 +12,7 @@ import type { } from '@kbn/security-solution-plugin/public'; import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; +import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { SecurityProductTypes, DeveloperConfig } from '../common/config'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -32,6 +33,7 @@ export interface SecuritySolutionServerlessPluginStartDeps { securitySolution: SecuritySolutionPluginStart; serverless: ServerlessPluginStart; management: ManagementStart; + cloud: CloudStart; } export interface ServerlessSecurityPublicConfig { diff --git a/x-pack/plugins/security_solution_serverless/server/plugin.ts b/x-pack/plugins/security_solution_serverless/server/plugin.ts index cef36b0dab9db..f83afd4593e4f 100644 --- a/x-pack/plugins/security_solution_serverless/server/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/server/plugin.ts @@ -49,28 +49,21 @@ export class SecuritySolutionServerlessPlugin this.logger = this.initializerContext.logger.get(); } - public setup(_coreSetup: CoreSetup, pluginsSetup: SecuritySolutionServerlessPluginSetupDeps) { + public setup(coreSetup: CoreSetup, pluginsSetup: SecuritySolutionServerlessPluginSetupDeps) { // securitySolutionEss plugin should always be disabled when securitySolutionServerless is enabled. // This check is an additional layer of security to prevent double registrations when // `plugins.forceEnableAllPlugins` flag is enabled). - const shouldRegister = pluginsSetup.securitySolutionEss == null; - - this.logger.info( - `Security Solution running with product tiers:\n${JSON.stringify( - this.config.productTypes, - null, - 2 - )}` - ); - if (shouldRegister) { + const productTypesStr = JSON.stringify(this.config.productTypes, null, 2); + this.logger.info(`Security Solution running with product types:\n${productTypesStr}`); pluginsSetup.securitySolution.setAppFeatures(getProductAppFeatures(this.config.productTypes)); } + pluginsSetup.ml.setFeaturesEnabled({ ad: true, dfa: true, nlp: false }); this.cspmUsageReportingTask = new SecurityUsageReportingTask({ - core: _coreSetup, + core: coreSetup, logFactory: this.initializerContext.logger, config: this.config, taskManager: pluginsSetup.taskManager, @@ -82,7 +75,7 @@ export class SecuritySolutionServerlessPlugin }); this.endpointUsageReportingTask = new SecurityUsageReportingTask({ - core: _coreSetup, + core: coreSetup, logFactory: this.initializerContext.logger, config: this.config, taskType: ENDPOINT_METERING_TASK.TYPE, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index be49724e18c72..77938bcdf3a44 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -33747,7 +33747,6 @@ "xpack.securitySolution.navigation.findings": "Résultats", "xpack.securitySolution.navigation.gettingStarted": "Démarrer", "xpack.securitySolution.navigation.hosts": "Hôtes", - "xpack.securitySolution.navigation.investigate": "Examiner", "xpack.securitySolution.navigation.kubernetes": "Kubernetes", "xpack.securitySolution.navigation.mainLabel": "Sécurité", "xpack.securitySolution.navigation.network": "Réseau", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 69d028fc9b788..76ac65690960b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -33746,7 +33746,6 @@ "xpack.securitySolution.navigation.findings": "調査結果", "xpack.securitySolution.navigation.gettingStarted": "使ってみる", "xpack.securitySolution.navigation.hosts": "ホスト", - "xpack.securitySolution.navigation.investigate": "調査", "xpack.securitySolution.navigation.kubernetes": "Kubernetes", "xpack.securitySolution.navigation.mainLabel": "セキュリティ", "xpack.securitySolution.navigation.network": "ネットワーク", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 293e0f0fcd4b6..04eb6d5b913c2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -33742,7 +33742,6 @@ "xpack.securitySolution.navigation.findings": "结果", "xpack.securitySolution.navigation.gettingStarted": "开始使用", "xpack.securitySolution.navigation.hosts": "主机", - "xpack.securitySolution.navigation.investigate": "调查", "xpack.securitySolution.navigation.kubernetes": "Kubernetes", "xpack.securitySolution.navigation.mainLabel": "安全", "xpack.securitySolution.navigation.network": "网络", diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/management.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/management.ts index 863ba724cf4ac..98565ffed71fc 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/management.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/management.ts @@ -19,7 +19,7 @@ export default function ({ getPageObject }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await PageObject.waitUntilUrlIncludes('/security/manage'); + await PageObject.waitUntilUrlIncludes('/security/project_settings'); }); }); }