mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[AI4DSOC] Add side navigation callout (#217652)
This commit is contained in:
parent
f985d24d67
commit
29f1e55a0b
14 changed files with 351 additions and 16 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -48,5 +48,6 @@
|
|||
"@kbn/cloud-security-posture-common",
|
||||
"@kbn/dev-utils",
|
||||
"@kbn/inference-common",
|
||||
"@kbn/ai-assistant-icon",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue