[AI4DSOC] Add side navigation callout (#217652)

This commit is contained in:
Tomasz Ciecierski 2025-04-24 10:37:44 +02:00 committed by GitHub
parent f985d24d67
commit 29f1e55a0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 351 additions and 16 deletions

2
.github/CODEOWNERS vendored
View file

@ -2251,7 +2251,7 @@ x-pack/test/api_integration/apis/management/index_management/inference_endpoints
/x-pack/solutions/security/plugins/security_solution_ess/public/upselling/pages/attack_discovery @elastic/security-generative-ai
/x-pack/test/security_solution_cypress/cypress/e2e/automatic_import @elastic/security-scalability
/x-pack/solutions/security/plugins/security_solution/public/configurations @elastic/security-generative-ai
/x-pack/solutions/security/plugins/security_solution_serverless/public/navigation/ai_soc @elastic/security-generative-ai @elastic/security-threat-hunting-explore
/x-pack/solutions/security/plugins/security_solution_serverless/public/navigation/ai_soc @elastic/security-solution @elastic/security-threat-hunting-explore
# AI4DSOC in Security Solution

View file

@ -8,8 +8,12 @@
*/
import { createLocation } from 'history';
import type { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser/src';
import { flattenNav, findActiveNodes } from './utils';
import type {
ChromeNavLink,
ChromeProjectNavigationNode,
NavigationTreeDefinition,
} from '@kbn/core-chrome-browser/src';
import { flattenNav, findActiveNodes, parseNavigationTree } from './utils';
const getDeepLink = (id: string, path: string, title = ''): ChromeNavLink => ({
id,
@ -86,6 +90,150 @@ describe('flattenNav', () => {
});
});
describe('parseNavigationTree', () => {
// Mock dependencies for parseNavigationTree
const mockDeps = {
deepLinks: {},
cloudLinks: {},
};
it('should parse a navigation tree with body, footer, and callout sections', () => {
const navigationTreeDef: NavigationTreeDefinition = {
body: [
{
type: 'navGroup',
id: 'test_group',
title: 'Test Group',
children: [
{
id: 'test_item',
title: 'Test Item',
},
],
},
],
footer: [
{
type: 'navItem',
id: 'footer_item',
title: 'Footer Item',
},
],
callout: [
{
type: 'navGroup',
id: 'callout_group',
title: 'Callout Group',
children: [
{
id: 'callout_item',
title: 'Callout Item',
},
],
},
],
};
const result = parseNavigationTree('es', navigationTreeDef, mockDeps);
// Verify the result contains all sections
expect(result.navigationTreeUI.body).toHaveLength(1);
expect(result.navigationTreeUI.footer).toHaveLength(1);
expect(result.navigationTreeUI.callout).toHaveLength(1);
// Verify the callout section was parsed correctly
const calloutGroup = result.navigationTreeUI.callout?.[0] as any;
expect(calloutGroup).toBeDefined();
expect(calloutGroup.id).toBe('callout_group');
expect(calloutGroup.children).toHaveLength(1);
expect(calloutGroup.children[0].id).toBe('callout_item');
});
it('should handle a navigation tree with only body and callout sections', () => {
const navigationTreeDef: NavigationTreeDefinition = {
body: [
{
type: 'navItem',
id: 'body_item',
title: 'Body Item',
},
],
callout: [
{
type: 'navGroup',
id: 'callout_group',
title: 'Callout Group',
children: [
{
id: 'callout_item',
title: 'Callout Item',
},
],
},
],
};
const result = parseNavigationTree('es', navigationTreeDef, mockDeps);
// Verify the result contains body and callout sections but not footer
expect(result.navigationTreeUI.body).toHaveLength(1);
expect(result.navigationTreeUI.footer).toBeUndefined();
expect(result.navigationTreeUI.callout).toHaveLength(1);
});
it('should handle a navigation tree with only body and footer sections (no callout)', () => {
const navigationTreeDef: NavigationTreeDefinition = {
body: [
{
type: 'navItem',
id: 'body_item',
title: 'Body Item',
},
],
footer: [
{
type: 'navItem',
id: 'footer_item',
title: 'Footer Item',
},
],
};
const result = parseNavigationTree('es', navigationTreeDef, mockDeps);
// Verify the result contains body and footer sections but not callout
expect(result.navigationTreeUI.body).toHaveLength(1);
expect(result.navigationTreeUI.footer).toHaveLength(1);
expect(result.navigationTreeUI.callout).toBeUndefined();
});
it('should handle a navigation tree with only a callout section', () => {
const navigationTreeDef: NavigationTreeDefinition = {
body: [],
callout: [
{
type: 'navGroup',
id: 'callout_group',
title: 'Callout Group',
children: [
{
id: 'callout_item',
title: 'Callout Item',
},
],
},
],
};
const result = parseNavigationTree('es', navigationTreeDef, mockDeps);
// Verify the result contains only the callout section
expect(result.navigationTreeUI.body).toHaveLength(0);
expect(result.navigationTreeUI.footer).toBeUndefined();
expect(result.navigationTreeUI.callout).toHaveLength(1);
});
});
describe('findActiveNodes', () => {
test('should find the active node', () => {
const flattendNavTree: Record<string, ChromeProjectNavigationNode> = {

View file

@ -26,6 +26,7 @@ import type {
} from '@kbn/core-chrome-browser/src';
import type { Location } from 'history';
import type { MouseEventHandler } from 'react';
import { SideNavigationSection } from '@kbn/core-chrome-browser/src/project_navigation';
import { getPresets } from './navigation_presets';
const wrapIdx = (index: number): string => `[${index}]`;
@ -419,7 +420,7 @@ export const parseNavigationTree = (
const onNodeInitiated = (
navNode: ChromeProjectNavigationNode | RecentlyAccessedDefinition | null,
section: 'body' | 'footer' = 'body'
section: SideNavigationSection = 'body'
) => {
if (navNode) {
if (!isRecentlyAccessedDefinition(navNode)) {
@ -437,7 +438,7 @@ export const parseNavigationTree = (
const parseNodesArray = (
nodes?: RootNavigationItemDefinition[],
section: 'body' | 'footer' = 'body',
section: SideNavigationSection = 'body',
startIndex = 0
): void => {
if (!nodes) return;
@ -450,6 +451,7 @@ export const parseNavigationTree = (
parseNodesArray(navigationTreeDef.body, 'body');
parseNodesArray(navigationTreeDef.footer, 'footer', navigationTreeDef.body?.length ?? 0);
parseNodesArray(navigationTreeDef.callout, 'callout', navigationTreeDef.body?.length ?? 0);
return { navigationTree, navigationTreeUI };
};

View file

@ -386,7 +386,7 @@ export type RootNavigationItemDefinition<
/**
* @public
*
* Definition for the complete navigation tree, including body and footer
* Definition for the complete navigation tree, including body, callout, and footer
*/
export interface NavigationTreeDefinition<
LinkId extends AppDeepLinkId = AppDeepLinkId,
@ -403,8 +403,15 @@ export interface NavigationTreeDefinition<
* or "group" items.
* */
footer?: Array<RootNavigationItemDefinition<LinkId, Id, ChildrenId>>;
/**
* Special callout section displayed between the body and footer.
* Typically used for promotional or informational content.
* */
callout?: Array<RootNavigationItemDefinition<LinkId, Id, ChildrenId>>;
}
export type SideNavigationSection = keyof NavigationTreeDefinition;
/**
* @public
*
@ -417,6 +424,7 @@ export interface NavigationTreeDefinitionUI {
id: SolutionId;
body: Array<ChromeProjectNavigationNode | RecentlyAccessedDefinition>;
footer?: Array<ChromeProjectNavigationNode | RecentlyAccessedDefinition>;
callout?: Array<ChromeProjectNavigationNode | RecentlyAccessedDefinition>;
}
/**

View file

@ -96,6 +96,11 @@ const NavigationComp: FC<Props> = ({ navigationTree$, dataTestSubj }) => {
</EuiFlexItem>
</EuiFlexGroup>
)}
{navigationTree.callout && (
<EuiFlexGroup direction="column">
<EuiFlexItem>{renderNodes(navigationTree.callout)}</EuiFlexItem>
</EuiFlexGroup>
)}
{/* Footer */}
{navigationTree.footer && (
<EuiCollapsibleNavBeta.Footer>

View file

@ -40,6 +40,7 @@ export const useBeaconSize = (iconSize: AssistantBeaconProps['size'] = 'xxl') =>
export const useStyles = ({
backgroundColor = 'body',
size: iconSize = 'xxl',
ringsColor,
}: AssistantBeaconProps) => {
const {
euiTheme: { colors },
@ -132,7 +133,7 @@ export const useStyles = ({
top: 0;
left: 0;
z-index: 0;
border: 1px solid ${colors.primary};
border: 1px solid ${ringsColor ?? colors.primary};
border-radius: 50%;
animation: 4s cubic-bezier(0.42, 0, 0.37, 1) 0.5s infinite normal none running;
}

View file

@ -26,13 +26,22 @@ export interface AssistantBeaconProps {
* Size of the beacon.
*/
size?: Size;
/**
* Color of the rings around the icon.
*/
ringsColor?: string;
}
/**
* An AI Assistant icon with a pulsing ring around it, used in "hero" areas promoting functionality or prompting interaction.
*/
export const AssistantBeacon = ({ backgroundColor, size = 'xxl' }: AssistantBeaconProps) => {
const { root, rings } = useStyles({ backgroundColor, size });
export const AssistantBeacon = ({
backgroundColor,
size = 'xxl',
ringsColor,
}: AssistantBeaconProps) => {
const { root, rings } = useStyles({ backgroundColor, size, ringsColor });
return (
<div css={root}>

View file

@ -730,6 +730,22 @@ Object {
"type": "navGroup",
},
],
"callout": Array [
Object {
"children": Array [
Object {
"id": "ai_soc_callout",
"renderItem": [Function],
"title": "",
},
],
"defaultIsCollapsed": false,
"id": "calloutGroup",
"isCollapsible": false,
"title": "",
"type": "navGroup",
},
],
"footer": Array [
Object {
"icon": "launch",

View file

@ -0,0 +1,40 @@
/*
* 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 { createAiSocNavigationTree$ } from './ai_soc_navigation';
describe('createAiSocNavigationTree$', () => {
it('includes callout section', (done) => {
const navigationTree$ = createAiSocNavigationTree$();
navigationTree$.subscribe((navigationTree) => {
expect(navigationTree.callout).toBeDefined();
expect(navigationTree.callout?.length).toBe(1);
// Type assertions to avoid TypeScript errors
const calloutGroup = navigationTree.callout?.[0] as {
id: string;
children: Array<{ id: string }>;
};
expect(calloutGroup.id).toBe('calloutGroup');
expect(calloutGroup.children[0].id).toBe('ai_soc_callout');
done();
});
});
it('includes body and footer sections', (done) => {
const navigationTree$ = createAiSocNavigationTree$();
navigationTree$.subscribe((navigationTree) => {
expect(navigationTree.body).toBeDefined();
expect(navigationTree.body.length).toBeGreaterThan(0);
expect(navigationTree.footer).toBeDefined();
expect(navigationTree.footer?.length).toBeGreaterThan(0);
done();
});
});
});

View file

@ -8,20 +8,16 @@
import * as Rx from 'rxjs';
import type { NavigationTreeDefinition } from '@kbn/core-chrome-browser';
import { i18n } from '@kbn/i18n';
import { SecurityPageName } from '@kbn/security-solution-navigation';
import { i18nStrings, securityLink } from '@kbn/security-solution-navigation/links';
import { type SecurityProductTypes } from '../../../common/config';
import { ProductLine } from '../../../common/product';
import { createStackManagementNavigationTree } from '../stack_management_navigation';
import { AiForTheSocIcon } from './icons';
const SOLUTION_NAME = i18n.translate(
'xpack.securitySolutionServerless.socNavLinks.projectType.title',
{ defaultMessage: 'AI for SOC' }
);
import { createStackManagementNavigationTree } from '../stack_management_navigation';
import { SOLUTION_NAME } from './translations';
import { AiSocCallout } from './callout';
export const shouldUseAINavigation = (productTypes: SecurityProductTypes) =>
productTypes.some((productType) => productType.product_line === ProductLine.aiSoc);
@ -91,6 +87,22 @@ export const createAiSocNavigationTree$ = (): Rx.Observable<NavigationTreeDefini
],
},
],
callout: [
{
type: 'navGroup',
id: 'calloutGroup',
title: '',
defaultIsCollapsed: false,
isCollapsible: false,
children: [
{
id: 'ai_soc_callout',
title: '',
renderItem: AiSocCallout,
},
],
},
],
footer: [
{
type: 'navItem',

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { AiSocCallout } from './callout';
import { CALLOUT_TITLE, CALLOUT_DESCRIPTION, CALLOUT_ARIA_LABEL } from './translations';
describe('AiSocCallout', () => {
it('renders the callout with correct content', () => {
render(<AiSocCallout />);
// Check that the title and description are rendered
expect(screen.getByText(CALLOUT_TITLE)).toBeInTheDocument();
expect(screen.getByText(CALLOUT_DESCRIPTION)).toBeInTheDocument();
});
it('has the correct accessibility attributes', () => {
render(<AiSocCallout />);
// Check that the callout has the correct aria-label
const callout = screen.getByLabelText(CALLOUT_ARIA_LABEL);
expect(callout).toBeInTheDocument();
});
it('has the correct test subject', () => {
render(<AiSocCallout />);
// Check that the callout has the correct data-test-subj attribute
const callout = screen.getByTestId('ai-soc-callout');
expect(callout).toBeInTheDocument();
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCallOut, EuiText, EuiSpacer, useEuiTheme } from '@elastic/eui';
import { AssistantBeacon } from '@kbn/ai-assistant-icon';
import { CALLOUT_TITLE, CALLOUT_DESCRIPTION, CALLOUT_ARIA_LABEL } from './translations';
export const AiSocCallout: React.FC = () => {
const { euiTheme } = useEuiTheme();
return (
<EuiCallOut color="accent" data-test-subj="ai-soc-callout" aria-label={CALLOUT_ARIA_LABEL}>
<AssistantBeacon
size="m"
ringsColor={euiTheme.colors.vis.euiColorVis4}
backgroundColor="backgroundBaseAccent"
/>
<EuiText size="xs">
<h4>{CALLOUT_TITLE}</h4>
<EuiSpacer size="xs" />
<p>
<small>{CALLOUT_DESCRIPTION}</small>
</p>
</EuiText>
</EuiCallOut>
);
};

View file

@ -7,6 +7,10 @@
import { i18n } from '@kbn/i18n';
export const SOLUTION_NAME = i18n.translate(
'xpack.securitySolutionServerless.socNavLinks.projectType.title',
{ defaultMessage: 'AI for SOC' }
);
export const ALERT_SUMMARY = i18n.translate(
'xpack.securitySolutionServerless.navigation.aiSoc.alertSummary',
{
@ -19,3 +23,23 @@ export const CONFIGURATIONS = i18n.translate(
defaultMessage: 'Configurations',
}
);
export const CALLOUT_TITLE = i18n.translate(
'xpack.securitySolutionServerless.navigation.aiSoc.callout.title',
{
defaultMessage: 'Powered by Elastic AI',
}
);
export const CALLOUT_DESCRIPTION = i18n.translate(
'xpack.securitySolutionServerless.navigation.aiSoc.callout.description',
{
defaultMessage: 'Empowering SOCs for faster threat detection, investigation, and response',
}
);
export const CALLOUT_ARIA_LABEL = i18n.translate(
'xpack.securitySolutionServerless.navigation.aiSoc.callout.ariaLabel',
{
defaultMessage: 'Information about Elastic AI for SOC',
}
);

View file

@ -48,5 +48,6 @@
"@kbn/cloud-security-posture-common",
"@kbn/dev-utils",
"@kbn/inference-common",
"@kbn/ai-assistant-icon",
]
}