[Security Solution] SideNav changes according to new Unified IA (ESS) (#159185)

This commit is contained in:
Sergi Massaneda 2023-06-27 17:53:06 +02:00 committed by GitHub
parent 6bbb49706c
commit aad68003a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 1385 additions and 961 deletions

View file

@ -120,7 +120,7 @@ pageLoadAssetSize:
serverless: 16573
serverlessObservability: 68747
serverlessSearch: 71995
serverlessSecurity: 70441
serverlessSecurity: 40000
sessionView: 77750
share: 71239
snapshotRestore: 79032

View file

@ -6,4 +6,13 @@
*/
export { SolutionSideNav, type SolutionSideNavProps } from './src';
export type { SolutionSideNavItem, LinkCategory, LinkCategories, Tracker } from './src/types';
export { LinkCategoryType, SolutionSideNavItemPosition } from './src/types';
export type {
SolutionSideNavItem,
LinkCategory,
TitleLinkCategory,
AccordionLinkCategory,
SeparatorLinkCategory,
LinkCategories,
Tracker,
} from './src/types';

View file

@ -12,6 +12,8 @@ import {
SolutionSideNav as SolutionSideNavComponent,
type SolutionSideNavProps,
type SolutionSideNavItem,
SolutionSideNavItemPosition,
LinkCategoryType,
} from '..';
const items: SolutionSideNavItem[] = [
@ -34,7 +36,8 @@ const items: SolutionSideNavItem[] = [
},
{
id: 'panelLink2',
label: 'I am the second nested',
label: 'I have an icon',
iconType: 'logoVulnerabilityManagement',
href: '#',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
@ -92,26 +95,41 @@ const items: SolutionSideNavItem[] = [
},
],
},
{ id: 'linkTruncated', href: '#', label: 'I have truncated text because I am too long' },
{ id: 'linkSmall', href: '#', label: 'I am smaller', labelSize: 'xs' },
];
const footerItems: SolutionSideNavItem[] = [
{ id: 'footerLink', href: '#', label: 'I am a footer link' },
{ id: 'linkWrapped', href: '#', label: 'I have wrapped text because I am too long' },
{
id: 'footerLinkPanel',
id: 'bottomLink',
href: '#',
label: 'I am a bottom link',
position: SolutionSideNavItemPosition.bottom,
},
{
id: 'bottomLinkPanel',
href: '#',
label: 'I also have panel',
position: SolutionSideNavItemPosition.bottom,
items: [
{
id: 'footerLinkPanel1',
label: 'I am a footer nested link',
id: 'bottomLinkPanel1',
label: 'I am a bottom nested link',
href: '#',
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
],
},
{ id: 'footerLinkSeparator', href: '#', label: 'I have a separator', appendSeparator: true },
{ id: 'footerLinkIcon', href: '#', label: 'I have an icon', iconType: 'heart' },
{
id: 'bottomLinkSeparator',
href: '#',
label: 'I have a separator',
appendSeparator: true,
position: SolutionSideNavItemPosition.bottom,
},
{
id: 'bottomLinkIcon',
href: '#',
label: 'I have an icon',
iconType: 'heart',
position: SolutionSideNavItemPosition.bottom,
},
];
export default {
@ -136,9 +154,7 @@ export default {
],
};
type Params = Pick<SolutionSideNavProps, 'selectedId' | 'panelTopOffset' | 'panelBottomOffset'>;
export const SolutionSideNav = (params: Params) => (
export const SolutionSideNav = (params: SolutionSideNavProps) => (
<>
<SolutionNav
name={'Security'}
@ -148,9 +164,9 @@ export const SolutionSideNav = (params: Params) => (
// eslint-disable-next-line react/no-children-prop
children={
<SolutionSideNavComponent
items={items}
footerItems={footerItems}
items={params.items}
selectedId={params.selectedId}
categories={params.categories}
panelBottomOffset={params.panelBottomOffset || undefined}
panelTopOffset={params.panelTopOffset || undefined}
/>
@ -168,9 +184,30 @@ export const SolutionSideNav = (params: Params) => (
SolutionSideNav.argTypes = {
selectedId: {
control: { type: 'radio' },
options: [...items, ...footerItems].map(({ id }) => id),
options: items.map(({ id }) => id),
defaultValue: 'simpleLink',
},
items: {
control: 'object',
defaultValue: items,
},
categories: {
control: 'object',
defaultValue: [
{
type: LinkCategoryType.separator,
linkIds: ['simpleLink', 'panelLink', 'categoriesPanelLink'],
},
{
type: LinkCategoryType.separator,
linkIds: ['linkWrapped'],
},
{
type: LinkCategoryType.separator,
linkIds: ['bottomLink', 'bottomLinkPanel', 'bottomLinkSeparator', 'bottomLinkIcon'],
},
],
},
panelTopOffset: {
control: 'text',
defaultValue: '0px',

View file

@ -5,24 +5,22 @@
* 2.0.
*/
import { transparentize, type EuiThemeComputed } from '@elastic/eui';
import { tint, type EuiThemeComputed } from '@elastic/eui';
import { css } from '@emotion/css';
export const SolutionSideNavItemStyles = (euiTheme: EuiThemeComputed<{}>) => css`
font-weight: ${euiTheme.font.weight.regular};
&.solutionSideNavItem--isPrimary * {
font-weight: ${euiTheme.font.weight.bold};
* {
// EuiListGroupItem changes the links font-weight, we need to override it
font-weight: ${euiTheme.font.weight.regular};
}
&:focus,
&:focus-within,
&:hover,
&.solutionSideNavItem--isActive {
background-color: ${transparentize(euiTheme.colors.primary, 0.1)};
&.solutionSideNavItem--isSelected {
background-color: ${tint(euiTheme.colors.lightShade, 0.5)};
& * {
font-weight: ${euiTheme.font.weight.medium};
}
}
.solutionSideNavItemButton:focus,
.solutionSideNavItemButton:focus-within,
.solutionSideNavItemButton:hover {
transform: none; /* prevent translationY transform that causes misalignment within the list item */
background-color: ${transparentize(euiTheme.colors.primary, 0.2)};
// Needed to place the icon on the right side
span.euiListGroupItem__label {
width: 100%;
}
`;

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { render } from '@testing-library/react';
import { SolutionSideNav, type SolutionSideNavProps } from './solution_side_nav';
import type { SolutionSideNavItem } from './types';
import { METRIC_TYPE } from '@kbn/analytics';
@ -134,7 +134,8 @@ describe('SolutionSideNav', () => {
result.getByTestId(`solutionSideNavItemButton-${'dashboardsLanding'}`).click();
waitFor(() => {
// add check at the end of the event loop to ensure the panel is removed
setTimeout(() => {
expect(result.queryByTestId('solutionSideNavPanel')).not.toBeInTheDocument();
});
});

View file

@ -5,64 +5,59 @@
* 2.0.
*/
/* eslint-disable @elastic/eui/href-or-on-click */
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
EuiListGroup,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
useIsWithinBreakpoints,
useEuiTheme,
EuiListGroupItem,
EuiHorizontalRule,
EuiSpacer,
EuiButtonIcon,
EuiIcon,
} from '@elastic/eui';
import partition from 'lodash/fp/partition';
import classNames from 'classnames';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import { SolutionSideNavPanel } from './solution_side_nav_panel';
import type { LinkCategories, SolutionSideNavItem, Tracker } from './types';
import { LinkCategories, SeparatorLinkCategory, SolutionSideNavItemPosition } from './types';
import type { SolutionSideNavItem, Tracker } from './types';
import { TELEMETRY_EVENT } from './telemetry/const';
import { TelemetryContextProvider, useTelemetryContext } from './telemetry/telemetry_context';
import { SolutionSideNavItemStyles } from './solution_side_nav.styles';
export const TOGGLE_PANEL_LABEL = i18n.translate('securitySolutionPackages.sideNav.togglePanel', {
defaultMessage: 'Toggle panel nav',
});
export interface SolutionSideNavProps {
/** All the items to display in the side navigation */
items: SolutionSideNavItem[];
/** The id of the selected item to highlight. It only affects the top level items rendered in the main panel */
selectedId: string;
footerItems?: SolutionSideNavItem[];
/** The categories to group and separate the main items. Ignores `position: 'bottom'` items */
categories?: SeparatorLinkCategory[];
/** Css value for the bottom offset of the secondary panel. defaults to 0 */
panelBottomOffset?: string;
/** Css value for the top offset of the secondary panel. defaults to the generic kibana header height */
panelTopOffset?: string;
// This enables Telemetry tracking inside side navigation, this has to be bound with the plugin appId
// e.g.: usageCollection?.reportUiCounter?.bind(null, appId)
/**
* The tracker function to enable navigation Telemetry, this has to be bound with the plugin `appId`
* e.g.: usageCollection?.reportUiCounter?.bind(null, appId)
* */
tracker?: Tracker;
}
export interface SolutionSideNavItemsProps {
items: SolutionSideNavItem[];
selectedId: string;
activePanelNavId: ActivePanelNav;
isMobileSize: boolean;
navItemsById: NavItemsById;
onOpenPanelNav: (id: string) => void;
}
export interface SolutionSideNavItemProps {
item: SolutionSideNavItem;
isSelected: boolean;
isActive: boolean;
hasPanelNav: boolean;
onOpenPanelNav: (id: string) => void;
}
type ActivePanelNav = string | null;
type NavItemsById = Record<
string,
{ title: string; panelItems: SolutionSideNavItem[]; categories?: LinkCategories }
>;
/**
* The Solution side navigation main component
*/
export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(function SolutionSideNav({
items,
categories,
selectedId,
footerItems = [],
panelBottomOffset,
panelTopOffset,
tracker,
@ -95,46 +90,16 @@ export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(functi
});
}, [onClosePanelNav]);
const navItemsById = useMemo<NavItemsById>(
const [topItems, bottomItems] = useMemo(
() =>
[...items, ...footerItems].reduce<NavItemsById>((acc, navItem) => {
if (navItem.items?.length) {
acc[navItem.id] = {
title: navItem.label,
panelItems: navItem.items,
categories: navItem.categories,
};
}
return acc;
}, {}),
[items, footerItems]
partition(
({ position = SolutionSideNavItemPosition.top }) =>
position === SolutionSideNavItemPosition.top,
items
),
[items]
);
const panelNav = useMemo(() => {
if (activePanelNavId == null || !navItemsById[activePanelNavId]) {
return null;
}
const { panelItems, title, categories } = navItemsById[activePanelNavId];
return (
<SolutionSideNavPanel
onClose={onClosePanelNav}
onOutsideClick={onOutsidePanelClick}
items={panelItems}
title={title}
categories={categories}
bottomOffset={panelBottomOffset}
topOffset={panelTopOffset}
/>
);
}, [
activePanelNavId,
navItemsById,
onClosePanelNav,
onOutsidePanelClick,
panelBottomOffset,
panelTopOffset,
]);
return (
<TelemetryContextProvider tracker={tracker}>
<EuiFlexGroup gutterSize="none" direction="column">
@ -143,11 +108,11 @@ export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(functi
<EuiFlexItem>
<EuiListGroup gutterSize="none">
<SolutionSideNavItems
items={items}
items={topItems}
categories={categories}
selectedId={selectedId}
activePanelNavId={activePanelNavId}
isMobileSize={isMobileSize}
navItemsById={navItemsById}
onOpenPanelNav={openPanelNav}
/>
</EuiListGroup>
@ -155,11 +120,10 @@ export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(functi
<EuiFlexItem grow={false}>
<EuiListGroup gutterSize="none">
<SolutionSideNavItems
items={footerItems}
items={bottomItems}
selectedId={selectedId}
activePanelNavId={activePanelNavId}
isMobileSize={isMobileSize}
navItemsById={navItemsById}
onOpenPanelNav={openPanelNav}
/>
</EuiListGroup>
@ -168,102 +132,233 @@ export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(functi
</EuiFlexItem>
</EuiFlexGroup>
{panelNav}
<SolutionSideNavPanels
items={items}
activePanelNavId={activePanelNavId}
onClose={onClosePanelNav}
onOutsideClick={onOutsidePanelClick}
bottomOffset={panelBottomOffset}
topOffset={panelTopOffset}
/>
</TelemetryContextProvider>
);
});
const SolutionSideNavItems: React.FC<SolutionSideNavItemsProps> = ({
items,
selectedId,
activePanelNavId,
isMobileSize,
navItemsById,
onOpenPanelNav,
}) => (
<>
{items.map((item) => (
<SolutionSideNavItem
key={item.id}
item={item}
isSelected={selectedId === item.id}
isActive={activePanelNavId === item.id}
hasPanelNav={!isMobileSize && item.id in navItemsById}
onOpenPanelNav={onOpenPanelNav}
/>
))}
</>
interface SolutionSideNavItemsProps {
items: SolutionSideNavItem[];
selectedId: string;
activePanelNavId: ActivePanelNav;
isMobileSize: boolean;
onOpenPanelNav: (id: string) => void;
categories?: LinkCategories;
}
/**
* The Solution side navigation items component.
* Renders either the top or bottom panel items, considering the categories if present.
* When `categories` is received all links that do not belong to any category are ignored.
*/
const SolutionSideNavItems: React.FC<SolutionSideNavItemsProps> = React.memo(
function SolutionSideNavItems({
items,
categories,
selectedId,
activePanelNavId,
isMobileSize,
onOpenPanelNav,
}) {
if (!categories?.length) {
return (
<>
{items.map((item) => (
<SolutionSideNavItem
key={item.id}
item={item}
isSelected={selectedId === item.id}
isActive={activePanelNavId === item.id}
isMobileSize={isMobileSize}
onOpenPanelNav={onOpenPanelNav}
/>
))}
</>
);
}
return (
<>
{categories?.map((category, categoryIndex) => {
const categoryItems = category.linkIds.reduce<SolutionSideNavItem[]>((acc, linkId) => {
const link = items.find((item) => item.id === linkId);
if (link) {
acc.push(link);
}
return acc;
}, []);
if (!categoryItems.length) {
return null;
}
return (
<>
{categoryIndex !== 0 && <EuiSpacer size="s" />}
{categoryItems.map((item) => (
<SolutionSideNavItem
key={item.id}
item={item}
isSelected={selectedId === item.id}
isActive={activePanelNavId === item.id}
isMobileSize={isMobileSize}
onOpenPanelNav={onOpenPanelNav}
/>
))}
<EuiSpacer size="s" />
</>
);
})}
</>
);
}
);
interface SolutionSideNavItemProps {
item: SolutionSideNavItem;
isSelected: boolean;
isActive: boolean;
onOpenPanelNav: (id: string) => void;
isMobileSize: boolean;
}
/**
* The Solution side navigation item component.
* Renders a single item for the main side navigation panel,
* and it adds a button to open the item secondary panel if needed.
*/
const SolutionSideNavItem: React.FC<SolutionSideNavItemProps> = React.memo(
function SolutionSideNavItem({ item, isSelected, isActive, hasPanelNav, onOpenPanelNav }) {
function SolutionSideNavItem({ item, isSelected, isActive, isMobileSize, onOpenPanelNav }) {
const { euiTheme } = useEuiTheme();
const { tracker } = useTelemetryContext();
const { id, href, label, onClick, labelSize, iconType, appendSeparator } = item;
const onLinkClicked: React.MouseEventHandler = (ev) => {
tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.NAVIGATION}${id}`);
onClick?.(ev);
};
const { id, href, label, items, onClick, iconType, appendSeparator } = item;
const solutionSideNavItemStyles = SolutionSideNavItemStyles(euiTheme);
const itemClassNames = classNames(
'solutionSideNavItem',
{
'solutionSideNavItem--isActive': isActive,
'solutionSideNavItem--isPrimary': isSelected,
},
{ 'solutionSideNavItem--isSelected': isSelected },
solutionSideNavItemStyles
);
const buttonClassNames = classNames('solutionSideNavItemButton');
const onButtonClick: React.MouseEventHandler = (ev) => {
ev.preventDefault();
ev.stopPropagation();
const hasPanelNav = useMemo(
() => !isMobileSize && items != null && items.length > 0,
[items, isMobileSize]
);
const onLinkClicked: React.MouseEventHandler = useCallback(
(ev) => {
tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.NAVIGATION}${id}`);
onClick?.(ev);
},
[id, onClick, tracker]
);
const onButtonClick: React.MouseEventHandler = useCallback(() => {
tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.PANEL_NAVIGATION_TOGGLE}${id}`);
onOpenPanelNav(id);
};
}, [id, onOpenPanelNav, tracker]);
const itemLabel = useMemo(() => {
if (iconType == null) {
return label;
}
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem>{label}</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiIcon type={iconType} color="text" />
</EuiFlexItem>
</EuiFlexGroup>
);
}, [label, iconType]);
return (
<>
<EuiLink
key={id}
href={href}
onClick={onLinkClicked}
color={isSelected ? 'primary' : 'text'}
data-test-subj={`solutionSideNavItemLink-${id}`}
>
<EuiListGroupItem
className={itemClassNames}
color={isSelected ? 'primary' : 'text'}
label={label}
size={labelSize ?? 's'}
{...(iconType && {
iconType,
iconProps: {
color: isSelected ? 'primary' : 'text',
},
})}
{...(hasPanelNav && {
extraAction: {
className: buttonClassNames,
color: isActive ? 'primary' : 'text',
onClick: onButtonClick,
iconType: 'spaces',
iconSize: 'm',
'aria-label': 'Toggle panel nav',
'data-test-subj': `solutionSideNavItemButton-${id}`,
alwaysShow: true,
},
})}
/>
</EuiLink>
{appendSeparator && <EuiHorizontalRule margin="xs" />}
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem>
<EuiListGroupItem
label={itemLabel}
href={href}
wrapText
onClick={onLinkClicked}
className={itemClassNames}
color="text"
size="s"
data-test-subj={`solutionSideNavItemLink-${id}`}
/>
</EuiFlexItem>
{hasPanelNav && (
<EuiFlexItem grow={0}>
<EuiButtonIcon
className={buttonClassNames}
display={isActive ? 'base' : 'empty'}
size="s"
color="text"
onClick={onButtonClick}
iconType="spaces"
iconSize="m"
aria-label={TOGGLE_PANEL_LABEL}
data-test-subj={`solutionSideNavItemButton-${id}`}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
{appendSeparator ? <EuiHorizontalRule margin="xs" /> : <EuiSpacer size="xs" />}
</>
);
}
);
interface SolutionSideNavPanelsProps {
items: SolutionSideNavItem[];
activePanelNavId: ActivePanelNav;
onClose: () => void;
onOutsideClick: () => void;
bottomOffset?: string;
topOffset?: string;
}
/**
* The Solution side navigation panels component.
* Renders the secondary panel according to the `activePanelNavId` received.
*/
const SolutionSideNavPanels: React.FC<SolutionSideNavPanelsProps> = React.memo(
function SolutionSideNavPanels({
items,
activePanelNavId,
onClose,
onOutsideClick,
bottomOffset,
topOffset,
}) {
const activePanelNavItem = useMemo<SolutionSideNavItem | undefined>(
() => items.find(({ id }) => id === activePanelNavId),
[items, activePanelNavId]
);
if (activePanelNavItem == null || !activePanelNavItem.items?.length) {
return null;
}
return (
<SolutionSideNavPanel
onClose={onClose}
onOutsideClick={onOutsideClick}
items={activePanelNavItem.items}
title={activePanelNavItem.label}
categories={activePanelNavItem.categories}
bottomOffset={bottomOffset}
topOffset={topOffset}
/>
);
}
);
// eslint-disable-next-line import/no-default-export
export default SolutionSideNav;

View file

@ -5,37 +5,31 @@
* 2.0.
*/
import { transparentize, type EuiThemeComputed } from '@elastic/eui';
import { css, injectGlobal } from '@emotion/css';
import { transparentize, type EuiThemeComputed, euiFontSize, type UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
const EUI_HEADER_HEIGHT = '93px';
const PANEL_LEFT_OFFSET = '248px';
const PANEL_WIDTH = '340px';
const EUI_HEADER_HEIGHT = '96px';
const PANEL_LEFT_OFFSET = '249px';
const PANEL_WIDTH = '270px';
export const panelClass = 'solutionSideNavPanel';
export const SolutionSideNavPanelStyles = (
euiTheme: EuiThemeComputed<{}>,
{ $bottomOffset, $topOffset }: { $bottomOffset?: string; $topOffset?: string } = {}
) => {
// We need to add the banner height to the top space when the header banner is present
injectGlobal(`
body.kbnBody--hasHeaderBanner .${panelClass} {
top: calc(${EUI_HEADER_HEIGHT} + ${euiTheme.size.xl});
}
`);
) => css`
position: fixed;
top: ${$topOffset ?? EUI_HEADER_HEIGHT};
left: ${PANEL_LEFT_OFFSET};
bottom: 0;
width: ${PANEL_WIDTH};
height: inherit;
z-index: 999;
background-color: ${euiTheme.colors.body};
return css`
position: fixed;
top: ${$topOffset ?? EUI_HEADER_HEIGHT};
left: ${PANEL_LEFT_OFFSET};
bottom: 0;
width: ${PANEL_WIDTH};
height: inherit;
// If the bottom bar is visible add padding to the navigation
${$bottomOffset != null &&
`
// If the bottom bar is visible add padding to the navigation
${$bottomOffset != null &&
`
height: inherit;
bottom: ${$bottomOffset};
box-shadow:
@ -47,27 +41,40 @@ export const SolutionSideNavPanelStyles = (
inset 0 -6px ${euiTheme.size.xs} -${euiTheme.size.xs} rgb(0 0 0 / 15%);
`}
.solutionSideNavPanelLink {
.solutionSideNavPanelLinkItem {
background-color: transparent; /* originally white, it prevents panel to remove the bottom inset box shadow */
&:hover {
background-color: ${transparentize(euiTheme.colors.primary, 0.1)};
}
dt {
color: ${euiTheme.colors.primaryText};
}
dd {
color: ${euiTheme.colors.darkestShade};
}
.solutionSideNavPanelLink {
&:focus-within {
background-color: transparent;
a {
text-decoration: auto;
}
}
`;
};
export const SolutionSideNavTitleStyles = (
euiTheme: EuiThemeComputed<{}>,
{ $paddingTop = false }: { $paddingTop?: boolean } = {}
) => css`
padding-left: ${euiTheme.size.s};
${$paddingTop && `padding-top: ${euiTheme.size.s};`}
&:hover {
background-color: ${transparentize(euiTheme.colors.primary, 0.1)};
a {
text-decoration: underline;
}
}
}
`;
export const SolutionSideNavTitleStyles = (euiTheme: EuiThemeComputed<{}>) => css`
padding-top: ${euiTheme.size.s};
`;
export const SolutionSideNavCategoryTitleStyles = (euiTheme: EuiThemeComputed<{}>) => css`
text-transform: uppercase;
color: ${euiTheme.colors.darkShade};
padding-left: ${euiTheme.size.s};
padding-bottom: ${euiTheme.size.s};
${euiFontSize({ euiTheme } as UseEuiTheme<{}>, 'xxs')}
font-weight: ${euiTheme.font.weight.medium};
`;
export const SolutionSideNavPanelLinksGroupStyles = (euiTheme: EuiThemeComputed<{}>) => css`
padding-left: 0;
padding-right: 0;
`;
export const SolutionSideNavCategoryAccordionStyles = (euiTheme: EuiThemeComputed<{}>) => css`
margin-bottom: ${euiTheme.size.s};
`;

View file

@ -12,7 +12,7 @@ import { BETA_LABEL } from './beta_badge';
import { TELEMETRY_EVENT } from './telemetry/const';
import { METRIC_TYPE } from '@kbn/analytics';
import { TelemetryContextProvider } from './telemetry/telemetry_context';
import type { SolutionSideNavItem, LinkCategories } from './types';
import { SolutionSideNavItem, LinkCategories, LinkCategoryType } from './types';
const mockUseIsWithinMinBreakpoint = jest.fn(() => true);
jest.mock('@elastic/eui', () => {
@ -52,12 +52,16 @@ const betaMockItemsCount = mockItems.filter((item) => item.isBeta).length;
const mockCategories: LinkCategories = [
{
label: 'HOSTS CATEGORY',
linkIds: ['hosts'],
linkIds: ['hosts', 'network'],
},
{
label: 'Empty category',
linkIds: [],
},
{
type: LinkCategoryType.separator,
linkIds: ['kubernetes'],
},
];
const bottomNavOffset = '10px';
@ -92,9 +96,6 @@ describe('SolutionSideNavPanel', () => {
mockItems.forEach((item) => {
expect(result.getByText(item.label)).toBeInTheDocument();
if (item.description) {
expect(result.getByText(item.description)).toBeInTheDocument();
}
});
expect(result.queryAllByText(BETA_LABEL).length).toBe(betaMockItemsCount);
});
@ -103,6 +104,7 @@ describe('SolutionSideNavPanel', () => {
const result = renderNavPanel({ categories: mockCategories });
mockCategories.forEach((mockCategory) => {
if (!mockCategory.label) return; // omit separator categories
if (mockCategory.linkIds.length) {
expect(result.getByText(mockCategory.label)).toBeInTheDocument();
} else {
@ -111,6 +113,16 @@ describe('SolutionSideNavPanel', () => {
});
});
it('should render separator categories with items', () => {
const result = renderNavPanel({ categories: mockCategories });
mockCategories.forEach((mockCategory) => {
if (mockCategory.type !== LinkCategoryType.separator) return; // omit non-separator categories
mockCategory.linkIds.forEach((linkId) => {
expect(result.queryByTestId(`solutionSideNavPanelLink-${linkId}`)).toBeInTheDocument();
});
});
});
describe('links', () => {
it('should contain correct href in links', () => {
const result = renderNavPanel();

View file

@ -5,15 +5,14 @@
* 2.0.
*/
import React, { Fragment, useCallback } from 'react';
import React, { useCallback } from 'react';
import {
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiFocusTrap,
EuiHorizontalRule,
EuiListGroup,
EuiListGroupItem,
EuiOutsideClickDetector,
EuiPanel,
EuiPortal,
@ -26,14 +25,23 @@ import {
} from '@elastic/eui';
import classNames from 'classnames';
import { METRIC_TYPE } from '@kbn/analytics';
import type { SolutionSideNavItem, LinkCategories } from './types';
import {
type SolutionSideNavItem,
type LinkCategories,
isAccordionLinkCategory,
isTitleLinkCategory,
isSeparatorLinkCategory,
} from './types';
import { BetaBadge } from './beta_badge';
import { TELEMETRY_EVENT } from './telemetry/const';
import { useTelemetryContext } from './telemetry/telemetry_context';
import {
SolutionSideNavPanelStyles,
panelClass,
SolutionSideNavCategoryTitleStyles,
SolutionSideNavTitleStyles,
SolutionSideNavCategoryAccordionStyles,
SolutionSideNavPanelLinksGroupStyles,
} from './solution_side_nav_panel.styles';
export interface SolutionSideNavPanelProps {
@ -45,18 +53,8 @@ export interface SolutionSideNavPanelProps {
bottomOffset?: string;
topOffset?: string;
}
export interface SolutionSideNavPanelCategoriesProps {
categories: LinkCategories;
items: SolutionSideNavItem[];
onClose: () => void;
}
export interface SolutionSideNavPanelItemsProps {
items: SolutionSideNavItem[];
onClose: () => void;
}
/**
* Renders the side navigation panel for secondary links
* Renders the secondary navigation panel for the nested link items
*/
export const SolutionSideNavPanel: React.FC<SolutionSideNavPanelProps> = React.memo(
function SolutionSideNavPanel({
@ -80,9 +78,8 @@ export const SolutionSideNavPanel: React.FC<SolutionSideNavPanelProps> = React.m
$bottomOffset,
$topOffset,
});
const solutionSideNavTitleStyles = SolutionSideNavTitleStyles(euiTheme, { $paddingTop: true });
const panelClasses = classNames(panelClass, 'eui-yScroll', solutionSideNavPanelStyles);
const titleClasses = classNames(solutionSideNavTitleStyles);
const titleClasses = classNames(SolutionSideNavTitleStyles(euiTheme));
// ESC key closes PanelNav
const onKeyDown = useCallback(
@ -103,30 +100,26 @@ export const SolutionSideNavPanel: React.FC<SolutionSideNavPanelProps> = React.m
<EuiPanel
className={panelClasses}
hasShadow={hasShadow}
// $bottomOffset={bottomOffsetLargerBreakpoint}
borderRadius="none"
paddingSize="m"
data-test-subj="solutionSideNavPanel"
>
<EuiFlexGroup direction="column" gutterSize="l" alignItems="flexStart">
<EuiFlexGroup direction="column" gutterSize="m" alignItems="flexStart">
<EuiFlexItem>
<EuiTitle size="xs" className={titleClasses}>
<strong>{title}</strong>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList>
{categories ? (
<SolutionSideNavPanelCategories
categories={categories}
items={items}
onClose={onClose}
/>
) : (
<SolutionSideNavPanelItems items={items} onClose={onClose} />
)}
</EuiDescriptionList>
<EuiFlexItem style={{ width: '100%' }}>
{categories ? (
<SolutionSideNavPanelCategories
categories={categories}
items={items}
onClose={onClose}
/>
) : (
<SolutionSideNavPanelItems items={items} onClose={onClose} />
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
@ -138,16 +131,21 @@ export const SolutionSideNavPanel: React.FC<SolutionSideNavPanelProps> = React.m
}
);
interface SolutionSideNavPanelCategoriesProps {
categories: LinkCategories;
items: SolutionSideNavItem[];
onClose: () => void;
}
/**
* Renders all the categories for the secondary navigation panel.
* Links that do not belong to any category are ignored
*/
const SolutionSideNavPanelCategories: React.FC<SolutionSideNavPanelCategoriesProps> = React.memo(
function SolutionSideNavPanelCategories({ categories, items, onClose }) {
const { euiTheme } = useEuiTheme();
const sideNavTitleStyles = SolutionSideNavTitleStyles(euiTheme);
const titleClasses = classNames(sideNavTitleStyles);
return (
<>
{categories.map(({ label, linkIds }) => {
const links = linkIds.reduce<SolutionSideNavItem[]>((acc, linkId) => {
{categories.map((category, index) => {
const categoryItems = category.linkIds.reduce<SolutionSideNavItem[]>((acc, linkId) => {
const link = items.find((item) => item.id === linkId);
if (link) {
acc.push(link);
@ -155,55 +153,150 @@ const SolutionSideNavPanelCategories: React.FC<SolutionSideNavPanelCategoriesPro
return acc;
}, []);
if (!links.length) {
if (!categoryItems.length) {
return null;
}
return (
<Fragment key={label}>
<EuiTitle size="xxxs" className={titleClasses}>
<h2>{label}</h2>
</EuiTitle>
<EuiHorizontalRule margin="xs" />
<SolutionSideNavPanelItems items={links} onClose={onClose} />
<EuiSpacer size="l" />
</Fragment>
);
if (isTitleLinkCategory(category)) {
return (
<SolutionSideNavPanelTitleCategory
label={category.label}
items={categoryItems}
onClose={onClose}
key={category.label}
/>
);
}
if (isAccordionLinkCategory(category)) {
return (
<SolutionSideNavPanelAccordionCategory
label={category.label}
items={categoryItems}
onClose={onClose}
key={category.label}
/>
);
}
if (isSeparatorLinkCategory(category)) {
return (
<SolutionSideNavPanelSeparatorCategory
items={categoryItems}
onClose={onClose}
key={index}
/>
);
}
return null;
})}
</>
);
}
);
const SolutionSideNavPanelItems: React.FC<SolutionSideNavPanelItemsProps> = React.memo(
function SolutionSideNavPanelItems({ items, onClose }) {
const panelLinkClassNames = classNames('solutionSideNavPanelLink');
const panelLinkItemClassNames = classNames('solutionSideNavPanelLinkItem');
const { tracker } = useTelemetryContext();
interface SolutionSideNavPanelTitleCategoryProps {
label: string;
items: SolutionSideNavItem[];
onClose: () => void;
}
/**
* Renders a title category for the secondary navigation panel.
*/
const SolutionSideNavPanelTitleCategory: React.FC<SolutionSideNavPanelTitleCategoryProps> =
React.memo(function SolutionSideNavPanelTitleCategory({ label, onClose, items }) {
const { euiTheme } = useEuiTheme();
const titleClasses = classNames(SolutionSideNavCategoryTitleStyles(euiTheme));
return (
<>
{items.map(({ id, href, onClick, label, description, isBeta, betaOptions }) => (
<a
key={id}
className={panelLinkClassNames}
data-test-subj={`solutionSideNavPanelLink-${id}`}
href={href}
onClick={(ev) => {
tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.PANEL_NAVIGATION}${id}`);
onClose();
onClick?.(ev);
}}
>
<EuiPanel hasShadow={false} className={panelLinkItemClassNames} paddingSize="s">
<EuiDescriptionListTitle>
{label}
{isBeta && <BetaBadge text={betaOptions?.text} />}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{description}</EuiDescriptionListDescription>
</EuiPanel>
</a>
))}
<EuiSpacer size="m" />
<EuiTitle size="xxxs" className={titleClasses}>
<h2>{label}</h2>
</EuiTitle>
<SolutionSideNavPanelItems items={items} onClose={onClose} />
<EuiSpacer size="s" />
</>
);
});
interface SolutionSideNavPanelAccordionCategoryProps {
label: string;
items: SolutionSideNavItem[];
onClose: () => void;
}
/**
* Renders an accordion category for the secondary navigation panel.
*/
const SolutionSideNavPanelAccordionCategory: React.FC<SolutionSideNavPanelAccordionCategoryProps> =
React.memo(function SolutionSideNavPanelAccordionCategory({ label, onClose, items }) {
const { euiTheme } = useEuiTheme();
const accordionClasses = classNames(SolutionSideNavCategoryAccordionStyles(euiTheme));
return (
<EuiAccordion id={label} buttonContent={label} className={accordionClasses}>
<SolutionSideNavPanelItems items={items} onClose={onClose} />
</EuiAccordion>
);
});
interface SolutionSideNavPanelSeparatorCategoryProps {
items: SolutionSideNavItem[];
onClose: () => void;
}
/**
* Renders a separator category for the secondary navigation panel.
*/
const SolutionSideNavPanelSeparatorCategory: React.FC<SolutionSideNavPanelSeparatorCategoryProps> =
React.memo(function SolutionSideNavPanelSeparatorCategory({ onClose, items }) {
return (
<>
<EuiSpacer size="m" />
<SolutionSideNavPanelItems items={items} onClose={onClose} />
<EuiSpacer size="s" />
</>
);
});
interface SolutionSideNavPanelItemsProps {
items: SolutionSideNavItem[];
onClose: () => void;
}
/**
* Renders the items for the secondary navigation panel.
*/
const SolutionSideNavPanelItems: React.FC<SolutionSideNavPanelItemsProps> = React.memo(
function SolutionSideNavPanelItems({ items, onClose }) {
const { euiTheme } = useEuiTheme();
const panelLinksGroupClassNames = classNames(SolutionSideNavPanelLinksGroupStyles(euiTheme));
const panelLinkClassNames = classNames('solutionSideNavPanelLink');
const { tracker } = useTelemetryContext();
return (
<EuiListGroup className={panelLinksGroupClassNames}>
{items.map(({ id, href, onClick, label, iconType, isBeta, betaOptions }) => {
const itemLabel = !isBeta ? (
label
) : (
<>
{label} <BetaBadge text={betaOptions?.text} />
</>
);
return (
<EuiListGroupItem
key={id}
label={itemLabel}
wrapText
className={panelLinkClassNames}
size="s"
data-test-subj={`solutionSideNavPanelLink-${id}`}
href={href}
iconType={iconType}
onClick={(ev) => {
tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.PANEL_NAVIGATION}${id}`);
onClose();
onClick?.(ev);
}}
/>
);
})}
</EuiListGroup>
);
}
);

View file

@ -7,7 +7,12 @@
import type React from 'react';
import type { UiCounterMetricType } from '@kbn/analytics';
import type { EuiListGroupItemProps, IconType } from '@elastic/eui';
import type { IconType } from '@elastic/eui';
export enum SolutionSideNavItemPosition {
top = 'top',
bottom = 'bottom',
}
export interface SolutionSideNavItem<T extends string = string> {
id: T;
@ -18,19 +23,50 @@ export interface SolutionSideNavItem<T extends string = string> {
items?: Array<SolutionSideNavItem<T>>;
categories?: LinkCategories<T>;
iconType?: IconType;
labelSize?: EuiListGroupItemProps['size'];
appendSeparator?: boolean;
position?: SolutionSideNavItemPosition;
isBeta?: boolean;
betaOptions?: {
text: string;
};
}
export interface LinkCategory<T extends string = string> {
label: string;
linkIds: readonly T[];
export enum LinkCategoryType {
title = 'title',
collapsibleTitle = 'collapsibleTitle',
accordion = 'accordion',
separator = 'separator',
}
export interface LinkCategory<T extends string = string> {
linkIds: readonly T[];
label?: string;
type?: LinkCategoryType;
}
export interface TitleLinkCategory<T extends string = string> extends LinkCategory<T> {
type?: LinkCategoryType.title;
label: string;
}
export const isTitleLinkCategory = (category: LinkCategory): category is TitleLinkCategory =>
(category.type == null || category.type === LinkCategoryType.title) && category.label != null;
export interface AccordionLinkCategory<T extends string = string> extends LinkCategory<T> {
type: LinkCategoryType.accordion;
label: string;
}
export const isAccordionLinkCategory = (
category: LinkCategory
): category is AccordionLinkCategory =>
category.type === LinkCategoryType.accordion && category.label != null;
export interface SeparatorLinkCategory<T extends string = string> extends LinkCategory<T> {
type: LinkCategoryType.separator;
}
export const isSeparatorLinkCategory = (
category: LinkCategory
): category is SeparatorLinkCategory => category.type === LinkCategoryType.separator;
export type LinkCategories<T extends string = string> = Readonly<Array<LinkCategory<T>>>;
export type Tracker = (

View file

@ -130,6 +130,7 @@ export enum SecurityPageName {
rules = 'rules',
rulesAdd = 'rules-add',
rulesCreate = 'rules-create',
rulesLanding = 'rules-landing',
sessions = 'sessions',
/*
* Warning: Computed values are not permitted in an enum with string valued members
@ -161,6 +162,7 @@ export const DETECTIONS_PATH = '/detections' as const;
export const ALERTS_PATH = '/alerts' as const;
export const ALERT_DETAILS_REDIRECT_PATH = `${ALERTS_PATH}/redirect` as const;
export const RULES_PATH = '/rules' as const;
export const RULES_LANDING_PATH = `${RULES_PATH}/landing` as const;
export const RULES_ADD_PATH = `${RULES_PATH}/add_rules` as const;
export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const;
export const EXCEPTIONS_PATH = '/exceptions' as const;

View file

@ -28,7 +28,7 @@ import {
CSP_FINDINGS,
POLICIES,
EXPLORE,
MANAGE,
SETTINGS,
ENTITY_ANALYTICS,
} from '../../screens/security_header';
@ -169,7 +169,7 @@ describe('top-level navigation common to all pages in the Security app', () => {
});
it('navigates to the Manage landing page', () => {
navigateFromHeaderTo(MANAGE);
navigateFromHeaderTo(SETTINGS);
cy.url().should('include', MANAGE_URL);
});

View file

@ -16,7 +16,7 @@ import { HOSTS_NAMES } from '../../screens/hosts/all_hosts';
import { ANOMALIES_TAB } from '../../screens/hosts/main';
import {
BREADCRUMBS,
EXPLORE,
EXPLORE_PANEL_BTN,
HOSTS,
KQL_INPUT,
NETWORK,
@ -222,7 +222,7 @@ describe('url state', () => {
kqlSearch('source.ip: "10.142.0.9" {enter}');
navigateFromHeaderTo(HOSTS);
openNavigationPanel(EXPLORE);
openNavigationPanel(EXPLORE_PANEL_BTN);
cy.get(NETWORK).should(
'have.attr',
'href',
@ -236,7 +236,7 @@ describe('url state', () => {
openAllHosts();
waitForAllHostsToBeLoaded();
openNavigationPanel(EXPLORE);
openNavigationPanel(EXPLORE_PANEL_BTN);
cy.get(HOSTS).should(
'have.attr',
'href',

View file

@ -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="Manage"]';
'[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Settings"]';
export const KIBANA_NAVIGATION_TOGGLE = '[data-test-subj="toggleNavButton"]';

View file

@ -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="Rules"]';
export const BACK_TO_RULES_TABLE = '[data-test-subj="breadcrumb"][title="SIEM Rules"]';
export const HIGHLIGHTED_ROWS_IN_TABLE =
'[data-test-subj="euiDataGridBody"] .alertsTableHighlightedRow';

View file

@ -7,6 +7,7 @@
// main links
export const DASHBOARDS = '[data-test-subj="solutionSideNavItemLink-dashboards"]';
export const DASHBOARDS_PANEL_BTN = '[data-test-subj="solutionSideNavItemButton-dashboards"]';
export const ALERTS = '[data-test-subj="solutionSideNavItemLink-alerts"]';
@ -18,8 +19,13 @@ export const CASES = '[data-test-subj="solutionSideNavItemLink-cases"]';
export const TIMELINES = '[data-test-subj="solutionSideNavItemLink-timelines"]';
export const EXPLORE = '[data-test-subj="solutionSideNavItemLink-explore"]';
export const EXPLORE_PANEL_BTN = '[data-test-subj="solutionSideNavItemButton-explore"]';
export const MANAGE = '[data-test-subj="solutionSideNavItemLink-administration"]';
export const RULES_LANDING = '[data-test-subj="solutionSideNavPanelLink-rules-landing"]';
export const RULES_PANEL_BTN = '[data-test-subj="solutionSideNavItemButton-rules-landing"]';
export const SETTINGS = '[data-test-subj="solutionSideNavItemLink-administration"]';
export const SETTINGS_PANEL_BTN = '[data-test-subj="solutionSideNavItemButton-administration"]';
// nested links
export const OVERVIEW = '[data-test-subj="solutionSideNavPanelLink-overview"]';
@ -80,24 +86,27 @@ export const openNavigationPanelFor = (page: string) => {
case KUBERNETES:
case ENTITY_ANALYTICS:
case CSP_DASHBOARD: {
panel = DASHBOARDS;
panel = DASHBOARDS_PANEL_BTN;
break;
}
case HOSTS:
case NETWORK:
case USERS: {
panel = EXPLORE;
panel = EXPLORE_PANEL_BTN;
break;
}
case RULES:
case EXCEPTIONS:
case CSP_BENCHMARKS: {
panel = RULES_PANEL_BTN;
break;
}
case ENDPOINTS:
case TRUSTED_APPS:
case EVENT_FILTERS:
case RULES:
case POLICIES:
case EXCEPTIONS:
case BLOCKLIST:
case CSP_BENCHMARKS: {
panel = MANAGE;
case BLOCKLIST: {
panel = SETTINGS_PANEL_BTN;
break;
}
}
@ -108,5 +117,5 @@ export const openNavigationPanelFor = (page: string) => {
// opens the navigation panel of a main link
export const openNavigationPanel = (page: string) => {
cy.get(`${page} button.solutionSideNavItemButton`).click({ force: true });
cy.get(page).click();
};

View file

@ -36,6 +36,10 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<
$addBottomPadding?: boolean;
}
>`
.kbnSolutionNav {
background-color: ${({ theme }) => theme.eui.euiColorEmptyShade};
}
.${BOTTOM_BAR_CLASSNAME} {
animation: 'none !important'; // disable the default bottom bar slide animation
background: ${({ theme }) =>

View file

@ -64,6 +64,10 @@ 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 ADD_RULES = i18n.translate('xpack.securitySolution.navigation.addRules', {
defaultMessage: 'Add Rules',
});
@ -127,8 +131,8 @@ export const EXPLORE = i18n.translate('xpack.securitySolution.navigation.explore
export const INVESTIGATE = i18n.translate('xpack.securitySolution.navigation.investigate', {
defaultMessage: 'Investigate',
});
export const MANAGE = i18n.translate('xpack.securitySolution.navigation.manage', {
defaultMessage: 'Manage',
export const SETTINGS = i18n.translate('xpack.securitySolution.navigation.settings', {
defaultMessage: 'Settings',
});
export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.blocklist', {

View file

@ -14,29 +14,29 @@ import { getCasesDeepLinks } from '@kbn/cases-plugin/public';
import { CASES_FEATURE_ID, CASES_PATH, SecurityPageName } from '../../common/constants';
import type { LinkItem } from '../common/links/types';
export const getCasesLinkItems = (): LinkItem => {
const casesLinks = getCasesDeepLinks<LinkItem>({
basePath: CASES_PATH,
extend: {
[SecurityPageName.case]: {
globalNavPosition: 5,
capabilities: [`${CASES_FEATURE_ID}.${READ_CASES_CAPABILITY}`],
},
[SecurityPageName.caseConfigure]: {
capabilities: [`${CASES_FEATURE_ID}.${UPDATE_CASES_CAPABILITY}`],
licenseType: 'gold',
sideNavDisabled: true,
},
[SecurityPageName.caseCreate]: {
capabilities: [`${CASES_FEATURE_ID}.${CREATE_CASES_CAPABILITY}`],
sideNavDisabled: true,
},
const casesLinks = getCasesDeepLinks<LinkItem>({
basePath: CASES_PATH,
extend: {
[SecurityPageName.case]: {
globalNavPosition: 5,
capabilities: [`${CASES_FEATURE_ID}.${READ_CASES_CAPABILITY}`],
},
});
const { id, deepLinks, ...rest } = casesLinks;
return {
...rest,
id: SecurityPageName.case,
links: deepLinks as LinkItem[],
};
[SecurityPageName.caseConfigure]: {
capabilities: [`${CASES_FEATURE_ID}.${UPDATE_CASES_CAPABILITY}`],
licenseType: 'gold',
sideNavDisabled: true,
},
[SecurityPageName.caseCreate]: {
capabilities: [`${CASES_FEATURE_ID}.${CREATE_CASES_CAPABILITY}`],
sideNavDisabled: true,
},
},
});
const { id, deepLinks, ...rest } = casesLinks;
export const links = {
...rest,
id: SecurityPageName.case,
links: deepLinks as LinkItem[],
};

View file

@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
import type { SecurityPageName } from '../../common/constants';
import { SERVER_APP_ID } from '../../common/constants';
import type { LinkItem } from '../common/links/types';
import { IconCloudDefend } from '../management/icons/cloud_defend';
import { IconCloudDefend } from '../common/icons/cloud_defend';
const commonLinkProperties: Partial<LinkItem> = {
isBeta: true,
@ -17,7 +17,7 @@ const commonLinkProperties: Partial<LinkItem> = {
capabilities: [`${SERVER_APP_ID}.show`],
};
export const manageLinks: LinkItem = {
export const cloudDefendLink: LinkItem = {
...getSecuritySolutionLink<SecurityPageName>('policies'),
description: i18n.translate('xpack.securitySolution.appLinks.cloudDefendPoliciesDescription', {
defaultMessage:

View file

@ -6,24 +6,25 @@
*/
import { getSecuritySolutionLink } from '@kbn/cloud-security-posture-plugin/public';
import { i18n } from '@kbn/i18n';
import { SecurityPageName, SERVER_APP_ID } from '../../common/constants';
import type { SecurityPageName } from '../../common/constants';
import { SERVER_APP_ID } from '../../common/constants';
import cloudSecurityPostureDashboardImage from '../common/images/cloud_security_posture_dashboard_page.png';
import cloudNativeVulnerabilityManagementDashboardImage from '../common/images/cloud_native_vulnerability_management_dashboard_page.png';
import type { LinkCategories, LinkItem } from '../common/links/types';
import { IconExceptionLists } from '../management/icons/exception_lists';
import type { LinkItem } from '../common/links/types';
import { IconEndpoints } from '../common/icons/endpoints';
const commonLinkProperties: Partial<LinkItem> = {
hideTimeline: true,
capabilities: [`${SERVER_APP_ID}.show`],
};
export const rootLinks: LinkItem = {
export const findingsLinks: LinkItem = {
...getSecuritySolutionLink<SecurityPageName>('findings'),
globalNavPosition: 3,
...commonLinkProperties,
};
export const dashboardLinks: LinkItem = {
export const cspDashboardLink: LinkItem = {
...getSecuritySolutionLink<SecurityPageName>('dashboard'),
description: i18n.translate(
'xpack.securitySolution.appLinks.cloudSecurityPostureDashboardDescription',
@ -46,7 +47,7 @@ export const vulnerabilityDashboardLink: LinkItem = {
...commonLinkProperties,
};
export const manageLinks: LinkItem = {
export const benchmarksLink: LinkItem = {
...getSecuritySolutionLink<SecurityPageName>('benchmarks'),
description: i18n.translate(
'xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarksDescription',
@ -54,18 +55,6 @@ export const manageLinks: LinkItem = {
defaultMessage: 'View benchmark rules.',
}
),
landingIcon: IconExceptionLists,
landingIcon: IconEndpoints,
...commonLinkProperties,
};
export const manageCategories: LinkCategories = [
{
label: i18n.translate('xpack.securitySolution.appLinks.category.cloudSecurityPosture', {
defaultMessage: 'CLOUD SECURITY',
}),
linkIds: [
SecurityPageName.cloudSecurityPostureBenchmarks,
SecurityPageName.cloudDefendPolicies,
],
},
];

View file

@ -17,7 +17,7 @@ const DEFAULT_NAV_ITEM: NavigationLink = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
icon: 'myTestIcon',
landingIcon: 'myTestIcon',
};
const spyTrack = jest.spyOn(telemetry, 'track');

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
@ -17,25 +17,26 @@ interface LandingLinksImagesProps {
items: NavigationLink[];
}
const LandingItem = styled(EuiFlexItem)`
min-width: 22em;
`;
const Title = styled(EuiTitle)`
margin-top: ${({ theme }) => theme.eui.euiSizeM};
margin-bottom: ${({ theme }) => theme.eui.euiSizeXS};
`;
const Description = styled(EuiFlexItem)`
max-width: 22em;
`;
const Link = styled.a`
color: inherit;
`;
const SecuritySolutionLink = withSecuritySolutionLink(Link);
const Description = styled(EuiFlexItem)`
max-width: 22em;
`;
const StyledEuiTitle = styled(EuiTitle)`
margin-top: ${({ theme }) => theme.eui.euiSizeM};
margin-bottom: ${({ theme }) => theme.eui.euiSizeXS};
`;
export const LandingLinksIcons: React.FC<LandingLinksImagesProps> = ({ items }) => (
<EuiFlexGrid columns={3} gutterSize="xl">
{items.map(({ title, description, id, icon, isBeta, betaOptions }) => (
<EuiFlexItem key={id} data-test-subj="LandingItem">
<EuiFlexGroup gutterSize="xl" wrap>
{items.map(({ title, description, id, landingIcon, isBeta, betaOptions }) => (
<LandingItem key={id} data-test-subj="LandingItem" grow={false}>
<EuiFlexGroup
direction="column"
alignItems="flexStart"
@ -44,11 +45,11 @@ export const LandingLinksIcons: React.FC<LandingLinksImagesProps> = ({ items })
>
<EuiFlexItem grow={false}>
<SecuritySolutionLink tabIndex={-1} deepLinkId={id}>
<EuiIcon aria-hidden="true" size="xl" type={icon ?? ''} role="presentation" />
<EuiIcon aria-hidden="true" size="xl" type={landingIcon ?? ''} role="presentation" />
</SecuritySolutionLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StyledEuiTitle size="xxs">
<Title size="xxs">
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<SecuritySolutionLinkAnchor
@ -66,7 +67,7 @@ export const LandingLinksIcons: React.FC<LandingLinksImagesProps> = ({ items })
</EuiFlexItem>
)}
</EuiFlexGroup>
</StyledEuiTitle>
</Title>
</EuiFlexItem>
<Description>
<EuiText size="s" color="text">
@ -74,7 +75,7 @@ export const LandingLinksIcons: React.FC<LandingLinksImagesProps> = ({ items })
</EuiText>
</Description>
</EuiFlexGroup>
</EuiFlexItem>
</LandingItem>
))}
</EuiFlexGrid>
</EuiFlexGroup>
);

View file

@ -7,10 +7,10 @@
import { render } from '@testing-library/react';
import React from 'react';
import { SecurityPageName } from '../../app/types';
import { TestProviders } from '../../common/mock';
import { ManagementCategories } from './landing';
import type { NavigationLink } from '../../common/links';
import { SecurityPageName } from '../../../../common';
import { TestProviders } from '../../mock';
import { LandingLinksIconsCategories } from './landing_links_icons_categories';
import type { NavigationLink } from '../../links';
const RULES_ITEM_LABEL = 'elastic rules!';
const EXCEPTIONS_ITEM_LABEL = 'exceptional!';
@ -35,27 +35,27 @@ const defaultAppManageLink: NavigationLink = {
id: SecurityPageName.rules,
title: RULES_ITEM_LABEL,
description: '',
icon: 'testIcon1',
landingIcon: 'testIcon1',
},
{
id: SecurityPageName.exceptions,
title: EXCEPTIONS_ITEM_LABEL,
description: '',
icon: 'testIcon2',
landingIcon: 'testIcon2',
},
],
};
const mockAppManageLink = jest.fn(() => defaultAppManageLink);
jest.mock('../../common/links/nav_links', () => ({
jest.mock('../../links/nav_links', () => ({
useRootNavLink: () => mockAppManageLink(),
}));
describe('ManagementCategories', () => {
describe('LandingLinksIconsCategories', () => {
it('should render items', () => {
const { queryByText } = render(
<TestProviders>
<ManagementCategories />
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
@ -75,7 +75,7 @@ describe('ManagementCategories', () => {
});
const { queryAllByTestId } = render(
<TestProviders>
<ManagementCategories />
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
@ -99,13 +99,13 @@ describe('ManagementCategories', () => {
id: SecurityPageName.rules,
title: RULES_ITEM_LABEL,
description: '',
icon: 'testIcon1',
landingIcon: 'testIcon1',
},
],
});
const { queryAllByTestId } = render(
<TestProviders>
<ManagementCategories />
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
@ -122,7 +122,7 @@ describe('ManagementCategories', () => {
});
const { queryByText } = render(
<TestProviders>
<ManagementCategories />
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);

View file

@ -0,0 +1,66 @@
/*
* 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 { css } from '@emotion/react';
import { EuiHorizontalRule, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import type { SecurityPageName } from '../../../../common';
import type { NavigationLink } from '../../links';
import { useRootNavLink } from '../../links/nav_links';
import { LandingLinksIcons } from './landing_links_icons';
type CategoriesLinks = Array<{ label?: string; links: NavigationLink[] }>;
const useCategories = ({ pageName }: { pageName: SecurityPageName }): CategoriesLinks => {
const { links = [], categories = [] } = useRootNavLink(pageName) ?? {};
const linksById = Object.fromEntries(links.map((link) => [link.id, link]));
return categories.reduce<CategoriesLinks>((acc, { label, linkIds }) => {
const linksItem = linkIds.reduce<NavigationLink[]>((linksAcc, linkId) => {
if (linksById[linkId]) {
linksAcc.push(linksById[linkId]);
}
return linksAcc;
}, []);
if (linksItem.length > 0) {
acc.push({ label, links: linksItem });
}
return acc;
}, []);
};
export const LandingLinksIconsCategories = React.memo(function LandingLinksIconsCategories({
pageName,
}: {
pageName: SecurityPageName;
}) {
const { euiTheme } = useEuiTheme();
const categories = useCategories({ pageName });
return (
<>
{categories.map(({ label, links }, index) => (
<div key={`${index}_${label}`}>
{index > 0 && (
<>
<EuiSpacer key="first" size="xl" />
<EuiSpacer key="second" size="xl" />
</>
)}
<EuiTitle size="xxxs">
<h2>{label}</h2>
</EuiTitle>
<EuiHorizontalRule
css={css`
margin-top: ${euiTheme.size.m};
margin-bottom: ${euiTheme.size.l};
`}
/>
<LandingLinksIcons items={links} />
</div>
))}
</>
);
});

View file

@ -18,14 +18,14 @@ const DEFAULT_NAV_ITEM: NavigationLink = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
image: 'TEST_IMAGE.png',
landingImage: 'TEST_IMAGE.png',
};
const BETA_NAV_ITEM: NavigationLink = {
id: SecurityPageName.kubernetes,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
image: 'TEST_IMAGE.png',
landingImage: 'TEST_IMAGE.png',
isBeta: true,
};
@ -58,17 +58,17 @@ describe('LandingLinksImages', () => {
expect(queryByText(title)).toBeInTheDocument();
});
it('renders image', () => {
const image = 'test_image.jpeg';
it('renders landingImage', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST_LABEL';
const { getByTestId } = render(
<TestProviders>
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, image, title }]} />
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
</TestProviders>
);
expect(getByTestId('LandingLinksImage')).toHaveAttribute('src', image);
expect(getByTestId('LandingLinksImage')).toHaveAttribute('src', landingImage);
});
it('renders beta tag when isBeta is true', () => {
@ -105,26 +105,26 @@ describe('LandingImageCards', () => {
expect(queryByText(title)).toBeInTheDocument();
});
it('renders image', () => {
const image = 'test_image.jpeg';
it('renders landingImage', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST_LABEL';
const { getByTestId } = render(
<TestProviders>
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, image, title }]} />
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
</TestProviders>
);
expect(getByTestId('LandingImageCard-image')).toHaveAttribute('src', image);
expect(getByTestId('LandingImageCard-image')).toHaveAttribute('src', landingImage);
});
it('sends telemetry', () => {
const image = 'test_image.jpeg';
const landingImage = 'test_image.jpeg';
const title = 'TEST LABEL';
const { getByText } = render(
<TestProviders>
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, image, title }]} />
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
</TestProviders>
);

View file

@ -59,7 +59,7 @@ const SecuritySolutionLink = withSecuritySolutionLink(Link);
export const LandingLinksImages: React.FC<LandingImagesProps> = ({ items }) => (
<EuiFlexGroup direction="column">
{items.map(({ title, description, image, id, isBeta, betaOptions }) => (
{items.map(({ title, description, landingImage, id, isBeta, betaOptions }) => (
<EuiFlexItem key={id} data-test-subj="LandingItem">
<SecuritySolutionLink
deepLinkId={id}
@ -72,13 +72,13 @@ export const LandingLinksImages: React.FC<LandingImagesProps> = ({ items }) => (
<EuiPanel hasBorder hasShadow={false} paddingSize="m" onClick={() => {}}>
<EuiFlexGroup>
<StyledFlexItem grow={false}>
{image && (
{landingImage && (
<EuiImage
data-test-subj="LandingLinksImage"
size="l"
role="presentation"
alt=""
src={image}
src={landingImage}
/>
)}
</StyledFlexItem>
@ -122,7 +122,7 @@ const SecuritySolutionCard = withSecuritySolutionLink(PrimaryTitleCard);
export const LandingImageCards: React.FC<LandingImagesProps> = React.memo(({ items }) => (
<EuiFlexGroup direction="row" wrap>
{items.map(({ id, image, title, description, isBeta, betaOptions }) => (
{items.map(({ id, landingImage, title, description, isBeta, betaOptions }) => (
<LandingImageCardItem key={id} data-test-subj="LandingImageCard-item" grow={false}>
<SecuritySolutionCard
deepLinkId={id}
@ -130,13 +130,13 @@ export const LandingImageCards: React.FC<LandingImagesProps> = React.memo(({ ite
textAlign="left"
paddingSize="m"
image={
image && (
landingImage && (
<EuiImage
data-test-subj="LandingImageCard-image"
role="presentation"
size={CARD_WIDTH}
alt={title}
src={image}
src={landingImage}
/>
)
}

View file

@ -587,7 +587,7 @@ interface LinkProps {
href: string;
}
type GetSecuritySolutionProps = (
export type GetSecuritySolutionProps = (
params: SecuritySolutionLinkProps & { onClick?: MouseEventHandler }
) => LinkProps;

View file

@ -123,12 +123,12 @@ const ipv4 = '192.0.2.255';
const ipv6 = '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff';
const ipv6Encoded = encodeIpv6(ipv6);
const securityBreadCrumb = {
const securityBreadcrumb = {
href: 'securitySolutionUI/get_started',
text: 'Security',
};
const hostsBreadcrumbs = {
const hostsBreadcrumb = {
href: 'securitySolutionUI/hosts',
text: 'Hosts',
};
@ -138,23 +138,28 @@ const networkBreadcrumb = {
href: 'securitySolutionUI/network',
};
const exploreBreadcrumbs = {
const exploreBreadcrumb = {
href: 'securitySolutionUI/explore',
text: 'Explore',
};
const rulesBReadcrumb = {
const rulesLandingBreadcrumb = {
text: 'Rules',
href: 'securitySolutionUI/rules-landing',
};
const rulesBreadcrumb = {
text: 'SIEM Rules',
href: 'securitySolutionUI/rules',
};
const exceptionsBReadcrumb = {
const exceptionsBreadcrumb = {
text: 'Shared Exception Lists',
href: 'securitySolutionUI/exceptions',
};
const manageBreadcrumbs = {
text: 'Manage',
const settingsBreadcrumb = {
text: 'Settings',
href: 'securitySolutionUI/administration',
};
@ -187,7 +192,7 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
securityBreadcrumb,
{
href: 'securitySolutionUI/dashboards',
text: 'Dashboards',
@ -205,9 +210,9 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
exploreBreadcrumbs,
hostsBreadcrumbs,
securityBreadcrumb,
exploreBreadcrumb,
hostsBreadcrumb,
{
href: '',
text: 'Authentications',
@ -221,8 +226,8 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
exploreBreadcrumbs,
securityBreadcrumb,
exploreBreadcrumb,
networkBreadcrumb,
{
text: 'Flows',
@ -237,7 +242,7 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
securityBreadcrumb,
{
text: 'Timelines',
href: '',
@ -251,9 +256,9 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
exploreBreadcrumbs,
hostsBreadcrumbs,
securityBreadcrumb,
exploreBreadcrumb,
hostsBreadcrumb,
{
text: 'siem-kibana',
href: 'securitySolutionUI/hosts/name/siem-kibana',
@ -268,8 +273,8 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
exploreBreadcrumbs,
securityBreadcrumb,
exploreBreadcrumb,
networkBreadcrumb,
{
text: ipv4,
@ -285,8 +290,8 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
exploreBreadcrumbs,
securityBreadcrumb,
exploreBreadcrumb,
networkBreadcrumb,
{
text: ipv6,
@ -302,7 +307,7 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
securityBreadcrumb,
{
text: 'Alerts',
href: 'securitySolutionUI/alerts',
@ -320,8 +325,8 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
securityBreadcrumb,
rulesLandingBreadcrumb,
{
text: 'Shared Exception Lists',
href: '',
@ -335,10 +340,10 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
securityBreadcrumb,
rulesLandingBreadcrumb,
{
text: 'Rules',
...rulesBreadcrumb,
href: '',
},
]);
@ -350,9 +355,9 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
rulesBReadcrumb,
securityBreadcrumb,
rulesLandingBreadcrumb,
rulesBreadcrumb,
{
text: 'Create',
href: '',
@ -374,9 +379,9 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
rulesBReadcrumb,
securityBreadcrumb,
rulesLandingBreadcrumb,
rulesBreadcrumb,
{
text: mockRuleName,
href: `securitySolutionUI/rules/id/${mockDetailName}`,
@ -402,9 +407,9 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
rulesBReadcrumb,
securityBreadcrumb,
rulesLandingBreadcrumb,
rulesBreadcrumb,
{
text: 'ALERT_RULE_NAME',
href: `securitySolutionUI/rules/id/${mockDetailName}`,
@ -446,8 +451,8 @@ describe('Navigation Breadcrumbs', () => {
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
securityBreadcrumb,
settingsBreadcrumb,
{
text: 'Endpoints',
href: '',
@ -462,9 +467,9 @@ describe('Navigation Breadcrumbs', () => {
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
securityBreadcrumb,
{
text: 'Manage',
...settingsBreadcrumb,
href: '',
},
]);
@ -485,9 +490,9 @@ describe('Navigation Breadcrumbs', () => {
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadCrumb,
manageBreadcrumbs,
exceptionsBReadcrumb,
securityBreadcrumb,
rulesLandingBreadcrumb,
exceptionsBreadcrumb,
{
text: mockListName,
href: ``,

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LinkCategoryType, type SeparatorLinkCategory } from '@kbn/security-solution-side-nav';
import { SecurityPageName } from '../../../../../common';
export const CATEGORIES: SeparatorLinkCategory[] = [
{
type: LinkCategoryType.separator,
linkIds: [SecurityPageName.dashboards],
},
{
type: LinkCategoryType.separator,
linkIds: [
SecurityPageName.alerts,
SecurityPageName.cloudSecurityPostureFindings,
SecurityPageName.case,
],
},
{
type: LinkCategoryType.separator,
linkIds: [
SecurityPageName.timelines,
SecurityPageName.threatIntelligenceIndicators,
SecurityPageName.exploreLanding,
],
},
{
type: LinkCategoryType.separator,
linkIds: [SecurityPageName.rulesLanding],
},
];

View file

@ -15,11 +15,12 @@ import type { SolutionSideNavProps } from '@kbn/security-solution-side-nav';
import type { NavigationLink } from '../../../links/types';
import { track } from '../../../lib/telemetry';
import { useKibana } from '../../../lib/kibana';
import { CATEGORIES } from './categories';
const manageNavLink: NavigationLink = {
const settingsNavLink: NavigationLink = {
id: SecurityPageName.administration,
title: 'manage',
description: 'manage description',
title: 'Settings',
description: 'Settings description',
categories: [{ label: 'test category', linkIds: [SecurityPageName.endpoints] }],
links: [
{
@ -38,6 +39,7 @@ const alertsNavLink: NavigationLink = {
const mockSolutionSideNav = jest.fn((_: SolutionSideNavProps) => <></>);
jest.mock('@kbn/security-solution-side-nav', () => ({
...jest.requireActual('@kbn/security-solution-side-nav'),
SolutionSideNav: (props: SolutionSideNavProps) => mockSolutionSideNav(props),
}));
jest.mock('../../../lib/kibana');
@ -80,7 +82,7 @@ const renderNav = () =>
describe('SecuritySideNav', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseNavLinks.mockReturnValue([alertsNavLink, manageNavLink]);
mockUseNavLinks.mockReturnValue([alertsNavLink, settingsNavLink]);
useKibana().services.chrome.hasHeaderBanner$ = jest.fn(() =>
new BehaviorSubject(false).asObservable()
);
@ -96,9 +98,10 @@ describe('SecuritySideNav', () => {
id: SecurityPageName.alerts,
label: 'alerts',
href: '/alerts',
position: 'top',
},
],
footerItems: [],
categories: CATEGORIES,
tracker: track,
});
});
@ -121,22 +124,21 @@ describe('SecuritySideNav', () => {
});
it('should render footer items', () => {
mockUseNavLinks.mockReturnValue([manageNavLink]);
mockUseNavLinks.mockReturnValue([settingsNavLink]);
renderNav();
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
items: [],
footerItems: [
items: [
{
id: SecurityPageName.administration,
label: 'manage',
label: 'Settings',
href: '/administration',
categories: manageNavLink.categories,
categories: settingsNavLink.categories,
position: 'bottom',
items: [
{
id: SecurityPageName.endpoints,
label: 'title 2',
description: 'description 2',
href: '/endpoints',
isBeta: true,
},
@ -148,12 +150,11 @@ describe('SecuritySideNav', () => {
});
it('should not render disabled items', () => {
mockUseNavLinks.mockReturnValue([{ ...alertsNavLink, disabled: true }, manageNavLink]);
mockUseNavLinks.mockReturnValue([{ ...alertsNavLink, disabled: true }, settingsNavLink]);
renderNav();
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
items: [],
footerItems: [
items: [
expect.objectContaining({
id: SecurityPageName.administration,
}),
@ -162,17 +163,18 @@ describe('SecuritySideNav', () => {
);
});
it('should render custom item', () => {
mockUseNavLinks.mockReturnValue([{ id: SecurityPageName.landing, title: 'get started' }]);
it('should render get started item', () => {
mockUseNavLinks.mockReturnValue([
{ id: SecurityPageName.landing, title: 'Get started', sideNavIcon: 'launch' },
]);
renderNav();
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
items: [],
footerItems: [
items: [
expect.objectContaining({
id: SecurityPageName.landing,
label: 'GET STARTED',
labelSize: 'xs',
label: 'Get started',
position: 'bottom',
iconType: 'launch',
appendSeparator: true,
}),

View file

@ -7,25 +7,79 @@
import React, { useMemo } from 'react';
import { EuiLoadingSpinner, useEuiTheme } from '@elastic/eui';
import { SolutionSideNav, type SolutionSideNavItem } from '@kbn/security-solution-side-nav';
import {
SolutionSideNav,
SolutionSideNavItemPosition,
type SolutionSideNavItem,
} from '@kbn/security-solution-side-nav';
import useObservable from 'react-use/lib/useObservable';
import { SecurityPageName } from '../../../../app/types';
import type { NavigationLink } from '../../../links';
import { getAncestorLinksInfo } from '../../../links';
import { useRouteSpy } from '../../../utils/route/use_route_spy';
import { useGetSecuritySolutionLinkProps } from '../../links';
import { useGetSecuritySolutionLinkProps, type GetSecuritySolutionProps } from '../../links';
import { useNavLinks } from '../../../links/nav_links';
import { useShowTimeline } from '../../../utils/timeline/use_show_timeline';
import { useIsPolicySettingsBarVisible } from '../../../../management/pages/policy/view/policy_hooks';
import { track } from '../../../lib/telemetry';
import { useKibana } from '../../../lib/kibana';
import { CATEGORIES } from './categories';
export const EUI_HEADER_HEIGHT = '93px';
export const BOTTOM_BAR_HEIGHT = '50px';
const isFooterNavItem = (id: SecurityPageName) =>
id === SecurityPageName.landing || id === SecurityPageName.administration;
const getNavItemPosition = (id: SecurityPageName): SolutionSideNavItemPosition =>
id === SecurityPageName.landing || id === SecurityPageName.administration
? SolutionSideNavItemPosition.bottom
: SolutionSideNavItemPosition.top;
const isGetStartedNavItem = (id: SecurityPageName) => id === SecurityPageName.landing;
/**
* Formats generic navigation links into the shape expected by the `SolutionSideNav`
*/
const formatLink = (
navLink: NavigationLink,
getSecuritySolutionLinkProps: GetSecuritySolutionProps
): SolutionSideNavItem => ({
id: navLink.id,
label: navLink.title,
position: getNavItemPosition(navLink.id),
...getSecuritySolutionLinkProps({ deepLinkId: navLink.id }),
...(navLink.sideNavIcon && { iconType: navLink.sideNavIcon }),
...(navLink.categories?.length && { categories: navLink.categories }),
...(navLink.links?.length && {
items: navLink.links.reduce<SolutionSideNavItem[]>((acc, current) => {
if (!current.disabled) {
acc.push({
id: current.id,
label: current.title,
iconType: current.sideNavIcon,
isBeta: current.isBeta,
betaOptions: current.betaOptions,
...getSecuritySolutionLinkProps({ deepLinkId: current.id }),
});
}
return acc;
}, []),
}),
});
/**
* Formats the get started navigation links into the shape expected by the `SolutionSideNav`
*/
const formatGetStartedLink = (
navLink: NavigationLink,
getSecuritySolutionLinkProps: GetSecuritySolutionProps
): SolutionSideNavItem => ({
id: navLink.id,
label: navLink.title,
iconType: navLink.sideNavIcon,
position: SolutionSideNavItemPosition.bottom,
appendSeparator: true,
...getSecuritySolutionLinkProps({ deepLinkId: navLink.id }),
});
/**
* Returns the formatted `items` and `footerItems` to be rendered in the navigation
*/
@ -34,60 +88,21 @@ const useSolutionSideNavItems = () => {
const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props
const sideNavItems = useMemo(() => {
const mainNavItems: SolutionSideNavItem[] = [];
const footerNavItems: SolutionSideNavItem[] = [];
navLinks.forEach((navLink) => {
if (!navLinks?.length) {
return undefined;
}
return navLinks.reduce<SolutionSideNavItem[]>((navItems, navLink) => {
if (navLink.disabled) {
return;
return navItems;
}
let sideNavItem: SolutionSideNavItem;
if (isGetStartedNavItem(navLink.id)) {
sideNavItem = {
id: navLink.id,
label: navLink.title.toUpperCase(),
labelSize: 'xs',
iconType: 'launch',
...getSecuritySolutionLinkProps({
deepLinkId: navLink.id,
}),
appendSeparator: true,
};
navItems.push(formatGetStartedLink(navLink, getSecuritySolutionLinkProps));
} else {
// generic links
sideNavItem = {
id: navLink.id,
label: navLink.title,
...getSecuritySolutionLinkProps({
deepLinkId: navLink.id,
}),
...(navLink.categories?.length && { categories: navLink.categories }),
...(navLink.links?.length && {
items: navLink.links.reduce<SolutionSideNavItem[]>((acc, current) => {
if (!current.disabled) {
acc.push({
id: current.id,
label: current.title,
description: current.description,
isBeta: current.isBeta,
betaOptions: current.betaOptions,
...getSecuritySolutionLinkProps({ deepLinkId: current.id }),
});
}
return acc;
}, []),
}),
};
navItems.push(formatLink(navLink, getSecuritySolutionLinkProps));
}
if (isFooterNavItem(navLink.id)) {
footerNavItems.push(sideNavItem);
} else {
mainNavItems.push(sideNavItem);
}
});
return [mainNavItems, footerNavItems];
return navItems;
}, []);
}, [navLinks, getSecuritySolutionLinkProps]);
return sideNavItems;
@ -123,19 +138,19 @@ const usePanelBottomOffset = (): string | undefined => {
* It takes the links to render from the generic application `links` configs.
*/
export const SecuritySideNav: React.FC = () => {
const [items, footerItems] = useSolutionSideNavItems();
const items = useSolutionSideNavItems();
const selectedId = useSelectedId();
const panelTopOffset = usePanelTopOffset();
const panelBottomOffset = usePanelBottomOffset();
if (items.length === 0 && footerItems.length === 0) {
if (!items) {
return <EuiLoadingSpinner size="m" data-test-subj="sideNavLoader" />;
}
return (
<SolutionSideNav
items={items}
footerItems={footerItems}
categories={CATEGORIES}
selectedId={selectedId}
panelTopOffset={panelTopOffset}
panelBottomOffset={panelBottomOffset}

View file

@ -7,7 +7,14 @@
import type { SVGProps } from 'react';
import React from 'react';
export const IconCloudDefend: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_327_292367)">
<path
fillRule="evenodd"

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconActionHistory: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconConsole: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconExceptionLists: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconConsoleCloud: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconTrustedApplications: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconDashboards: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}

View file

@ -0,0 +1,26 @@
/*
* 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 IconPipeline: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
fill="none"
viewBox="0 0 32 32"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M30 23H29C28.449 23 28 22.552 28 22V14C28 13.448 28.449 13 29 13H30V23ZM20 20V22C20 22.552 19.551 23 19 23H13C12.449 23 12 22.552 12 22V20H6V16H12V14C12 13.448 12.449 13 13 13H19C19.551 13 20 13.448 20 14V16H26V20H20ZM3 23H2V13H3C3.551 13 4 13.448 4 14V22C4 22.552 3.551 23 3 23ZM29 11C27.346 11 26 12.346 26 14H22C22 12.346 20.654 11 19 11H13C11.346 11 10 12.346 10 14H6C6 12.346 4.654 11 3 11H0V25H3C4.654 25 6 23.654 6 22H10C10 23.654 11.346 25 13 25H19C20.654 25 22 23.654 22 22H26C26 23.654 27.346 25 29 25H32V11H29Z"
fill="#535766"
/>
<path fillRule="evenodd" clipRule="evenodd" d="M22 5H10V7H15V9H17V7H22V5Z" fill="#00BFB3" />
</svg>
);

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconSiemRules: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconRollup: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconHostIsolation: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconSavedObject: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconBlocklist: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconShield: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}

View file

@ -6,7 +6,7 @@
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconEndpointPolicies: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
export const IconTool: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
@ -18,8 +18,9 @@ export const IconEndpointPolicies: React.FC<SVGProps<SVGSVGElement>> = ({ ...pro
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 28.0001H14V19.5081L14.6 19.2461C17.88 17.8121 20 14.5751 20 11.0001C20 7.96306 18.471 5.17106 16 3.52106V11.0001H6V3.52106C3.529 5.17106 2 7.96306 2 11.0001C2 14.5751 4.12 17.8121 7.4 19.2461L8 19.5081V28.0001H6V20.7951C2.334 18.9241 0 15.1481 0 11.0001C0 6.63006 2.591 2.67406 6.6 0.922059L8 0.310059V9.00006H14V0.310059L15.4 0.922059C19.409 2.67406 22 6.63006 22 11.0001C22 15.1481 19.666 18.9241 16 20.7951V28.0001Z"
d="M16 28H14V19.508L14.6 19.246C17.88 17.812 20 14.575 20 11C20 7.963 18.471 5.171 16 3.521V11H6V3.521C3.529 5.171 2 7.963 2 11C2 14.575 4.12 17.812 7.4 19.246L8 19.508V28H6V20.795C2.334 18.924 0 15.148 0 11C0 6.63 2.591 2.674 6.6 0.921998L8 0.309998V9H14V0.309998L15.4 0.921998C19.409 2.674 22 6.63 22 11C22 15.148 19.666 18.924 16 20.795V28Z"
fill="#535766"
/>
<path fillRule="evenodd" clipRule="evenodd" d="M6 32H16V30H6V32Z" fill="#00BFB3" />
</svg>
);

View file

@ -7,26 +7,26 @@
import type { CoreStart } from '@kbn/core/public';
import type { AppLinkItems } from './types';
import { indicatorsLinks } from '../../threat_intelligence/links';
import { links as detectionLinks } from '../../detections/links';
import { links as alertsLinks } from '../../detections/links';
import { links as rulesLinks } from '../../rules/links';
import { links as timelinesLinks } from '../../timelines/links';
import { getCasesLinkItems } from '../../cases/links';
import { links as casesLinks } from '../../cases/links';
import { links as managementLinks, getManagementFilteredLinks } from '../../management/links';
import { exploreLinks } from '../../explore/links';
import { gettingStartedLinks } from '../../overview/links';
import { rootLinks as cloudSecurityPostureRootLinks } from '../../cloud_security_posture/links';
import { findingsLinks } from '../../cloud_security_posture/links';
import type { StartPlugins } from '../../types';
import { dashboardsLandingLinks } from '../../dashboards/links';
import { dashboardsLinks } from '../../dashboards/links';
const casesLinks = getCasesLinkItems();
export const links = Object.freeze([
dashboardsLandingLinks,
detectionLinks,
cloudSecurityPostureRootLinks,
timelinesLinks,
export const links: AppLinkItems = Object.freeze([
dashboardsLinks,
alertsLinks,
findingsLinks,
casesLinks,
exploreLinks,
timelinesLinks,
indicatorsLinks,
exploreLinks,
rulesLinks,
gettingStartedLinks,
managementLinks,
]);
@ -38,13 +38,14 @@ export const getFilteredLinks = async (
const managementFilteredLinks = await getManagementFilteredLinks(core, plugins);
return Object.freeze([
dashboardsLandingLinks,
detectionLinks,
cloudSecurityPostureRootLinks,
timelinesLinks,
dashboardsLinks,
alertsLinks,
findingsLinks,
casesLinks,
exploreLinks,
timelinesLinks,
indicatorsLinks,
exploreLinks,
rulesLinks,
gettingStartedLinks,
managementFilteredLinks,
]);

View file

@ -42,9 +42,9 @@ describe('formatNavigationLinks', () => {
Object {
"description": "description 2",
"disabled": true,
"icon": "someicon",
"id": "endpoints",
"image": "someimage",
"landingIcon": "someicon",
"landingImage": "someimage",
"skipUrlState": true,
"title": "title 2",
},

View file

@ -12,14 +12,15 @@ import type { SecurityPageName } from '../../app/types';
import type { AppLinkItems, NavigationLink } from './types';
export const formatNavigationLinks = (appLinks: AppLinkItems): NavigationLink[] =>
appLinks.map((link) => ({
appLinks.map<NavigationLink>((link) => ({
id: link.id,
title: link.title,
...(link.categories != null ? { categories: link.categories } : {}),
...(link.description != null ? { description: link.description } : {}),
...(link.sideNavDisabled === true ? { disabled: true } : {}),
...(link.landingIcon != null ? { icon: link.landingIcon } : {}),
...(link.landingImage != null ? { image: link.landingImage } : {}),
...(link.landingIcon != null ? { landingIcon: link.landingIcon } : {}),
...(link.landingImage != null ? { landingImage: link.landingImage } : {}),
...(link.sideNavIcon != null ? { sideNavIcon: link.sideNavIcon } : {}),
...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}),
...(link.isBeta != null ? { isBeta: link.isBeta } : {}),
...(link.betaOptions != null ? { betaOptions: link.betaOptions } : {}),

View file

@ -8,6 +8,10 @@
import type { Capabilities } from '@kbn/core/types';
import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types';
import type { IconType } from '@elastic/eui';
import type {
LinkCategory as BaseLinkCategory,
LinkCategories as BaseLinkCategories,
} from '@kbn/security-solution-side-nav';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import type { SecurityPageName } from '../../../common/constants';
import type { UpsellingService } from '../lib/upsellings';
@ -23,12 +27,8 @@ export interface LinksPermissions {
license?: ILicense;
}
export interface LinkCategory {
label: string;
linkIds: readonly SecurityPageName[];
}
export type LinkCategories = Readonly<LinkCategory[]>;
export type LinkCategory = BaseLinkCategory<SecurityPageName>;
export type LinkCategories = BaseLinkCategories<SecurityPageName>;
export interface LinkItem {
/**
@ -119,6 +119,10 @@ export interface LinkItem {
* Disables link in the side navigation. Defaults to false.
*/
sideNavDisabled?: boolean;
/**
* Icon that is displayed on the side navigation menu.
*/
sideNavIcon?: IconType;
/**
* Disables the state query string in the URL. Defaults to false.
*/
@ -143,11 +147,12 @@ export interface NavigationLink {
categories?: LinkCategories;
description?: string;
disabled?: boolean;
icon?: IconType;
id: SecurityPageName;
landingIcon?: IconType;
landingImage?: string;
links?: NavigationLink[];
image?: string;
title: string;
sideNavIcon?: IconType;
skipUrlState?: boolean;
unauthorized?: boolean;
isBeta?: boolean;

View file

@ -9,18 +9,26 @@ import { DASHBOARDS_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/c
import { DASHBOARDS } from '../app/translations';
import type { LinkItem } from '../common/links/types';
import { links as kubernetesLinks } from '../kubernetes/links';
import {
dashboardLinks as cloudSecurityPostureLinks,
vulnerabilityDashboardLink,
} from '../cloud_security_posture/links';
import { cspDashboardLink, vulnerabilityDashboardLink } from '../cloud_security_posture/links';
import {
ecsDataQualityDashboardLinks,
detectionResponseLinks,
entityAnalyticsLinks,
overviewLinks,
} from '../overview/links';
import { IconDashboards } from '../common/icons/dashboards';
export const dashboardsLandingLinks: LinkItem = {
const subLinks: LinkItem[] = [
overviewLinks,
detectionResponseLinks,
kubernetesLinks,
cspDashboardLink,
vulnerabilityDashboardLink,
entityAnalyticsLinks,
ecsDataQualityDashboardLinks,
].map((link) => ({ ...link, sideNavIcon: IconDashboards }));
export const dashboardsLinks: LinkItem = {
id: SecurityPageName.dashboards,
title: DASHBOARDS,
path: DASHBOARDS_PATH,
@ -31,14 +39,6 @@ export const dashboardsLandingLinks: LinkItem = {
defaultMessage: 'Dashboards',
}),
],
links: [
overviewLinks,
detectionResponseLinks,
kubernetesLinks,
cloudSecurityPostureLinks,
vulnerabilityDashboardLink,
entityAnalyticsLinks,
ecsDataQualityDashboardLinks,
],
links: subLinks,
skipUrlState: false,
};

View file

@ -36,13 +36,13 @@ const APP_DASHBOARD_LINKS: NavigationLink = {
id: SecurityPageName.overview,
title: OVERVIEW_ITEM_LABEL,
description: '',
icon: 'testIcon1',
landingIcon: 'testIcon1',
},
{
id: SecurityPageName.detectionAndResponse,
title: DETECTION_RESPONSE_ITEM_LABEL,
description: '',
icon: 'testIcon2',
landingIcon: 'testIcon2',
},
],
};

View file

@ -188,5 +188,6 @@ export const exploreLinks: LinkItem = {
}),
],
links: [hostsLinks, networkLinks, usersLinks],
hideTimeline: true,
skipUrlState: true,
};

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SVGProps } from 'react';
import React from 'react';
export const IconEventFilters: React.FC<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
fill="none"
viewBox="0 0 32 32"
{...props}
>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M30 23H29C28.449 23 28 22.552 28 22V14C28 13.448 28.449 13 29 13H30V23ZM20 20V22C20 22.552 19.551 23 19 23H13C12.449 23 12 22.552 12 22V20H6V16H12V14C12 13.448 12.449 13 13 13H19C19.551 13 20 13.448 20 14V16H26V20H20ZM3 23H2V13H3C3.551 13 4 13.448 4 14V22C4 22.552 3.551 23 3 23ZM29 11C27.346 11 26 12.346 26 14H22C22 12.346 20.654 11 19 11H13C11.346 11 10 12.346 10 14H6C6 12.346 4.654 11 3 11H0V25H3C4.654 25 6 23.654 6 22H10C10 23.654 11.346 25 13 25H19C20.654 25 22 23.654 22 22H26C26 23.654 27.346 25 29 25H32V11H29Z"
fill="#535766"
/>
<path fillRule="evenodd" clipRule="evenodd" d="M22 5H10V7H15V9H17V7H22V5Z" fill="#00BFB3" />
</svg>
</svg>
);

View file

@ -17,61 +17,41 @@ import {
BLOCKLIST_PATH,
ENDPOINTS_PATH,
EVENT_FILTERS_PATH,
EXCEPTIONS_PATH,
HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGE_PATH,
POLICIES_PATH,
RESPONSE_ACTIONS_HISTORY_PATH,
RULES_ADD_PATH,
RULES_CREATE_PATH,
RULES_PATH,
SecurityPageName,
SERVER_APP_ID,
TRUSTED_APPS_PATH,
} from '../../common/constants';
import {
ADD_RULES,
BLOCKLIST,
CREATE_NEW_RULE,
ENDPOINTS,
EVENT_FILTERS,
EXCEPTIONS,
HOST_ISOLATION_EXCEPTIONS,
MANAGE,
SETTINGS,
POLICIES,
RESPONSE_ACTIONS_HISTORY,
RULES,
TRUSTED_APPLICATIONS,
} from '../app/translations';
import { licenseService } from '../common/hooks/use_license';
import type { LinkItem } from '../common/links/types';
import type { StartPlugins } from '../types';
import {
manageCategories as cloudSecurityPostureCategories,
manageLinks as cloudSecurityPostureLinks,
} from '../cloud_security_posture/links';
import { manageLinks as cloudDefendLinks } from '../cloud_defend/links';
import { IconActionHistory } from './icons/action_history';
import { IconBlocklist } from './icons/blocklist';
import { IconEndpoints } from './icons/endpoints';
import { IconEndpointPolicies } from './icons/endpoint_policies';
import { IconEventFilters } from './icons/event_filters';
import { IconExceptionLists } from './icons/exception_lists';
import { IconHostIsolation } from './icons/host_isolation';
import { IconSiemRules } from './icons/siem_rules';
import { IconTrustedApplications } from './icons/trusted_applications';
import { cloudDefendLink } from '../cloud_defend/links';
import { IconConsole } from '../common/icons/console';
import { IconShield } from '../common/icons/shield';
import { IconEndpoints } from '../common/icons/endpoints';
import { IconTool } from '../common/icons/tool';
import { IconPipeline } from '../common/icons/pipeline';
import { IconSavedObject } from '../common/icons/saved_object';
import { IconDashboards } from '../common/icons/dashboards';
import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client';
const categories = [
{
label: i18n.translate('xpack.securitySolution.appLinks.category.siem', {
defaultMessage: 'SIEM',
}),
linkIds: [SecurityPageName.rules, SecurityPageName.exceptions],
},
{
label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', {
defaultMessage: 'ENDPOINTS',
defaultMessage: 'Endpoints',
}),
linkIds: [
SecurityPageName.endpoints,
@ -83,73 +63,29 @@ const categories = [
SecurityPageName.responseActionsHistory,
],
},
...cloudSecurityPostureCategories,
{
label: i18n.translate('xpack.securitySolution.appLinks.category.cloudSecurity', {
defaultMessage: 'Cloud Security',
}),
linkIds: [cloudDefendLink.id],
},
];
export const links: LinkItem = {
id: SecurityPageName.administration,
title: MANAGE,
title: SETTINGS,
path: MANAGE_PATH,
skipUrlState: true,
hideTimeline: true,
globalNavPosition: 8,
capabilities: [`${SERVER_APP_ID}.show`],
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.manage', {
defaultMessage: 'Manage',
i18n.translate('xpack.securitySolution.appLinks.settings', {
defaultMessage: 'Settings',
}),
],
categories,
links: [
{
id: SecurityPageName.rules,
title: 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.",
}),
landingIcon: IconSiemRules,
path: RULES_PATH,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.rules', {
defaultMessage: 'Rules',
}),
],
links: [
{
id: SecurityPageName.rulesAdd,
title: ADD_RULES,
path: RULES_ADD_PATH,
skipUrlState: true,
hideTimeline: true,
},
{
id: SecurityPageName.rulesCreate,
title: CREATE_NEW_RULE,
path: RULES_CREATE_PATH,
skipUrlState: true,
hideTimeline: true,
},
],
},
{
id: SecurityPageName.exceptions,
title: EXCEPTIONS,
description: i18n.translate('xpack.securitySolution.appLinks.exceptionsDescription', {
defaultMessage:
'Create and manage shared exception lists to prevent the creation of unwanted alerts.',
}),
landingIcon: IconExceptionLists,
path: EXCEPTIONS_PATH,
skipUrlState: true,
hideTimeline: true,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.exceptions', {
defaultMessage: 'Exception lists',
}),
],
},
{
id: SecurityPageName.endpoints,
description: i18n.translate('xpack.securitySolution.appLinks.endpointsDescription', {
@ -168,7 +104,7 @@ export const links: LinkItem = {
defaultMessage:
'Use policies to customize endpoint and cloud workload protections and other configurations.',
}),
landingIcon: IconEndpointPolicies,
landingIcon: IconTool,
path: POLICIES_PATH,
skipUrlState: true,
hideTimeline: true,
@ -183,7 +119,7 @@ export const links: LinkItem = {
'Improve performance or alleviate conflicts with other applications running on your hosts.',
}
),
landingIcon: IconTrustedApplications,
landingIcon: IconDashboards,
path: TRUSTED_APPS_PATH,
skipUrlState: true,
hideTimeline: true,
@ -194,7 +130,7 @@ export const links: LinkItem = {
description: i18n.translate('xpack.securitySolution.appLinks.eventFiltersDescription', {
defaultMessage: 'Exclude high volume or unwanted events being written into Elasticsearch.',
}),
landingIcon: IconEventFilters,
landingIcon: IconPipeline,
path: EVENT_FILTERS_PATH,
skipUrlState: true,
hideTimeline: true,
@ -205,7 +141,7 @@ export const links: LinkItem = {
description: i18n.translate('xpack.securitySolution.appLinks.hostIsolationDescription', {
defaultMessage: 'Allow isolated hosts to communicate with specific IPs.',
}),
landingIcon: IconHostIsolation,
landingIcon: IconSavedObject,
path: HOST_ISOLATION_EXCEPTIONS_PATH,
skipUrlState: true,
hideTimeline: true,
@ -216,7 +152,7 @@ export const links: LinkItem = {
description: i18n.translate('xpack.securitySolution.appLinks.blocklistDescription', {
defaultMessage: 'Exclude unwanted applications from running on your hosts.',
}),
landingIcon: IconBlocklist,
landingIcon: IconShield,
path: BLOCKLIST_PATH,
skipUrlState: true,
hideTimeline: true,
@ -227,13 +163,12 @@ export const links: LinkItem = {
description: i18n.translate('xpack.securitySolution.appLinks.actionHistoryDescription', {
defaultMessage: 'View the history of response actions performed on hosts.',
}),
landingIcon: IconActionHistory,
landingIcon: IconConsole,
path: RESPONSE_ACTIONS_HISTORY_PATH,
skipUrlState: true,
hideTimeline: true,
},
cloudSecurityPostureLinks,
cloudDefendLinks,
cloudDefendLink,
],
};

View file

@ -5,77 +5,22 @@
* 2.0.
*/
import React from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui';
import { SecurityPageName } from '../../app/types';
import { HeaderPage } from '../../common/components/header_page';
import { useRootNavLink } from '../../common/links/nav_links';
import type { NavigationLink } from '../../common/links';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { LandingLinksIcons } from '../../common/components/landing_links/landing_links_icons';
import { LandingLinksIconsCategories } from '../../common/components/landing_links/landing_links_icons_categories';
const MANAGE_PAGE_TITLE = i18n.translate('xpack.securitySolution.management.landing.pageTitle', {
defaultMessage: 'Manage',
const PAGE_TITLE = i18n.translate('xpack.securitySolution.management.landing.settingsTitle', {
defaultMessage: 'Settings',
});
export const ManageLandingPage = () => (
<SecuritySolutionPageWrapper>
<HeaderPage title={MANAGE_PAGE_TITLE} />
<ManagementCategories />
<HeaderPage title={PAGE_TITLE} />
<LandingLinksIconsCategories pageName={SecurityPageName.administration} />
<SpyRoute pageName={SecurityPageName.administration} />
</SecuritySolutionPageWrapper>
);
const StyledEuiHorizontalRule = styled(EuiHorizontalRule)`
margin-top: ${({ theme }) => theme.eui.euiSizeM};
margin-bottom: ${({ theme }) => theme.eui.euiSizeL};
`;
type ManagementCategories = Array<{ label: string; links: NavigationLink[] }>;
const useManagementCategories = (): ManagementCategories => {
const { links = [], categories = [] } = useRootNavLink(SecurityPageName.administration) ?? {};
const manageLinksById = Object.fromEntries(links.map((link) => [link.id, link]));
return categories.reduce<ManagementCategories>((acc, { label, linkIds }) => {
const linksItem = linkIds.reduce<NavigationLink[]>((linksAcc, linkId) => {
if (manageLinksById[linkId]) {
linksAcc.push(manageLinksById[linkId]);
}
return linksAcc;
}, []);
if (linksItem.length > 0) {
acc.push({ label, links: linksItem });
}
return acc;
}, []);
};
export const ManagementCategories = () => {
const managementCategories = useManagementCategories();
return (
<>
{managementCategories.map(({ label, links }, index) => (
<div key={label}>
{index > 0 && (
<>
<EuiSpacer key="first" size="xl" />
<EuiSpacer key="second" size="xl" />
</>
)}
<EuiTitle size="xxxs">
<h2>{label}</h2>
</EuiTitle>
<StyledEuiHorizontalRule />
<LandingLinksIcons items={links} />
</div>
))}
</>
);
};
ManagementCategories.displayName = 'ManagementCategories';

View file

@ -55,6 +55,7 @@ export const gettingStartedLinks: LinkItem = {
defaultMessage: 'Getting started',
}),
],
sideNavIcon: 'launch',
skipUrlState: true,
hideTimeline: true,
};

View file

@ -0,0 +1,51 @@
/*
* 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 { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { SecurityPageName } from '../../common';
import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper';
import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper';
import { SpyRoute } from '../common/utils/route/spy_routes';
import { LandingLinksIconsCategories } from '../common/components/landing_links/landing_links_icons_categories';
import { Title } from '../common/components/header_page/title';
import { SecuritySolutionLinkButton } from '../common/components/links';
const RULES_PAGE_TITLE = i18n.translate('xpack.securitySolution.rules.landing.pageTitle', {
defaultMessage: 'Rules',
});
const CREATE_RULE_BUTTON = i18n.translate('xpack.securitySolution.rules.landing.createRule', {
defaultMessage: 'Create rule',
});
const RulesLandingHeader: React.FC = () => (
<EuiFlexGroup gutterSize="none" direction="row">
<EuiFlexItem>
<Title title={RULES_PAGE_TITLE} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SecuritySolutionLinkButton deepLinkId={SecurityPageName.rulesCreate} iconType="plusInCircle">
{CREATE_RULE_BUTTON}
</SecuritySolutionLinkButton>
</EuiFlexItem>
</EuiFlexGroup>
);
export const RulesLandingPage = () => (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.rulesLanding}>
<SecuritySolutionPageWrapper>
<RulesLandingHeader />
<EuiSpacer size="xl" />
<LandingLinksIconsCategories pageName={SecurityPageName.rulesLanding} />
<SpyRoute pageName={SecurityPageName.rulesLanding} />
</SecuritySolutionPageWrapper>
</TrackApplicationView>
</PluginTemplateWrapper>
);

View file

@ -0,0 +1,93 @@
/*
* 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 {
RULES_PATH,
RULES_CREATE_PATH,
EXCEPTIONS_PATH,
RULES_LANDING_PATH,
RULES_ADD_PATH,
} from '../../common/constants';
import { ADD_RULES, CREATE_NEW_RULE, EXCEPTIONS, RULES, SIEM_RULES } from '../app/translations';
import { SecurityPageName } from '../app/types';
import { benchmarksLink } from '../cloud_security_posture/links';
import type { LinkItem } from '../common/links';
import { IconConsoleCloud } from '../common/icons/console_cloud';
import { IconRollup } from '../common/icons/rollup';
export const links: LinkItem = {
id: SecurityPageName.rulesLanding,
title: RULES,
path: RULES_LANDING_PATH,
hideTimeline: true,
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.",
}),
landingIcon: IconRollup,
path: RULES_PATH,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.rules', {
defaultMessage: 'Rules',
}),
],
links: [
{
id: SecurityPageName.rulesAdd,
title: ADD_RULES,
path: RULES_ADD_PATH,
skipUrlState: true,
hideTimeline: true,
},
{
id: SecurityPageName.rulesCreate,
title: CREATE_NEW_RULE,
path: RULES_CREATE_PATH,
skipUrlState: true,
hideTimeline: true,
},
],
},
{
id: SecurityPageName.exceptions,
title: EXCEPTIONS,
description: i18n.translate('xpack.securitySolution.appLinks.exceptionsDescription', {
defaultMessage:
'Create and manage shared exception lists to prevent the creation of unwanted alerts.',
}),
landingIcon: IconConsoleCloud,
path: EXCEPTIONS_PATH,
skipUrlState: true,
hideTimeline: true,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.exceptions', {
defaultMessage: 'Exception lists',
}),
],
},
benchmarksLink,
],
categories: [
{
label: i18n.translate('xpack.securitySolution.appLinks.category.siemRules', {
defaultMessage: 'Security Detection Rules',
}),
linkIds: [SecurityPageName.rules, SecurityPageName.exceptions],
},
{
label: i18n.translate('xpack.securitySolution.appLinks.category.cspRules', {
defaultMessage: 'Cloud Security Rules',
}),
linkIds: [SecurityPageName.cloudSecurityPostureBenchmarks],
},
],
};

View file

@ -10,7 +10,7 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import * as i18n from './translations';
import { RULES_PATH, SecurityPageName } from '../../common/constants';
import { RULES_LANDING_PATH, RULES_PATH, SecurityPageName } from '../../common/constants';
import { NotFoundPage } from '../app/404';
import { RulesPage } from '../detection_engine/rule_management_ui/pages/rule_management';
import { CreateRulePage } from '../detection_engine/rule_creation_ui/pages/rule_creation';
@ -24,6 +24,8 @@ import { PluginTemplateWrapper } from '../common/components/plugin_template_wrap
import { SpyRoute } from '../common/utils/route/spy_routes';
import { AllRulesTabs } from '../detection_engine/rule_management_ui/components/rules_table/rules_table_toolbar';
import { AddRulesPage } from '../detection_engine/rule_management_ui/pages/add_rules';
import type { SecuritySubPluginRoutes } from '../app/types';
import { RulesLandingPage } from './landing';
const RulesSubRoutes = [
{
@ -100,11 +102,13 @@ const RulesContainerComponent: React.FC = () => {
const Rules = React.memo(RulesContainerComponent);
const renderRulesRoutes = () => <Rules />;
export const routes = [
export const routes: SecuritySubPluginRoutes = [
{
path: RULES_LANDING_PATH,
component: RulesLandingPage,
},
{
path: RULES_PATH,
render: renderRulesRoutes,
component: Rules,
},
];

View file

@ -74,3 +74,6 @@ export const GetStartedComponent: React.FC = () => {
GetStartedComponent.displayName = 'GetStartedComponent';
export const GetStarted = React.memo(GetStartedComponent);
// eslint-disable-next-line import/no-default-export
export default GetStarted;

View file

@ -10,7 +10,7 @@ import React from 'react';
import { CoreStart } from '@kbn/core/public';
import type { GetStartedComponent } from './types';
import { GetStarted } from './get_started';
import { GetStarted } from './lazy';
import { KibanaServicesProvider } from '../../services';
import { ServerlessSecurityPluginStartDependencies } from '../../types';

View file

@ -0,0 +1,18 @@
/*
* 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, { lazy, Suspense } from 'react';
import { EuiLoadingLogo } from '@elastic/eui';
const GetStartedLazy = lazy(() => import('./get_started'));
const centerLogoStyle = { display: 'flex', margin: 'auto' };
export const GetStarted = () => (
<Suspense fallback={<EuiLoadingLogo logo="logoSecurity" size="xl" style={centerLogoStyle} />}>
<GetStartedLazy />
</Suspense>
);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LinkCategoryType, type SeparatorLinkCategory } from '@kbn/security-solution-side-nav';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
export const CATEGORIES: SeparatorLinkCategory[] = [
{
type: LinkCategoryType.separator,
linkIds: [SecurityPageName.dashboards],
},
{
type: LinkCategoryType.separator,
linkIds: [
SecurityPageName.alerts,
SecurityPageName.cloudSecurityPostureFindings,
SecurityPageName.case,
],
},
{
type: LinkCategoryType.separator,
linkIds: [
SecurityPageName.timelines,
SecurityPageName.threatIntelligenceIndicators,
SecurityPageName.exploreLanding,
],
},
{
type: LinkCategoryType.separator,
linkIds: [SecurityPageName.rulesLanding],
},
];

View file

@ -12,7 +12,7 @@ import type {
SideNavCompProps,
} from '@kbn/core-chrome-browser/src/project_navigation';
import { ServerlessSecurityPluginStartDependencies } from '../../types';
import { SecuritySideNavigation } from './side_navigation';
import { SecuritySideNavigation } from './lazy';
import { KibanaServicesProvider } from '../../services';
export const getSecuritySideNavComponent = (

View file

@ -0,0 +1,16 @@
/*
* 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, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
const SecuritySideNavigationLazy = lazy(() => import('./side_navigation'));
export const SecuritySideNavigation = () => (
<Suspense fallback={<EuiLoadingSpinner size="m" />}>
<SecuritySideNavigationLazy />
</Suspense>
);

View file

@ -18,6 +18,7 @@ const mockUseSideNavSelectedId = useSideNavSelectedId as jest.Mock;
const mockSolutionSideNav = jest.fn((_props: unknown) => <div data-test-subj="solutionSideNav" />);
jest.mock('@kbn/security-solution-side-nav', () => ({
...jest.requireActual('@kbn/security-solution-side-nav'),
SolutionSideNav: (props: unknown) => mockSolutionSideNav(props),
}));
@ -34,12 +35,11 @@ const sideNavItems = [
href: '/alerts',
onClick: jest.fn(),
},
];
const sideNavFooterItems = [
{
id: SecurityPageName.administration,
label: 'Manage',
href: '/administration',
position: 'bottom',
onClick: jest.fn(),
},
];
@ -78,17 +78,6 @@ describe('SecuritySideNavigation', () => {
);
});
it('should pass footerItems props to the SolutionSideNav component', () => {
mockUseSideNavItems.mockReturnValueOnce(sideNavFooterItems);
render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
footerItems: sideNavFooterItems,
})
);
});
it('should selectedId the SolutionSideNav component', () => {
render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });

View file

@ -9,19 +9,15 @@ import React from 'react';
import { EuiLoadingSpinner, useEuiTheme } from '@elastic/eui';
import { SolutionNav } from '@kbn/shared-ux-page-solution-nav';
import { SolutionSideNav } from '@kbn/security-solution-side-nav';
import {
usePartitionFooterNavItems,
useSideNavItems,
useSideNavSelectedId,
} from '../../hooks/use_side_nav_items';
import { useSideNavItems, useSideNavSelectedId } from '../../hooks/use_side_nav_items';
import { CATEGORIES } from './categories';
export const SecuritySideNavigation: React.FC = () => {
const { euiTheme } = useEuiTheme();
const sideNavItems = useSideNavItems();
const selectedId = useSideNavSelectedId(sideNavItems);
const [items, footerItems] = usePartitionFooterNavItems(sideNavItems);
const items = useSideNavItems();
const selectedId = useSideNavSelectedId(items);
const isLoading = items.length === 0 && footerItems.length === 0;
const isLoading = items.length === 0;
return isLoading ? (
<EuiLoadingSpinner size="m" data-test-subj="sideNavLoader" />
@ -33,7 +29,7 @@ export const SecuritySideNavigation: React.FC = () => {
children={
<SolutionSideNav
items={items}
footerItems={footerItems}
categories={CATEGORIES}
selectedId={selectedId}
panelTopOffset={`calc(${euiTheme.size.l} * 2)`}
/>
@ -45,3 +41,6 @@ export const SecuritySideNavigation: React.FC = () => {
/>
);
};
// eslint-disable-next-line import/no-default-export
export default SecuritySideNavigation;

View file

@ -6,11 +6,7 @@
*/
import { renderHook } from '@testing-library/react-hooks';
import {
usePartitionFooterNavItems,
useSideNavItems,
useSideNavSelectedId,
} from './use_side_nav_items';
import { useSideNavItems, useSideNavSelectedId } from './use_side_nav_items';
import { BehaviorSubject } from 'rxjs';
import type { NavigationLink } from '@kbn/security-solution-plugin/public/common/links/types';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
@ -55,12 +51,14 @@ describe('useSideNavItems', () => {
{
id: SecurityPageName.alerts,
label: 'Alerts',
position: 'top',
href: expect.any(String),
onClick: expect.any(Function),
},
{
id: SecurityPageName.case,
label: 'Cases',
position: 'top',
href: expect.any(String),
onClick: expect.any(Function),
},
@ -82,6 +80,7 @@ describe('useSideNavItems', () => {
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
position: 'top',
href: expect.any(String),
onClick: expect.any(Function),
items: [
@ -101,6 +100,7 @@ describe('useSideNavItems', () => {
{
id: SecurityPageName.landing,
title: 'Get Started',
sideNavIcon: 'launch',
},
]);
const { result } = renderHook(useSideNavItems, { wrapper: KibanaServicesProvider });
@ -110,10 +110,10 @@ describe('useSideNavItems', () => {
expect(items).toEqual([
{
id: SecurityPageName.landing,
label: 'GET STARTED',
label: 'Get Started',
position: 'bottom',
href: expect.any(String),
onClick: expect.any(Function),
labelSize: 'xs',
iconType: 'launch',
appendSeparator: true,
},
@ -121,101 +121,6 @@ describe('useSideNavItems', () => {
});
});
describe('usePartitionFooterNavItems', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should partition main items only', async () => {
const mainInputItems = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '',
onClick: jest.fn(),
},
];
const { result } = renderHook(usePartitionFooterNavItems, {
initialProps: mainInputItems,
});
const [items, footerItems] = result.current;
expect(items).toEqual(mainInputItems);
expect(footerItems).toEqual([]);
});
it('should partition footer items only', async () => {
const footerInputItems = [
{
id: SecurityPageName.landing,
label: 'GET STARTED',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.administration,
label: 'Manage',
href: '',
onClick: jest.fn(),
},
];
const { result } = renderHook(usePartitionFooterNavItems, {
initialProps: footerInputItems,
});
const [items, footerItems] = result.current;
expect(items).toEqual([]);
expect(footerItems).toEqual(footerInputItems);
});
it('should partition main and footer items', async () => {
const mainInputItems = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '',
onClick: jest.fn(),
},
];
const footerInputItems = [
{
id: SecurityPageName.landing,
label: 'GET STARTED',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.administration,
label: 'Manage',
href: '',
onClick: jest.fn(),
},
];
const { result } = renderHook(usePartitionFooterNavItems, {
initialProps: [...mainInputItems, ...footerInputItems],
});
const [items, footerItems] = result.current;
expect(items).toEqual(mainInputItems);
expect(footerItems).toEqual(footerInputItems);
});
});
describe('useSideNavSelectedId', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -7,18 +7,62 @@
import { useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { partition } from 'lodash/fp';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
import type { SolutionSideNavItem } from '@kbn/security-solution-side-nav';
import { SolutionSideNavItem, SolutionSideNavItemPosition } from '@kbn/security-solution-side-nav';
import { useKibana } from '../services';
import { useGetLinkProps } from './use_link_props';
import { type GetLinkProps, useGetLinkProps } from './use_link_props';
import { useNavLinks } from './use_nav_links';
const isFooterNavItem = (id: string) =>
id === SecurityPageName.landing || id === SecurityPageName.administration;
type NavigationLink = ReturnType<typeof useNavLinks>[number];
const isBottomNavItem = (id: string) =>
id === SecurityPageName.landing || id === SecurityPageName.administration;
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({ deepLinkId: navLink.id }),
...(navLink.categories?.length && { categories: navLink.categories }),
...(navLink.links?.length && {
items: navLink.links.reduce<SolutionSideNavItem[]>((acc, current) => {
if (!current.disabled) {
acc.push({
id: current.id,
label: current.title,
iconType: current.sideNavIcon,
isBeta: current.isBeta,
betaOptions: current.betaOptions,
...getLinkProps({ deepLinkId: current.id }),
});
}
return acc;
}, []),
}),
});
/**
* Formats the get started navigation links into the shape expected by the `SolutionSideNav`
*/
const formatGetStartedLink = (
navLink: NavigationLink,
getLinkProps: GetLinkProps
): SolutionSideNavItem => ({
id: navLink.id,
label: navLink.title,
iconType: navLink.sideNavIcon,
position: SolutionSideNavItemPosition.bottom,
...getLinkProps({ deepLinkId: navLink.id }),
appendSeparator: true,
});
// DFS for the sideNavItem matching the current `pathname`, returns all item hierarchy when found
const findItemsByPath = (
sideNavItems: SolutionSideNavItem[],
@ -53,37 +97,9 @@ export const useSideNavItems = (): SolutionSideNavItem[] => {
return items;
}
if (isGetStartedNavItem(navLink.id)) {
items.push({
id: navLink.id,
label: navLink.title.toUpperCase(),
...getLinkProps({ deepLinkId: navLink.id }),
labelSize: 'xs',
iconType: 'launch',
appendSeparator: true,
});
items.push(formatGetStartedLink(navLink, getLinkProps));
} else {
// default sideNavItem formatting
items.push({
id: navLink.id,
label: navLink.title,
...getLinkProps({ deepLinkId: navLink.id }),
...(navLink.categories?.length && { categories: navLink.categories }),
...(navLink.links?.length && {
items: navLink.links.reduce<SolutionSideNavItem[]>((acc, current) => {
if (!current.disabled) {
acc.push({
id: current.id,
label: current.title,
description: current.description,
isBeta: current.isBeta,
betaOptions: current.betaOptions,
...getLinkProps({ deepLinkId: current.id }),
});
}
return acc;
}, []),
}),
});
items.push(formatLink(navLink, getLinkProps));
}
return items;
}, []),
@ -117,16 +133,6 @@ const useAddExternalSideNavItems = (securitySideNavItems: SolutionSideNavItem[])
return sideNavItemsWithExternals;
};
/**
* Partitions the sideNavItems into main and footer SideNavItems
* @param sideNavItems array for all SideNavItems
* @returns `[items, footerItems]` to be used in the side navigation component
*/
export const usePartitionFooterNavItems = (
sideNavItems: SolutionSideNavItem[]
): [SolutionSideNavItem[], SolutionSideNavItem[]] =>
useMemo(() => partition((item) => !isFooterNavItem(item.id), sideNavItems), [sideNavItems]);
/**
* Returns the selected item id, which is the root item in the links hierarchy
*/

View file

@ -29432,9 +29432,7 @@
"xpack.securitySolution.appLinks.actionHistoryDescription": "Affichez l'historique des actions de réponse effectuées sur les hôtes.",
"xpack.securitySolution.appLinks.alerts": "Alertes",
"xpack.securitySolution.appLinks.blocklistDescription": "Excluez les applications non souhaitées de l'exécution sur vos hôtes.",
"xpack.securitySolution.appLinks.category.cloudSecurityPosture": "SÉCURITÉ DU CLOUD",
"xpack.securitySolution.appLinks.category.endpoints": "POINTS DE TERMINAISON",
"xpack.securitySolution.appLinks.category.siem": "SIEM",
"xpack.securitySolution.appLinks.cloudDefendPoliciesDescription": "Sécurisez les charges de travail conteneurisées dans Kubernetes contre les attaques et les dérives grâce à des politiques d'exécution granulaires et flexibles.",
"xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarksDescription": "Affichez les règles de benchmark.",
"xpack.securitySolution.appLinks.cloudSecurityPostureDashboardDescription": "Un aperçu des résultats de toutes les intégrations CSP.",
@ -29458,7 +29456,6 @@
"xpack.securitySolution.appLinks.hosts.sessions": "Sessions",
"xpack.securitySolution.appLinks.hosts.uncommonProcesses": "Processus inhabituels",
"xpack.securitySolution.appLinks.kubernetesDescription": "Fournit des visualisations interactives de votre charge de travail et de vos données de session Kubernetes.",
"xpack.securitySolution.appLinks.manage": "Gérer",
"xpack.securitySolution.appLinks.network": "Réseau",
"xpack.securitySolution.appLinks.network.description": "Fournit des indicateurs d'activités clés sur une carte interactive ainsi que des tableaux d'événements qui permettent l'interaction avec la chronologie.",
"xpack.securitySolution.appLinks.network.dns": "DNS",
@ -32636,7 +32633,6 @@
"xpack.securitySolution.navigation.investigate": "Examiner",
"xpack.securitySolution.navigation.kubernetes": "Kubernetes",
"xpack.securitySolution.navigation.mainLabel": "Sécurité",
"xpack.securitySolution.navigation.manage": "Gérer",
"xpack.securitySolution.navigation.network": "Réseau",
"xpack.securitySolution.navigation.newRuleTitle": "Créer une nouvelle règle",
"xpack.securitySolution.navigation.overview": "Aperçu",

View file

@ -29413,9 +29413,7 @@
"xpack.securitySolution.appLinks.actionHistoryDescription": "ホストで実行された対応アクションの履歴を表示します。",
"xpack.securitySolution.appLinks.alerts": "アラート",
"xpack.securitySolution.appLinks.blocklistDescription": "不要なアプリケーションがホストで実行されないようにします。",
"xpack.securitySolution.appLinks.category.cloudSecurityPosture": "クラウドセキュリティ",
"xpack.securitySolution.appLinks.category.endpoints": "エンドポイント",
"xpack.securitySolution.appLinks.category.siem": "SIEM",
"xpack.securitySolution.appLinks.cloudDefendPoliciesDescription": "粒度の高い柔軟なランタイムポリシーによって、Kubernetesのコンテナーワークロードを攻撃とドリフトから保護します。",
"xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarksDescription": "ベンチマークルールを表示します。",
"xpack.securitySolution.appLinks.cloudSecurityPostureDashboardDescription": "すべてのCSP統合の結果の概要。",
@ -29439,7 +29437,6 @@
"xpack.securitySolution.appLinks.hosts.sessions": "セッション",
"xpack.securitySolution.appLinks.hosts.uncommonProcesses": "非共通プロセス",
"xpack.securitySolution.appLinks.kubernetesDescription": "Kubernetesワークロードおよびセッションデータのインタラクティブビジュアライゼーションを提供します。",
"xpack.securitySolution.appLinks.manage": "管理",
"xpack.securitySolution.appLinks.network": "ネットワーク",
"xpack.securitySolution.appLinks.network.description": "インタラクティブなマップで主要なアクティビティメトリックと、タイムラインと連携できるイベントテーブルを提供します。",
"xpack.securitySolution.appLinks.network.dns": "DNS",
@ -32617,7 +32614,6 @@
"xpack.securitySolution.navigation.investigate": "調査",
"xpack.securitySolution.navigation.kubernetes": "Kubernetes",
"xpack.securitySolution.navigation.mainLabel": "セキュリティ",
"xpack.securitySolution.navigation.manage": "管理",
"xpack.securitySolution.navigation.network": "ネットワーク",
"xpack.securitySolution.navigation.newRuleTitle": "新規ルールを作成",
"xpack.securitySolution.navigation.overview": "概要",

View file

@ -29409,9 +29409,7 @@
"xpack.securitySolution.appLinks.actionHistoryDescription": "查看在主机上执行的响应操作的历史记录。",
"xpack.securitySolution.appLinks.alerts": "告警",
"xpack.securitySolution.appLinks.blocklistDescription": "阻止不需要的应用程序在您的主机上运行。",
"xpack.securitySolution.appLinks.category.cloudSecurityPosture": "云安全",
"xpack.securitySolution.appLinks.category.endpoints": "终端",
"xpack.securitySolution.appLinks.category.siem": "SIEM",
"xpack.securitySolution.appLinks.cloudDefendPoliciesDescription": "通过细粒度、灵活的运行时策略保护 Kubernetes 中的容器工作负载,使其免于受到攻击和出现漂移。",
"xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarksDescription": "查看基准规则。",
"xpack.securitySolution.appLinks.cloudSecurityPostureDashboardDescription": "所有 CSP 集成中的结果概述。",
@ -29435,7 +29433,6 @@
"xpack.securitySolution.appLinks.hosts.sessions": "会话",
"xpack.securitySolution.appLinks.hosts.uncommonProcesses": "不常见进程",
"xpack.securitySolution.appLinks.kubernetesDescription": "提供 Kubernetes 工作负载和会话数据的交互式可视化。",
"xpack.securitySolution.appLinks.manage": "管理",
"xpack.securitySolution.appLinks.network": "网络",
"xpack.securitySolution.appLinks.network.description": "在交互式地图中提供关键活动指标以及启用与时间线进行交互的事件表。",
"xpack.securitySolution.appLinks.network.dns": "DNS",
@ -32613,7 +32610,6 @@
"xpack.securitySolution.navigation.investigate": "调查",
"xpack.securitySolution.navigation.kubernetes": "Kubernetes",
"xpack.securitySolution.navigation.mainLabel": "安全",
"xpack.securitySolution.navigation.manage": "管理",
"xpack.securitySolution.navigation.network": "网络",
"xpack.securitySolution.navigation.newRuleTitle": "创建新规则",
"xpack.securitySolution.navigation.overview": "概览",