[SecuritySolution] Project breadcrumbs (#160784)

## Summary

- Integrate new chrome project breadcrumbs API and "navigation tree" API
on `serverless_plugin`.
  - test with: `yarn serverless-security`
- Ess implementation migrated to `ess_security` plugin, everything
should work the same way.

Project breadcrumbs implementation
https://github.com/elastic/kibana/pull/160252


![screenshot](d6533184-1bc8-4596-a3e1-4d815984f654)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2023-07-03 18:13:35 +02:00 committed by GitHub
parent a81287f10c
commit 44c7091507
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 1266 additions and 1031 deletions

View file

@ -35,7 +35,8 @@
"home",
"taskManager",
"usageCollection",
"spaces"
"spaces",
"serverless",
],
"requiredBundles": [],
"extraPublicDirs": [

View file

@ -13,7 +13,7 @@ import {
} from '../kibana_react.mock';
export const KibanaServices = {
get: jest.fn(),
get: jest.fn(() => ({})),
getKibanaVersion: jest.fn(() => '8.0.0'),
getConfig: jest.fn(() => null),
};

View file

@ -7,8 +7,10 @@
import type { CoreStart } from '@kbn/core/public';
import type { CasesUiConfigType } from '../../../../common/ui/types';
import type { CasesPluginStart } from '../../../types';
type GlobalServices = Pick<CoreStart, 'application' | 'http' | 'theme'>;
type GlobalServices = Pick<CoreStart, 'application' | 'http' | 'theme'> &
Pick<CasesPluginStart, 'serverless'>;
export class KibanaServices {
private static kibanaVersion?: string;
@ -19,13 +21,14 @@ export class KibanaServices {
application,
config,
http,
serverless,
kibanaVersion,
theme,
}: GlobalServices & {
kibanaVersion: string;
config: CasesUiConfigType;
}) {
this.services = { application, http, theme };
this.services = { application, http, theme, serverless };
this.kibanaVersion = kibanaVersion;
this.config = config;
}

View file

@ -20,6 +20,7 @@ import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get
import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock';
import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles';
jest.mock('../../common/lib/kibana');
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/use_get_action_license', () => {
return {

View file

@ -33,6 +33,7 @@ import { CreateCase } from '.';
import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors';
import { useGetTags } from '../../containers/use_get_tags';
jest.mock('../../common/lib/kibana');
jest.mock('../../containers/api');
jest.mock('../../containers/user_profiles/api');
jest.mock('../../containers/use_get_tags');

View file

@ -14,11 +14,19 @@ import { CasesDeepLinkId } from '../../common/navigation';
const mockSetBreadcrumbs = jest.fn();
const mockSetTitle = jest.fn();
const mockSetServerlessBreadcrumbs = jest.fn();
const mockGetKibanaServices = jest.fn((): unknown => ({
serverless: { setBreadcrumbs: mockSetServerlessBreadcrumbs },
}));
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
KibanaServices: {
...originalModule.KibanaServices,
get: () => mockGetKibanaServices(),
},
useNavigation: jest.fn().mockReturnValue({
getAppUrl: jest.fn((params?: { deepLinkId: string }) => params?.deepLinkId ?? '/test'),
}),
@ -50,12 +58,19 @@ describe('useCasesBreadcrumbs', () => {
{ href: '/test', onClick: expect.any(Function), text: 'Test' },
{ text: 'Cases' },
]);
expect(mockSetServerlessBreadcrumbs).toHaveBeenCalledWith([]);
});
it('should sets the cases title', () => {
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.cases), { wrapper });
expect(mockSetTitle).toHaveBeenCalledWith(['Cases', 'Test']);
});
it('should not set serverless breadcrumbs in ess', () => {
mockGetKibanaServices.mockReturnValueOnce({ serverless: undefined });
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.cases), { wrapper });
expect(mockSetServerlessBreadcrumbs).not.toHaveBeenCalled();
});
});
describe('set create_case breadcrumbs', () => {
@ -66,12 +81,19 @@ describe('useCasesBreadcrumbs', () => {
{ href: CasesDeepLinkId.cases, onClick: expect.any(Function), text: 'Cases' },
{ text: 'Create' },
]);
expect(mockSetServerlessBreadcrumbs).toHaveBeenCalledWith([]);
});
it('should sets the cases title', () => {
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.casesCreate), { wrapper });
expect(mockSetTitle).toHaveBeenCalledWith(['Create', 'Cases', 'Test']);
});
it('should not set serverless breadcrumbs in ess', () => {
mockGetKibanaServices.mockReturnValueOnce({ serverless: undefined });
renderHook(() => useCasesBreadcrumbs(CasesDeepLinkId.casesCreate), { wrapper });
expect(mockSetServerlessBreadcrumbs).not.toHaveBeenCalled();
});
});
describe('set case_view breadcrumbs', () => {
@ -83,11 +105,18 @@ describe('useCasesBreadcrumbs', () => {
{ href: CasesDeepLinkId.cases, onClick: expect.any(Function), text: 'Cases' },
{ text: title },
]);
expect(mockSetServerlessBreadcrumbs).toHaveBeenCalledWith([{ text: title }]);
});
it('should sets the cases title', () => {
renderHook(() => useCasesTitleBreadcrumbs(title), { wrapper });
expect(mockSetTitle).toHaveBeenCalledWith([title, 'Cases', 'Test']);
});
it('should not set serverless breadcrumbs in ess', () => {
mockGetKibanaServices.mockReturnValueOnce({ serverless: undefined });
renderHook(() => useCasesTitleBreadcrumbs(title), { wrapper });
expect(mockSetServerlessBreadcrumbs).not.toHaveBeenCalled();
});
});
});

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { useCallback, useEffect } from 'react';
import { useKibana, useNavigation } from '../../common/lib/kibana';
import { KibanaServices, useKibana, useNavigation } from '../../common/lib/kibana';
import type { ICasesDeepLinkId } from '../../common/navigation';
import { CasesDeepLinkId } from '../../common/navigation';
import { useCasesContext } from '../cases_context/use_cases_context';
@ -84,6 +84,7 @@ export const useCasesBreadcrumbs = (pageDeepLink: ICasesDeepLinkId) => {
]
: []),
]);
KibanaServices.get().serverless?.setBreadcrumbs([]);
}, [pageDeepLink, appTitle, getAppUrl, applyBreadcrumbs]);
};
@ -93,16 +94,18 @@ export const useCasesTitleBreadcrumbs = (caseTitle: string) => {
const applyBreadcrumbs = useApplyBreadcrumbs();
useEffect(() => {
const titleBreadcrumb: ChromeBreadcrumb = {
text: caseTitle,
};
const casesBreadcrumbs: ChromeBreadcrumb[] = [
{ text: appTitle, href: getAppUrl() },
{
text: casesBreadcrumbTitle[CasesDeepLinkId.cases],
href: getAppUrl({ deepLinkId: CasesDeepLinkId.cases }),
},
{
text: caseTitle,
},
titleBreadcrumb,
];
applyBreadcrumbs(casesBreadcrumbs);
KibanaServices.get().serverless?.setBreadcrumbs([titleBreadcrumb]);
}, [caseTitle, appTitle, getAppUrl, applyBreadcrumbs]);
};

View file

@ -25,6 +25,7 @@ import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { FilesSetup, FilesStart } from '@kbn/files-plugin/public';
import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public';
import type {
CasesBulkGetRequest,
@ -58,6 +59,7 @@ import type { PersistableStateAttachmentTypeRegistry } from './client/attachment
export interface CasesPluginSetup {
files: FilesSetup;
security: SecurityPluginSetup;
serverless?: ServerlessPluginSetup;
management: ManagementSetup;
home?: HomePublicPluginSetup;
}
@ -72,6 +74,7 @@ export interface CasesPluginStart {
licensing?: LicensingPluginStart;
savedObjectsManagement: SavedObjectsManagementPluginStart;
security: SecurityPluginStart;
serverless?: ServerlessPluginStart;
spaces?: SpacesPluginStart;
storage: Storage;
triggersActionsUi: TriggersActionsStart;

View file

@ -67,6 +67,7 @@
"@kbn/core-lifecycle-browser",
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/core-theme-browser",
"@kbn/serverless",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { emptyLastBreadcrumbUrl } from './breadcrumbs';
describe('emptyLastBreadcrumbUrl', () => {
it('should empty the URL and onClick function of the last breadcrumb', () => {
const breadcrumbs: ChromeBreadcrumb[] = [
{ text: 'Home', href: '/home', onClick: () => {} },
{ text: 'Breadcrumb 1', href: '/bc1', onClick: () => {} },
{ text: 'Last Breadcrumbs', href: '/last_bc', onClick: () => {} },
];
const expectedBreadcrumbs = [
{ text: 'Home', href: '/home', onClick: breadcrumbs[0].onClick },
{ text: 'Breadcrumb 1', href: '/bc1', onClick: breadcrumbs[1].onClick },
{ text: 'Last Breadcrumbs', href: '', onClick: undefined },
];
expect(emptyLastBreadcrumbUrl(breadcrumbs)).toEqual(expectedBreadcrumbs);
});
it('should return the original breadcrumbs if the input is empty', () => {
const emptyBreadcrumbs: ChromeBreadcrumb[] = [];
expect(emptyLastBreadcrumbUrl(emptyBreadcrumbs)).toEqual(emptyBreadcrumbs);
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { PluginStart as SecuritySolutionPluginStart } from '@kbn/security-solution-plugin/public';
import { ChromeBreadcrumb, CoreStart } from '@kbn/core/public';
export const subscribeBreadcrumbs = (
securitySolution: SecuritySolutionPluginStart,
core: CoreStart
) => {
securitySolution.getBreadcrumbsNav$().subscribe((breadcrumbsNav) => {
const breadcrumbs = [...breadcrumbsNav.leading, ...breadcrumbsNav.trailing];
if (breadcrumbs.length > 0) {
core.chrome.setBreadcrumbs(emptyLastBreadcrumbUrl(breadcrumbs));
}
});
};
export const emptyLastBreadcrumbUrl = (breadcrumbs: ChromeBreadcrumb[]) => {
const lastBreadcrumb = breadcrumbs[breadcrumbs.length - 1];
if (lastBreadcrumb) {
return [...breadcrumbs.slice(0, -1), { ...lastBreadcrumb, href: '', onClick: undefined }];
}
return breadcrumbs;
};

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { subscribeBreadcrumbs } from './breadcrumbs';

View file

@ -1,26 +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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/ess_security/public/common'],
testMatch: ['<rootDir>/x-pack/plugins/ess_security/public/common/**/*.test.{js,mjs,ts,tsx}'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/ess_security/public/common',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/ess_security/public/common/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/ess_security/public/common/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/ess_security/public/common/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/ess_security/public/common/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/ess_security/public/common/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/ess_security/public/common/*.d.ts',
'!<rootDir>/x-pack/plugins/ess_security/public/common/*.config.ts',
'!<rootDir>/x-pack/plugins/ess_security/public/common/index.{js,ts,tsx}',
],
};

View file

@ -1,16 +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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/ess_security/public/get_started'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/ess_security/public/get_started',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/plugins/ess_security/public/get_started/**/*.{ts,tsx}'],
};

View file

@ -8,7 +8,7 @@ module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
/** all nested directories have their own Jest config file */
testMatch: ['<rootDir>/x-pack/plugins/ess_security/public/*.test.{js,mjs,ts,tsx}'],
testMatch: ['<rootDir>/x-pack/plugins/ess_security/public/**/*.test.{js,mjs,ts,tsx}'],
roots: ['<rootDir>/x-pack/plugins/ess_security/public'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/ess_security/public',
coverageReporters: ['text', 'html'],

View file

@ -6,6 +6,7 @@
*/
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { subscribeBreadcrumbs } from './breadcrumbs';
import { getSecurityGetStartedComponent } from './get_started';
import {
EssSecurityPluginSetup,
@ -26,8 +27,8 @@ export class EssSecurityPlugin
constructor() {}
public setup(
core: CoreSetup,
setupDeps: EssSecurityPluginSetupDependencies
_core: CoreSetup,
_setupDeps: EssSecurityPluginSetupDependencies
): EssSecurityPluginSetup {
return {};
}
@ -37,6 +38,8 @@ export class EssSecurityPlugin
startDeps: EssSecurityPluginStartDependencies
): EssSecurityPluginStart {
const { securitySolution } = startDeps;
subscribeBreadcrumbs(securitySolution, core);
securitySolution.setGetStartedPage(getSecurityGetStartedComponent(core, startDeps));
return {};

View file

@ -5,14 +5,14 @@
* 2.0.
*/
import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser';
import type { GetSecuritySolutionUrl } from '../common/components/link_to';
import type { RouteSpyState } from '../common/utils/route/types';
import type { GetTrailingBreadcrumbs } from '../common/components/navigation/breadcrumbs/types';
export const getTrailingBreadcrumbs = (
params: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => {
const breadcrumbs = [];
if (params.state?.ruleName) {

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { BehaviorSubject } from 'rxjs';
import type { BreadcrumbsNav } from './types';
// Used to update the breadcrumbsNav$ observable internally.
const breadcrumbsNavUpdater$ = new BehaviorSubject<BreadcrumbsNav>({
leading: [],
trailing: [],
});
// The observable can be exposed by the plugin contract.
export const breadcrumbsNav$ = breadcrumbsNavUpdater$.asObservable();
export const updateBreadcrumbsNav = (breadcrumbsNav: BreadcrumbsNav) => {
breadcrumbsNavUpdater$.next(breadcrumbsNav);
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export type { BreadcrumbsNav } from './types';
export { updateBreadcrumbsNav, breadcrumbsNav$ } from './breadcrumbs';

View file

@ -0,0 +1,13 @@
/*
* 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 { ChromeBreadcrumb } from '@kbn/core/public';
export interface BreadcrumbsNav {
trailing: ChromeBreadcrumb[];
leading: ChromeBreadcrumb[];
}

View file

@ -1,33 +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 { ChromeBreadcrumb } from '@kbn/core/public';
import { SecurityPageName } from '../../../../app/types';
import { APP_NAME } from '../../../../../common/constants';
import { getAppLandingUrl } from '../../link_to/redirect_to_landing';
import type { GetSecuritySolutionUrl } from '../../link_to';
import { getAncestorLinksInfo } from '../../../links';
export const getLeadingBreadcrumbsForSecurityPage = (
pageName: SecurityPageName,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): [ChromeBreadcrumb, ...ChromeBreadcrumb[]] => {
const landingPath = getSecuritySolutionUrl({ deepLinkId: SecurityPageName.landing });
const siemRootBreadcrumb: ChromeBreadcrumb = {
text: APP_NAME,
href: getAppLandingUrl(landingPath),
};
const breadcrumbs: ChromeBreadcrumb[] = getAncestorLinksInfo(pageName).map(({ title, id }) => ({
text: title,
href: getSecuritySolutionUrl({ deepLinkId: id }),
}));
return [siemRootBreadcrumb, ...breadcrumbs];
};

View file

@ -1,549 +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 '../../../mock/match_media';
import { encodeIpv6 } from '../../../lib/helpers';
import { getBreadcrumbsForRoute, useBreadcrumbs } from '.';
import { HostsTableType } from '../../../../explore/hosts/store/model';
import type { RouteSpyState } from '../../../utils/route/types';
import { NetworkRouteType } from '../../../../explore/network/pages/navigation/types';
import { AdministrationSubTab } from '../../../../management/types';
import { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../mock';
import type { GetSecuritySolutionUrl } from '../../link_to';
import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants';
import { links } from '../../../links/app_links';
import { updateAppLinks } from '../../../links';
import { allowedExperimentalValues } from '../../../../../common/experimental_features';
import { AlertDetailRouteType } from '../../../../detections/pages/alert_details/types';
import { UsersTableType } from '../../../../explore/users/store/model';
import { UpsellingService } from '../../../lib/upsellings';
const mockUseRouteSpy = jest.fn();
jest.mock('../../../utils/route/use_route_spy', () => ({
useRouteSpy: () => mockUseRouteSpy(),
}));
const getMockObject = (
pageName: SecurityPageName,
pathName: string,
detailName: string | undefined
): RouteSpyState => {
switch (pageName) {
case SecurityPageName.hosts:
return {
detailName,
pageName,
pathName,
search: '',
tabName: HostsTableType.authentications,
};
case SecurityPageName.users:
return {
detailName,
pageName,
pathName,
search: '',
tabName: UsersTableType.allUsers,
};
case SecurityPageName.network:
return {
detailName,
pageName,
pathName,
search: '',
tabName: NetworkRouteType.flows,
};
case SecurityPageName.administration:
return {
detailName,
pageName,
pathName,
search: '',
tabName: AdministrationSubTab.endpoints,
};
case SecurityPageName.alerts:
return {
detailName,
pageName,
pathName,
search: '',
tabName: AlertDetailRouteType.summary,
};
default:
return {
detailName,
pageName,
pathName,
search: '',
} as RouteSpyState;
}
};
// The string returned is different from what getSecuritySolutionUrl returns, but does not matter for the purposes of this test.
const getSecuritySolutionUrl: GetSecuritySolutionUrl = ({
deepLinkId,
path,
}: {
deepLinkId?: string;
path?: string;
absolute?: boolean;
}) => `${APP_UI_ID}${deepLinkId ? `/${deepLinkId}` : ''}${path ?? ''}`;
const mockSetBreadcrumbs = jest.fn();
jest.mock('../../../lib/kibana/kibana_react', () => {
return {
useKibana: () => ({
services: {
chrome: {
setBreadcrumbs: mockSetBreadcrumbs,
},
application: {
navigateToApp: jest.fn(),
getUrlForApp: (appId: string, options?: { path?: string; deepLinkId?: boolean }) =>
`${appId}/${options?.deepLinkId ?? ''}${options?.path ?? ''}`,
},
},
}),
};
});
const hostName = 'siem-kibana';
const ipv4 = '192.0.2.255';
const ipv6 = '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff';
const ipv6Encoded = encodeIpv6(ipv6);
const securityBreadcrumb = {
href: 'securitySolutionUI/get_started',
text: 'Security',
};
const hostsBreadcrumb = {
href: 'securitySolutionUI/hosts',
text: 'Hosts',
};
const networkBreadcrumb = {
text: 'Network',
href: 'securitySolutionUI/network',
};
const exploreBreadcrumb = {
href: 'securitySolutionUI/explore',
text: 'Explore',
};
const rulesLandingBreadcrumb = {
text: 'Rules',
href: 'securitySolutionUI/rules-landing',
};
const rulesBreadcrumb = {
text: 'SIEM Rules',
href: 'securitySolutionUI/rules',
};
const exceptionsBreadcrumb = {
text: 'Shared Exception Lists',
href: 'securitySolutionUI/exceptions',
};
const settingsBreadcrumb = {
text: 'Settings',
href: 'securitySolutionUI/administration',
};
describe('Navigation Breadcrumbs', () => {
beforeAll(() => {
updateAppLinks(links, {
experimentalFeatures: allowedExperimentalValues,
capabilities: {
navLinks: {},
management: {},
catalogue: {},
actions: { show: true, crud: true },
siem: {
show: true,
crud: true,
},
},
upselling: new UpsellingService(),
});
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('getBreadcrumbsForRoute', () => {
it('should return Overview breadcrumbs when supplied overview pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.overview, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
{
href: 'securitySolutionUI/dashboards',
text: 'Dashboards',
},
{
href: '',
text: 'Overview',
},
]);
});
it('should return Host breadcrumbs when supplied hosts pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.hosts, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
exploreBreadcrumb,
hostsBreadcrumb,
{
href: '',
text: 'Authentications',
},
]);
});
it('should return Network breadcrumbs when supplied network pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.network, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
exploreBreadcrumb,
networkBreadcrumb,
{
text: 'Flows',
href: '',
},
]);
});
it('should return Timelines breadcrumbs when supplied timelines pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.timelines, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
{
text: 'Timelines',
href: '',
},
]);
});
it('should return Host Details breadcrumbs when supplied a pathname with hostName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.hosts, '/', hostName),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
exploreBreadcrumb,
hostsBreadcrumb,
{
text: 'siem-kibana',
href: 'securitySolutionUI/hosts/name/siem-kibana',
},
{ text: 'Authentications', href: '' },
]);
});
it('should return IP Details breadcrumbs when supplied pathname with ipv4', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.network, '/', ipv4),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
exploreBreadcrumb,
networkBreadcrumb,
{
text: ipv4,
href: `securitySolutionUI/network/ip/${ipv4}/source/flows`,
},
{ text: 'Flows', href: '' },
]);
});
it('should return IP Details breadcrumbs when supplied pathname with ipv6', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.network, '/', ipv6Encoded),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
exploreBreadcrumb,
networkBreadcrumb,
{
text: ipv6,
href: `securitySolutionUI/network/ip/${ipv6Encoded}/source/flows`,
},
{ text: 'Flows', href: '' },
]);
});
it('should return Alerts breadcrumbs when supplied alerts pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.alerts, '/alerts', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
{
text: 'Alerts',
href: 'securitySolutionUI/alerts',
},
{
text: 'Summary',
href: '',
},
]);
});
it('should return Exceptions breadcrumbs when supplied exceptions pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.exceptions, '/exceptions', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
rulesLandingBreadcrumb,
{
text: 'Shared Exception Lists',
href: '',
},
]);
});
it('should return Rules breadcrumbs when supplied rules pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.rules, '/rules', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
rulesLandingBreadcrumb,
{
...rulesBreadcrumb,
href: '',
},
]);
});
it('should return Rules breadcrumbs when supplied rules Creation pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.rules, '/rules/create', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
rulesLandingBreadcrumb,
rulesBreadcrumb,
{
text: 'Create',
href: '',
},
]);
});
it('should return Rules breadcrumbs when supplied rules Details pageName', () => {
const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3';
const mockRuleName = 'ALERT_RULE_NAME';
const breadcrumbs = getBreadcrumbsForRoute(
{
...getMockObject(SecurityPageName.rules, `/rules/id/${mockDetailName}`, undefined),
detailName: mockDetailName,
state: {
ruleName: mockRuleName,
},
},
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
rulesLandingBreadcrumb,
rulesBreadcrumb,
{
text: mockRuleName,
href: `securitySolutionUI/rules/id/${mockDetailName}`,
},
{
text: 'Deleted rule',
href: '',
},
]);
});
it('should return Rules breadcrumbs when supplied rules Edit pageName', () => {
const mockDetailName = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3';
const mockRuleName = 'ALERT_RULE_NAME';
const breadcrumbs = getBreadcrumbsForRoute(
{
...getMockObject(SecurityPageName.rules, `/rules/id/${mockDetailName}/edit`, undefined),
detailName: mockDetailName,
state: {
ruleName: mockRuleName,
},
},
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
rulesLandingBreadcrumb,
rulesBreadcrumb,
{
text: 'ALERT_RULE_NAME',
href: `securitySolutionUI/rules/id/${mockDetailName}`,
},
{
text: 'Edit',
href: '',
},
]);
});
it('should return null breadcrumbs when supplied Cases pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.case, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual(null);
});
it('should return null breadcrumbs when supplied Cases details pageName', () => {
const sampleCase = {
id: 'my-case-id',
name: 'Case name',
};
const breadcrumbs = getBreadcrumbsForRoute(
{
...getMockObject(SecurityPageName.case, `/${sampleCase.id}`, sampleCase.id),
state: { caseTitle: sampleCase.name },
},
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual(null);
});
it('should return Endpoints breadcrumbs when supplied endpoints pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.endpoints, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
settingsBreadcrumb,
{
text: 'Endpoints',
href: '',
},
]);
});
it('should return Admin breadcrumbs when supplied admin pageName', () => {
const breadcrumbs = getBreadcrumbsForRoute(
getMockObject(SecurityPageName.administration, '/', undefined),
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
{
...settingsBreadcrumb,
href: '',
},
]);
});
it('should return Exceptions breadcrumbs when supplied exception Details pageName', () => {
const mockListName = 'new shared list';
const breadcrumbs = getBreadcrumbsForRoute(
{
...getMockObject(
SecurityPageName.exceptions,
`/exceptions/details/${mockListName}`,
undefined
),
state: {
listName: mockListName,
},
},
getSecuritySolutionUrl
);
expect(breadcrumbs).toEqual([
securityBreadcrumb,
rulesLandingBreadcrumb,
exceptionsBreadcrumb,
{
text: mockListName,
href: ``,
},
]);
});
});
describe('setBreadcrumbs()', () => {
it('should call chrome breadcrumb service with correct breadcrumbs', () => {
mockUseRouteSpy.mockReturnValueOnce([getMockObject(SecurityPageName.hosts, '/', hostName)]);
renderHook(useBreadcrumbs, {
initialProps: { isEnabled: true },
wrapper: TestProviders,
});
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
expect.objectContaining({
text: 'Security',
href: 'securitySolutionUI/get_started',
onClick: expect.any(Function),
}),
expect.objectContaining({
text: 'Explore',
href: 'securitySolutionUI/explore',
onClick: expect.any(Function),
}),
expect.objectContaining({
text: 'Hosts',
href: 'securitySolutionUI/hosts',
onClick: expect.any(Function),
}),
expect.objectContaining({
text: 'siem-kibana',
href: 'securitySolutionUI/hosts/name/siem-kibana',
onClick: expect.any(Function),
}),
{
text: 'Authentications',
href: '',
},
]);
});
it('should not call chrome breadcrumb service when not enabled', () => {
mockUseRouteSpy.mockReturnValueOnce([getMockObject(SecurityPageName.hosts, '/', hostName)]);
renderHook(useBreadcrumbs, {
initialProps: { isEnabled: false },
wrapper: TestProviders,
});
expect(mockSetBreadcrumbs).not.toHaveBeenCalled();
});
});
});

View file

@ -4,136 +4,4 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import { last } from 'lodash/fp';
import { useDispatch } from 'react-redux';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { getTrailingBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../../explore/hosts/pages/details/utils';
import { getTrailingBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../explore/network/pages/details/utils';
import { getTrailingBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils';
import { getTrailingBreadcrumbs as geExceptionsBreadcrumbs } from '../../../../exceptions/utils/pages.utils';
import { getTrailingBreadcrumbs as getCSPBreadcrumbs } from '../../../../cloud_security_posture/breadcrumbs';
import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../explore/users/pages/details/utils';
import { getTrailingBreadcrumbs as getKubernetesBreadcrumbs } from '../../../../kubernetes/pages/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getAlertDetailBreadcrumbs } from '../../../../detections/pages/alert_details/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getDashboardBreadcrumbs } from '../../../../dashboards/pages/utils';
import { SecurityPageName } from '../../../../app/types';
import type { RouteSpyState } from '../../../utils/route/types';
import { timelineActions } from '../../../../timelines/store/timeline';
import { TimelineId } from '../../../../../common/types/timeline';
import { getLeadingBreadcrumbsForSecurityPage } from './get_breadcrumbs_for_page';
import type { GetSecuritySolutionUrl } from '../../link_to';
import { useGetSecuritySolutionUrl } from '../../link_to';
import { TELEMETRY_EVENT, track } from '../../../lib/telemetry';
import { useKibana } from '../../../lib/kibana';
import { useRouteSpy } from '../../../utils/route/use_route_spy';
export const useBreadcrumbs = ({ isEnabled }: { isEnabled: boolean }) => {
const dispatch = useDispatch();
const [routeProps] = useRouteSpy();
const getSecuritySolutionUrl = useGetSecuritySolutionUrl();
const {
chrome: { setBreadcrumbs },
application: { navigateToUrl },
} = useKibana().services;
useEffect(() => {
if (!isEnabled) {
return;
}
const breadcrumbs = getBreadcrumbsForRoute(routeProps, getSecuritySolutionUrl);
if (!breadcrumbs) {
return;
}
setBreadcrumbs(
breadcrumbs.map((breadcrumb) => ({
...breadcrumb,
...(breadcrumb.href && !breadcrumb.onClick
? {
onClick: (ev) => {
ev.preventDefault();
const trackedPath = breadcrumb.href?.split('?')[0] ?? 'unknown';
track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.BREADCRUMB}${trackedPath}`);
dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false }));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
navigateToUrl(breadcrumb.href!);
},
}
: {}),
}))
);
}, [routeProps, isEnabled, dispatch, getSecuritySolutionUrl, setBreadcrumbs, navigateToUrl]);
};
export const getBreadcrumbsForRoute = (
spyState: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] | null => {
if (
!spyState?.pageName ||
// cases manages its own breadcrumbs, return null
spyState.pageName === SecurityPageName.case
) {
return null;
}
const leadingBreadcrumbs = getLeadingBreadcrumbsForSecurityPage(
spyState.pageName,
getSecuritySolutionUrl
);
return emptyLastBreadcrumbUrl([
...leadingBreadcrumbs,
...getTrailingBreadcrumbsForRoutes(spyState, getSecuritySolutionUrl),
]);
};
const getTrailingBreadcrumbsForRoutes = (
spyState: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
switch (spyState.pageName) {
case SecurityPageName.hosts:
return getHostDetailsBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.network:
return getIPDetailsBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.users:
return getUsersBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.rules:
case SecurityPageName.rulesAdd:
case SecurityPageName.rulesCreate:
return getDetectionRulesBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.exceptions:
return geExceptionsBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.kubernetes:
return getKubernetesBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.alerts:
return getAlertDetailBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.cloudSecurityPostureBenchmarks:
return getCSPBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.dashboards:
return getDashboardBreadcrumbs(spyState);
}
return [];
};
const emptyLastBreadcrumbUrl = (breadcrumbs: ChromeBreadcrumb[]) => {
const leadingBreadCrumbs = breadcrumbs.slice(0, -1);
const lastBreadcrumb = last(breadcrumbs);
if (lastBreadcrumb) {
return [
...leadingBreadCrumbs,
{
...lastBreadcrumb,
href: '',
},
];
}
return breadcrumbs;
};
export { useBreadcrumbsNav } from './use_breadcrumbs_nav';

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SecurityPageName } from '../../../../../common';
import type { GetTrailingBreadcrumbs } from './types';
import { getTrailingBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../../explore/hosts/pages/details/breadcrumbs';
import { getTrailingBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../explore/network/pages/details/breadcrumbs';
import { getTrailingBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/breadcrumbs';
import { getTrailingBreadcrumbs as geExceptionsBreadcrumbs } from '../../../../exceptions/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getCSPBreadcrumbs } from '../../../../cloud_security_posture/breadcrumbs';
import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../explore/users/pages/details/breadcrumbs';
import { getTrailingBreadcrumbs as getKubernetesBreadcrumbs } from '../../../../kubernetes/pages/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getAlertDetailBreadcrumbs } from '../../../../detections/pages/alert_details/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getDashboardBreadcrumbs } from '../../../../dashboards/pages/breadcrumbs';
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (
spyState,
getSecuritySolutionUrl
) => {
switch (spyState.pageName) {
case SecurityPageName.hosts:
return getHostDetailsBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.network:
return getIPDetailsBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.users:
return getUsersBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.rules:
case SecurityPageName.rulesAdd:
case SecurityPageName.rulesCreate:
return getDetectionRulesBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.exceptions:
return geExceptionsBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.kubernetes:
return getKubernetesBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.alerts:
return getAlertDetailBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.cloudSecurityPostureBenchmarks:
return getCSPBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.dashboards:
return getDashboardBreadcrumbs(spyState, getSecuritySolutionUrl);
}
return [];
};

View file

@ -6,13 +6,10 @@
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { RouteSpyState } from '../../common/utils/route/types';
import type { RouteSpyState } from '../../../utils/route/types';
import type { GetSecuritySolutionUrl } from '../../link_to';
export const getTrailingBreadcrumbs = (params: RouteSpyState): ChromeBreadcrumb[] => {
const breadcrumbName = params?.state?.dashboardName;
if (breadcrumbName) {
return [{ text: breadcrumbName }];
}
return [];
};
export type GetTrailingBreadcrumbs<T extends RouteSpyState = RouteSpyState> = (
spyState: T,
getSecuritySolutionUrl: GetSecuritySolutionUrl
) => ChromeBreadcrumb[];

View file

@ -0,0 +1,150 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { GetSecuritySolutionUrl } from '../../link_to';
import { SecurityPageName } from '../../../../../common/constants';
import type { LinkInfo, LinkItem } from '../../../links';
import { useBreadcrumbsNav } from './use_breadcrumbs_nav';
import type { BreadcrumbsNav } from '../../../breadcrumbs';
jest.mock('../../../lib/kibana');
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
const link1Id = 'link-1' as SecurityPageName;
const link2Id = 'link-2' as SecurityPageName;
const link3Id = 'link-3' as SecurityPageName;
const link4Id = 'link-4' as SecurityPageName;
const link5Id = 'link-5' as SecurityPageName;
const link1: LinkItem = { id: link1Id, title: 'link 1', path: '/link1' };
const link2: LinkItem = { id: link2Id, title: 'link 2', path: '/link2' };
const link3: LinkItem = { id: link3Id, title: 'link 3', path: '/link3' };
const link4: LinkItem = { id: link4Id, title: 'link 4', path: '/link4' };
const link5: LinkItem = { id: link5Id, title: 'link 5', path: '/link5' };
const ancestorsLinks = [link1, link2, link3];
const trailingLinks = [link4, link5];
const allLinks = [...ancestorsLinks, ...trailingLinks];
const mockSecuritySolutionUrl: GetSecuritySolutionUrl = jest.fn(
({ deepLinkId }: { deepLinkId: SecurityPageName }) =>
allLinks.find((link) => link.id === deepLinkId)?.path ?? deepLinkId
);
jest.mock('../../link_to', () => ({
useGetSecuritySolutionUrl: () => mockSecuritySolutionUrl,
}));
const mockUpdateBreadcrumbsNav = jest.fn((_param: BreadcrumbsNav) => {});
jest.mock('../../../breadcrumbs', () => ({
updateBreadcrumbsNav: (param: BreadcrumbsNav) => mockUpdateBreadcrumbsNav(param),
}));
const mockUseRouteSpy = jest.fn((): [{ pageName: string }] => [{ pageName: link1Id }]);
jest.mock('../../../utils/route/use_route_spy', () => ({
useRouteSpy: () => mockUseRouteSpy(),
}));
const mockGetAncestorLinks = jest.fn((_id: unknown): LinkInfo[] => ancestorsLinks);
jest.mock('../../../links', () => ({
...jest.requireActual('../../../links'),
getAncestorLinksInfo: (id: unknown) => mockGetAncestorLinks(id),
}));
const mockGetTrailingBreadcrumbs = jest.fn((): ChromeBreadcrumb[] =>
trailingLinks.map(({ title: text, path: href }) => ({ text, href }))
);
jest.mock('./trailing_breadcrumbs', () => ({
getTrailingBreadcrumbs: () => mockGetTrailingBreadcrumbs(),
}));
const landingBreadcrumb = {
href: 'get_started',
text: 'Security',
onClick: expect.any(Function),
};
describe('useBreadcrumbsNav', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should process breadcrumbs with current pageName', () => {
renderHook(useBreadcrumbsNav);
expect(mockGetAncestorLinks).toHaveBeenCalledWith(link1Id);
expect(mockGetTrailingBreadcrumbs).toHaveBeenCalledWith();
});
it('should not process breadcrumbs with empty pageName', () => {
mockUseRouteSpy.mockReturnValueOnce([{ pageName: '' }]);
renderHook(useBreadcrumbsNav);
expect(mockGetAncestorLinks).not.toHaveBeenCalled();
expect(mockGetTrailingBreadcrumbs).not.toHaveBeenCalledWith();
});
it('should not process breadcrumbs with cases pageName', () => {
mockUseRouteSpy.mockReturnValueOnce([{ pageName: SecurityPageName.case }]);
renderHook(useBreadcrumbsNav);
expect(mockGetAncestorLinks).not.toHaveBeenCalled();
expect(mockGetTrailingBreadcrumbs).not.toHaveBeenCalledWith();
});
it('should call updateBreadcrumbsNav with all breadcrumbs', () => {
renderHook(useBreadcrumbsNav);
expect(mockUpdateBreadcrumbsNav).toHaveBeenCalledWith({
leading: [
landingBreadcrumb,
{
href: link1.path,
text: link1.title,
onClick: expect.any(Function),
},
{
href: link2.path,
text: link2.title,
onClick: expect.any(Function),
},
{
href: link3.path,
text: link3.title,
onClick: expect.any(Function),
},
],
trailing: [
{
href: link4.path,
text: link4.title,
onClick: expect.any(Function),
},
{
href: link5.path,
text: link5.title,
onClick: expect.any(Function),
},
],
});
});
it('should create breadcrumbs onClick handler', () => {
renderHook(useBreadcrumbsNav);
const event = { preventDefault: jest.fn() } as unknown as React.MouseEvent<
HTMLElement,
MouseEvent
>;
const breadcrumb = mockUpdateBreadcrumbsNav.mock.calls?.[0]?.[0]?.leading[1];
breadcrumb?.onClick?.(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { METRIC_TYPE } from '@kbn/analytics';
import type { Dispatch } from 'redux';
import { SecurityPageName } from '../../../../app/types';
import type { RouteSpyState } from '../../../utils/route/types';
import { timelineActions } from '../../../../timelines/store/timeline';
import { TimelineId } from '../../../../../common/types/timeline';
import type { GetSecuritySolutionUrl } from '../../link_to';
import { useGetSecuritySolutionUrl } from '../../link_to';
import { TELEMETRY_EVENT, track } from '../../../lib/telemetry';
import { useNavigateTo, type NavigateTo } from '../../../lib/kibana';
import { useRouteSpy } from '../../../utils/route/use_route_spy';
import { updateBreadcrumbsNav } from '../../../breadcrumbs';
import { getAncestorLinksInfo } from '../../../links';
import { APP_NAME } from '../../../../../common/constants';
import { getTrailingBreadcrumbs } from './trailing_breadcrumbs';
export const useBreadcrumbsNav = () => {
const dispatch = useDispatch();
const [routeProps] = useRouteSpy();
const { navigateTo } = useNavigateTo();
const getSecuritySolutionUrl = useGetSecuritySolutionUrl();
useEffect(() => {
// cases manages its own breadcrumbs
if (!routeProps.pageName || routeProps.pageName === SecurityPageName.case) {
return;
}
const leadingBreadcrumbs = getLeadingBreadcrumbs(routeProps, getSecuritySolutionUrl);
const trailingBreadcrumbs = getTrailingBreadcrumbs(routeProps, getSecuritySolutionUrl);
updateBreadcrumbsNav({
leading: addOnClicksHandlers(leadingBreadcrumbs, dispatch, navigateTo),
trailing: addOnClicksHandlers(trailingBreadcrumbs, dispatch, navigateTo),
});
}, [routeProps, getSecuritySolutionUrl, dispatch, navigateTo]);
};
const getLeadingBreadcrumbs = (
{ pageName }: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
const landingBreadcrumb: ChromeBreadcrumb = {
text: APP_NAME,
href: getSecuritySolutionUrl({ deepLinkId: SecurityPageName.landing }),
};
const breadcrumbs: ChromeBreadcrumb[] = getAncestorLinksInfo(pageName).map(({ title, id }) => ({
text: title,
href: getSecuritySolutionUrl({ deepLinkId: id }),
}));
return [landingBreadcrumb, ...breadcrumbs];
};
const addOnClicksHandlers = (
breadcrumbs: ChromeBreadcrumb[],
dispatch: Dispatch,
navigateTo: NavigateTo
): ChromeBreadcrumb[] =>
breadcrumbs.map((breadcrumb) => ({
...breadcrumb,
...(breadcrumb.href &&
!breadcrumb.onClick && {
onClick: createOnClickHandler(breadcrumb.href, dispatch, navigateTo),
}),
}));
const createOnClickHandler =
(href: string, dispatch: Dispatch, navigateTo: NavigateTo): ChromeBreadcrumb['onClick'] =>
(ev) => {
ev.preventDefault();
const trackedPath = href.split('?')[0];
track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.BREADCRUMB}${trackedPath}`);
dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false }));
navigateTo({ url: href });
};

View file

@ -9,9 +9,8 @@ import { renderHook } from '@testing-library/react-hooks';
import { BehaviorSubject } from 'rxjs';
import { useSecuritySolutionNavigation } from './use_security_solution_navigation';
const mockSetBreadcrumbs = jest.fn();
jest.mock('../breadcrumbs', () => ({
useBreadcrumbs: () => mockSetBreadcrumbs,
useBreadcrumbsNav: () => jest.fn(),
}));
const mockIsSidebarEnabled$ = new BehaviorSubject(true);

View file

@ -16,7 +16,7 @@ import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n';
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
import { useKibana } from '../../../lib/kibana';
import { useBreadcrumbs } from '../breadcrumbs';
import { useBreadcrumbsNav } from '../breadcrumbs';
import { SecuritySideNav } from '../security_side_nav';
const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', {
@ -27,9 +27,7 @@ export const useSecuritySolutionNavigation = (): KibanaPageTemplateProps['soluti
const { isSidebarEnabled$ } = useKibana().services;
const isSidebarEnabled = useObservable(isSidebarEnabled$);
useBreadcrumbs({
isEnabled: true, // TODO: use isSidebarEnabled$ when serverless breadcrumb is ready
});
useBreadcrumbsNav();
if (!isSidebarEnabled) {
return undefined;

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/breadcrumbs/types';
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => {
const breadcrumbName = params?.state?.dashboardName;
if (breadcrumbName) {
return [{ text: breadcrumbName }];
}
return [];
};

View file

@ -6,7 +6,7 @@
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to';
import type { GetTrailingBreadcrumbs } from '../../../../common/components/navigation/breadcrumbs/types';
import { getAlertDetailsUrl } from '../../../../common/components/link_to';
import { SecurityPageName } from '../../../../../common/constants';
import type { AlertDetailRouteSpyState } from '../../../../common/utils/route/types';
@ -17,10 +17,15 @@ const TabNameMappedToI18nKey: Record<AlertDetailRouteType, string> = {
[AlertDetailRouteType.summary]: i18n.SUMMARY_PAGE_TITLE,
};
export const getTrailingBreadcrumbs = (
params: AlertDetailRouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs<AlertDetailRouteSpyState> = (
params,
getSecuritySolutionUrl
) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName != null) {

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import {
getRuleDetailsTabUrl,
getRuleDetailsUrl,
} from '../../../../common/components/link_to/redirect_to_detection_engine';
import * as i18nRules from './translations';
import { SecurityPageName } from '../../../../app/types';
import { RULES_PATH } from '../../../../../common/constants';
import type { GetTrailingBreadcrumbs } from '../../../../common/components/navigation/breadcrumbs/types';
import {
RuleDetailTabs,
RULE_DETAILS_TAB_NAME,
} from '../../../../detection_engine/rule_details_ui/pages/rule_details';
import { DELETED_RULE } from '../../../../detection_engine/rule_details_ui/pages/rule_details/translations';
const getRuleDetailsTabName = (tabName: string): string => {
return RULE_DETAILS_TAB_NAME[tabName] ?? RULE_DETAILS_TAB_NAME[RuleDetailTabs.alerts];
};
const isRuleCreatePage = (pathname: string) =>
pathname.includes(RULES_PATH) && pathname.includes('/create');
const isRuleEditPage = (pathname: string) =>
pathname.includes(RULES_PATH) && pathname.includes('/edit');
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName && params.state?.ruleName) {
breadcrumb = [
...breadcrumb,
{
text: params.state.ruleName,
href: getSecuritySolutionUrl({
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsUrl(params.detailName, ''),
}),
},
];
}
if (params.detailName && params.state?.ruleName && params.tabName) {
breadcrumb = [
...breadcrumb,
{
text: getRuleDetailsTabName(params.tabName),
href: getSecuritySolutionUrl({
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsTabUrl(params.detailName, params.tabName, ''),
}),
},
];
}
if (isRuleCreatePage(params.pathName)) {
breadcrumb = [
...breadcrumb,
{
text: i18nRules.ADD_PAGE_TITLE,
href: '',
},
];
}
if (isRuleEditPage(params.pathName) && params.detailName && params.state?.ruleName) {
breadcrumb = [
...breadcrumb,
{
text: i18nRules.EDIT_PAGE_TITLE,
href: '',
},
];
}
if (!isRuleEditPage(params.pathName) && params.state && !params.state.isExistingRule) {
breadcrumb = [...breadcrumb, { text: DELETED_RULE, href: '' }];
}
return breadcrumb;
};

View file

@ -5,27 +5,13 @@
* 2.0.
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { isThreatMatchRule } from '../../../../../common/detection_engine/utils';
import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations';
import {
getRuleDetailsTabUrl,
getRuleDetailsUrl,
} from '../../../../common/components/link_to/redirect_to_detection_engine';
import * as i18nRules from './translations';
import type { RouteSpyState } from '../../../../common/utils/route/types';
import { SecurityPageName } from '../../../../app/types';
import { DEFAULT_THREAT_MATCH_QUERY, RULES_PATH } from '../../../../../common/constants';
import { DEFAULT_THREAT_MATCH_QUERY } from '../../../../../common/constants';
import type { AboutStepRule, DefineStepRule, RuleStepsOrder, ScheduleStepRule } from './types';
import { DataSourceType, GroupByOptions, RuleStep } from './types';
import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to';
import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../common/detection_engine/rule_schema';
import {
RuleDetailTabs,
RULE_DETAILS_TAB_NAME,
} from '../../../../detection_engine/rule_details_ui/pages/rule_details';
import { DELETED_RULE } from '../../../../detection_engine/rule_details_ui/pages/rule_details/translations';
import { fillEmptySeverityMappings } from './helpers';
export const ruleStepsOrder: RuleStepsOrder = [
@ -35,75 +21,6 @@ export const ruleStepsOrder: RuleStepsOrder = [
RuleStep.ruleActions,
];
const getRuleDetailsTabName = (tabName: string): string => {
return RULE_DETAILS_TAB_NAME[tabName] ?? RULE_DETAILS_TAB_NAME[RuleDetailTabs.alerts];
};
const isRuleCreatePage = (pathname: string) =>
pathname.includes(RULES_PATH) && pathname.includes('/create');
const isRuleEditPage = (pathname: string) =>
pathname.includes(RULES_PATH) && pathname.includes('/edit');
export const getTrailingBreadcrumbs = (
params: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName && params.state?.ruleName) {
breadcrumb = [
...breadcrumb,
{
text: params.state.ruleName,
href: getSecuritySolutionUrl({
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsUrl(params.detailName, ''),
}),
},
];
}
if (params.detailName && params.state?.ruleName && params.tabName) {
breadcrumb = [
...breadcrumb,
{
text: getRuleDetailsTabName(params.tabName),
href: getSecuritySolutionUrl({
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsTabUrl(params.detailName, params.tabName, ''),
}),
},
];
}
if (isRuleCreatePage(params.pathName)) {
breadcrumb = [
...breadcrumb,
{
text: i18nRules.ADD_PAGE_TITLE,
href: '',
},
];
}
if (isRuleEditPage(params.pathName) && params.detailName && params.state?.ruleName) {
breadcrumb = [
...breadcrumb,
{
text: i18nRules.EDIT_PAGE_TITLE,
href: '',
},
];
}
if (!isRuleEditPage(params.pathName) && params.state && !params.state.isExistingRule) {
breadcrumb = [...breadcrumb, { text: DELETED_RULE, href: '' }];
}
return breadcrumb;
};
export const threatDefault = [
{
framework: 'MITRE ATT&CK',

View file

@ -6,16 +6,17 @@
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { EXCEPTIONS_PATH } from '../../../common/constants';
import type { GetSecuritySolutionUrl } from '../../common/components/link_to';
import type { RouteSpyState } from '../../common/utils/route/types';
import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/breadcrumbs/types';
const isListDetailPage = (pathname: string) =>
pathname.includes(EXCEPTIONS_PATH) && pathname.includes('/details');
export const getTrailingBreadcrumbs = (
params: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (isListDetailPage(params.pathName) && params.state?.listName) {

View file

@ -8,16 +8,13 @@
import { get } from 'lodash/fp';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { hostsModel } from '../../store';
import { HostsTableType } from '../../store/model';
import { getHostDetailsUrl } from '../../../../common/components/link_to/redirect_to_hosts';
import * as i18n from '../translations';
import type { HostRouteSpyState } from '../../../../common/utils/route/types';
import { SecurityPageName } from '../../../../app/types';
import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to';
export const type = hostsModel.HostsType.details;
import type { GetTrailingBreadcrumbs } from '../../../../common/components/navigation/breadcrumbs/types';
const TabNameMappedToI18nKey: Record<HostsTableType, string> = {
[HostsTableType.hosts]: i18n.NAVIGATION_ALL_HOSTS_TITLE,
@ -29,10 +26,15 @@ const TabNameMappedToI18nKey: Record<HostsTableType, string> = {
[HostsTableType.sessions]: i18n.NAVIGATION_SESSIONS_TITLE,
};
export const getTrailingBreadcrumbs = (
params: HostRouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs<HostRouteSpyState> = (
params,
getSecuritySolutionUrl
) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName != null) {

View file

@ -20,10 +20,9 @@ import {
} from '../../../../common/mock';
import { HostDetailsTabs } from './details_tabs';
import { hostDetailsPagePath } from '../types';
import { type } from './utils';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { getHostDetailsPageFilters } from './helpers';
import { HostsTableType } from '../../store/model';
import { HostsType, HostsTableType } from '../../store/model';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import type { State } from '../../../../common/store';
import { createStore } from '../../../../common/store';
@ -123,7 +122,7 @@ describe('body', () => {
hostDetailsPagePath={hostDetailsPagePath}
indexNames={[]}
indexPattern={mockIndexPattern}
type={type}
type={HostsType.details}
hostDetailsFilter={mockHostDetailsPageFilters}
filterQuery={filterQuery}
from={'2020-07-07T08:20:18.966Z'}

View file

@ -10,14 +10,13 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { TableId } from '@kbn/securitysolution-data-table';
import { RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskDetailsTabBody } from '../../../components/risk_score/risk_details_tab_body';
import { HostsTableType } from '../../store/model';
import { HostsType, HostsTableType } from '../../store/model';
import { AnomaliesQueryTabBody } from '../../../../common/containers/anomalies/anomalies_query_tab_body';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { AnomaliesHostTable } from '../../../../common/components/ml/tables/anomalies_host_table';
import { EventsQueryTabBody } from '../../../../common/components/events_tab';
import type { HostDetailsTabsProps } from './types';
import { type } from './utils';
import {
AuthenticationsQueryTabBody,
@ -43,7 +42,7 @@ export const HostDetailsTabs = React.memo<HostDetailsTabsProps>(
skip: isInitializing || filterQuery === undefined,
setQuery,
startDate: from,
type,
type: HostsType.details,
indexPattern,
indexNames,
hostName: detailName,

View file

@ -49,7 +49,7 @@ import { SpyRoute } from '../../../../common/utils/route/spy_routes';
import { HostDetailsTabs } from './details_tabs';
import { navTabsHostDetails } from './nav_tabs';
import type { HostDetailsProps } from './types';
import { type } from './utils';
import { HostsType } from '../../store/model';
import { getHostDetailsPageFilters } from './helpers';
import { showGlobalFilters } from '../../../../timelines/components/timeline/helpers';
import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen';
@ -269,7 +269,7 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
to={to}
from={from}
detailName={detailName}
type={type}
type={HostsType.details}
setQuery={setQuery}
filterQuery={stringifiedAdditionalFilters}
hostDetailsPagePath={hostDetailsPagePath}

View file

@ -10,15 +10,13 @@ import { get } from 'lodash/fp';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { decodeIpv6 } from '../../../../common/lib/helpers';
import { getNetworkDetailsUrl } from '../../../../common/components/link_to/redirect_to_network';
import { networkModel } from '../../store';
import * as i18n from '../translations';
import { NetworkDetailsRouteType } from './types';
import type { NetworkRouteSpyState } from '../../../../common/utils/route/types';
import { SecurityPageName } from '../../../../app/types';
import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to';
import { NetworkRouteType } from '../navigation/types';
import type { GetTrailingBreadcrumbs } from '../../../../common/components/navigation/breadcrumbs/types';
export const type = networkModel.NetworkType.details;
const TabNameMappedToI18nKey: Record<NetworkDetailsRouteType | NetworkRouteType, string> = {
[NetworkDetailsRouteType.events]: i18n.NAVIGATION_EVENTS_TITLE,
[NetworkDetailsRouteType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE,
@ -28,11 +26,15 @@ const TabNameMappedToI18nKey: Record<NetworkDetailsRouteType | NetworkRouteType,
[NetworkDetailsRouteType.tls]: i18n.NAVIGATION_TLS_TITLE,
[NetworkRouteType.dns]: i18n.NAVIGATION_DNS_TITLE,
};
export const getTrailingBreadcrumbs = (
params: NetworkRouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs<NetworkRouteSpyState> = (
params,
getSecuritySolutionUrl
) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName != null) {

View file

@ -58,8 +58,6 @@ import {
SecurityCellActionsTrigger,
} from '../../../../common/components/cell_actions';
export { getTrailingBreadcrumbs } from './utils';
const NetworkDetailsManage = manageQuery(IpOverview);
const NetworkDetailsComponent: React.FC = () => {

View file

@ -8,16 +8,13 @@
import { get } from 'lodash/fp';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { usersModel } from '../../store';
import { UsersTableType } from '../../store/model';
import { getUsersDetailsUrl } from '../../../../common/components/link_to/redirect_to_users';
import * as i18n from '../translations';
import type { UsersRouteSpyState } from '../../../../common/utils/route/types';
import { SecurityPageName } from '../../../../app/types';
import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to';
export const type = usersModel.UsersType.details;
import type { GetTrailingBreadcrumbs } from '../../../../common/components/navigation/breadcrumbs/types';
const TabNameMappedToI18nKey: Record<UsersTableType, string> = {
[UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE,
@ -28,10 +25,15 @@ const TabNameMappedToI18nKey: Record<UsersTableType, string> = {
[UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE,
};
export const getTrailingBreadcrumbs = (
params: UsersRouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs<UsersRouteSpyState> = (
params,
getSecuritySolutionUrl
) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName != null) {

View file

@ -41,7 +41,6 @@ import { SpyRoute } from '../../../../common/utils/route/spy_routes';
import { UsersDetailsTabs } from './details_tabs';
import { navTabsUsersDetails } from './nav_tabs';
import type { UsersDetailsProps } from './types';
import { type } from './utils';
import { getUsersDetailsPageFilters } from './helpers';
import { showGlobalFilters } from '../../../../timelines/components/timeline/helpers';
import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen';
@ -257,7 +256,7 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({
userDetailFilter={usersDetailsPageFilters}
setQuery={setQuery}
to={to}
type={type}
type={UsersType.details}
usersDetailsPagePath={usersDetailsPagePath}
/>
</SecuritySolutionPageWrapper>

View file

@ -9,6 +9,8 @@ import type { PluginInitializerContext } from '@kbn/core/public';
import { Plugin } from './plugin';
import type { PluginSetup, PluginStart } from './types';
export type { TimelineModel } from './timelines/store/timeline/model';
export type { NavigationLink } from './common/links';
export type {
UpsellingService,
PageUpsellings,

View file

@ -6,15 +6,16 @@
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { RouteSpyState } from '../../../common/utils/route/types';
import { SecurityPageName } from '../../../app/types';
import type { GetSecuritySolutionUrl } from '../../../common/components/link_to';
import { getKubernetesDetailsUrl } from '../../../common/components/link_to';
import type { GetTrailingBreadcrumbs } from '../../../common/components/navigation/breadcrumbs/types';
export const getTrailingBreadcrumbs = (
params: RouteSpyState,
getSecuritySolutionUrl: GetSecuritySolutionUrl
): ChromeBreadcrumb[] => {
/**
* This module should only export this function.
* All the `getTrailingBreadcrumbs` functions in Security are loaded into the main bundle.
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => {
let breadcrumb: ChromeBreadcrumb[] = [];
if (params.detailName != null) {

View file

@ -6,6 +6,7 @@
*/
import { BehaviorSubject } from 'rxjs';
import type { BreadcrumbsNav } from './common/breadcrumbs';
import type { NavigationLink } from './common/links/types';
const setupMock = () => ({
@ -15,6 +16,10 @@ const setupMock = () => ({
const startMock = () => ({
getNavLinks$: jest.fn(() => new BehaviorSubject<NavigationLink[]>([])),
setIsSidebarEnabled: jest.fn(),
setGetStartedPage: jest.fn(),
getBreadcrumbsNav$: jest.fn(
() => new BehaviorSubject<BreadcrumbsNav>({ leading: [], trailing: [] })
),
});
export const securitySolutionMock = {

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { BehaviorSubject, Subject } from 'rxjs';
import { Subject } from 'rxjs';
import type * as H from 'history';
import type {
AppMountParameters,
@ -36,7 +36,6 @@ import { APP_ID, APP_UI_ID, APP_PATH, APP_ICON_SOLUTION } from '../common/consta
import { updateAppLinks, type LinksPermissions } from './common/links';
import { registerDeepLinksUpdater } from './common/links/deep_links';
import { navLinks$ } from './common/links/nav_links';
import { licenseService } from './common/hooks/use_license';
import type { SecuritySolutionUiConfigType } from './common/types';
import { ExperimentalFeaturesService } from './common/experimental_features_service';
@ -49,10 +48,10 @@ import { getLazyEndpointPolicyResponseExtension } from './management/pages/polic
import { getLazyEndpointGenericErrorsListExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_generic_errors_list';
import type { ExperimentalFeatures } from '../common/experimental_features';
import { parseExperimentalConfigValue } from '../common/experimental_features';
import { UpsellingService } from './common/lib/upsellings';
import { LazyEndpointCustomAssetsExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_custom_assets_extension';
import type { SecurityAppStore } from './common/store/types';
import { PluginContract } from './plugin_contract';
export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
/**
@ -76,12 +75,10 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
*/
readonly prebuiltRulesPackageVersion?: string;
private config: SecuritySolutionUiConfigType;
private contract: PluginContract;
private telemetry: TelemetryService;
readonly experimentalFeatures: ExperimentalFeatures;
private upsellingService: UpsellingService;
private isSidebarEnabled$: BehaviorSubject<boolean>;
private getStartedComponent$: BehaviorSubject<React.ComponentType | null>;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<SecuritySolutionUiConfigType>();
@ -91,9 +88,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.kibanaVersion = initializerContext.env.packageInfo.version;
this.kibanaBranch = initializerContext.env.packageInfo.branch;
this.prebuiltRulesPackageVersion = this.config.prebuiltRulesPackageVersion;
this.isSidebarEnabled$ = new BehaviorSubject<boolean>(true);
this.getStartedComponent$ = new BehaviorSubject<React.ComponentType | null>(null);
this.upsellingService = new UpsellingService();
this.contract = new PluginContract();
this.telemetry = new TelemetryService();
}
private appUpdater$ = new Subject<AppUpdater>();
@ -158,6 +153,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
const services: StartServices = {
...coreStart,
...startPlugins,
...this.contract.getStartServices(),
apm,
savedObjectsTagging: savedObjectsTaggingOss.getTaggingApi(),
storage: this.storage,
@ -168,9 +164,6 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
getPluginWrapper: () => SecuritySolutionTemplateWrapper,
},
savedObjectsManagement: startPluginsDeps.savedObjectsManagement,
isSidebarEnabled$: this.isSidebarEnabled$,
getStartedComponent$: this.getStartedComponent$,
upselling: this.upsellingService,
telemetry: this.telemetry.start(),
};
return services;
@ -235,19 +228,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
},
});
return {
resolver: async () => {
/**
* The specially formatted comment in the `import` expression causes the corresponding webpack chunk to be named. This aids us in debugging chunk size issues.
* See https://webpack.js.org/api/module-methods/#magic-comments
*/
const { resolverPluginSetup } = await import(
/* webpackChunkName: "resolver" */ './resolver'
);
return resolverPluginSetup();
},
upselling: this.upsellingService,
};
return this.contract.getSetupContract();
}
public start(core: CoreStart, plugins: StartPlugins): PluginStart {
@ -310,19 +291,12 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
// Not using await to prevent blocking start execution
this.registerAppLinks(core, plugins);
return {
getNavLinks$: () => navLinks$,
setIsSidebarEnabled: (isSidebarEnabled: boolean) =>
this.isSidebarEnabled$.next(isSidebarEnabled),
setGetStartedPage: (getStartedComponent) => {
this.getStartedComponent$.next(getStartedComponent);
},
};
return this.contract.getStartContract();
}
public stop() {
licenseService.stop();
return {};
return this.contract.getStopContract();
}
private lazyHelpersForRoutes() {
@ -492,13 +466,14 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
async registerAppLinks(core: CoreStart, plugins: StartPlugins) {
const { links, getFilteredLinks } = await this.lazyApplicationLinks();
const { license$ } = plugins.licensing;
const upselling = this.contract.upsellingService;
registerDeepLinksUpdater(this.appUpdater$);
license$.subscribe(async (license) => {
const linksPermissions: LinksPermissions = {
experimentalFeatures: this.experimentalFeatures,
upselling: this.upsellingService,
upselling,
capabilities: core.application.capabilities,
};

View file

@ -0,0 +1,67 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import { UpsellingService } from './common/lib/upsellings';
import type { ContractStartServices, PluginSetup, PluginStart } from './types';
import { navLinks$ } from './common/links/nav_links';
import { breadcrumbsNav$ } from './common/breadcrumbs';
export class PluginContract {
public isSidebarEnabled$: BehaviorSubject<boolean>;
public getStartedComponent$: BehaviorSubject<React.ComponentType | null>;
public upsellingService: UpsellingService;
constructor() {
this.isSidebarEnabled$ = new BehaviorSubject<boolean>(true);
this.getStartedComponent$ = new BehaviorSubject<React.ComponentType | null>(null);
this.upsellingService = new UpsellingService();
}
public getStartServices(): ContractStartServices {
return {
isSidebarEnabled$: this.isSidebarEnabled$.asObservable(),
getStartedComponent$: this.getStartedComponent$.asObservable(),
upselling: this.upsellingService,
};
}
public getSetupContract(): PluginSetup {
return {
resolver: lazyResolver,
upselling: this.upsellingService,
};
}
public getStartContract(): PluginStart {
return {
getNavLinks$: () => navLinks$,
setIsSidebarEnabled: (isSidebarEnabled: boolean) =>
this.isSidebarEnabled$.next(isSidebarEnabled),
setGetStartedPage: (getStartedComponent) => {
this.getStartedComponent$.next(getStartedComponent);
},
getBreadcrumbsNav$: () => breadcrumbsNav$,
};
}
public getStopContract() {
return {};
}
}
const lazyResolver = async () => {
/**
* The specially formatted comment in the `import` expression causes the corresponding webpack chunk to be named. This aids us in debugging chunk size issues.
* See https://webpack.js.org/api/module-methods/#magic-comments
*/
const { resolverPluginSetup } = await import(
/* webpackChunkName: "resolver" */
'./resolver'
);
return resolverPluginSetup();
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { BehaviorSubject, Observable } from 'rxjs';
import type { Observable } from 'rxjs';
import type { AppLeaveHandler, CoreStart } from '@kbn/core/public';
import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
@ -70,6 +70,7 @@ import type { NavigationLink } from './common/links';
import type { TelemetryClientStart } from './common/lib/telemetry';
import type { Dashboards } from './dashboards';
import type { UpsellingService } from './common/lib/upsellings';
import type { BreadcrumbsNav } from './common/breadcrumbs/types';
export interface SetupPlugins {
cloud?: CloudSetup;
@ -127,8 +128,15 @@ export interface StartPluginsDependencies extends StartPlugins {
savedObjectsTaggingOss: SavedObjectTaggingOssPluginStart;
}
export interface ContractStartServices {
isSidebarEnabled$: Observable<boolean>;
getStartedComponent$: Observable<React.ComponentType | null>;
upselling: UpsellingService;
}
export type StartServices = CoreStart &
StartPlugins & {
StartPlugins &
ContractStartServices & {
storage: Storage;
sessionStorage: Storage;
apm: ApmBase;
@ -143,9 +151,6 @@ export type StartServices = CoreStart &
getPluginWrapper: () => typeof SecuritySolutionTemplateWrapper;
};
savedObjectsManagement: SavedObjectsManagementPluginStart;
isSidebarEnabled$: BehaviorSubject<boolean>;
getStartedComponent$: BehaviorSubject<React.ComponentType | null>;
upselling: UpsellingService;
telemetry: TelemetryClientStart;
};
@ -158,6 +163,7 @@ export interface PluginStart {
getNavLinks$: () => Observable<NavigationLink[]>;
setIsSidebarEnabled: (isSidebarEnabled: boolean) => void;
setGetStartedPage: (getStartedComponent: React.ComponentType) => void;
getBreadcrumbsNav$: () => Observable<BreadcrumbsNav>;
}
export interface AppObservableLibs {

View file

@ -83,7 +83,6 @@
"@kbn/guided-onboarding-plugin",
"@kbn/i18n-react",
"@kbn/kibana-react-plugin",
"@kbn/core-chrome-browser",
"@kbn/ecs-data-quality-dashboard",
"@kbn/elastic-assistant",
"@kbn/data-views-plugin",

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/serverless_security/public/common'],
testMatch: [
'<rootDir>/x-pack/plugins/serverless_security/public/common/**/*.test.{js,mjs,ts,tsx}',
],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/serverless_security/public/common',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/serverless_security/public/common/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/*.d.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/*.config.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/common/index.{js,ts,tsx}',
],
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Services } from '../services';
export const subscribeBreadcrumbs = (services: Services) => {
const { securitySolution, serverless } = services;
securitySolution.getBreadcrumbsNav$().subscribe((breadcrumbsNav) => {
serverless.setBreadcrumbs(breadcrumbsNav.trailing);
});
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getProjectNavLinks$ } from './nav_links';
export type { ProjectNavLinks, ProjectNavigationLink } 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 { map, type Observable } from 'rxjs';
import type { NavigationLink } from '@kbn/security-solution-plugin/public';
import type { ProjectNavLinks, ProjectNavigationLink } from './types';
export const getProjectNavLinks$ = (navLinks$: Observable<NavigationLink[]>): ProjectNavLinks => {
return navLinks$.pipe(map(processNavLinks));
};
// TODO: This is a placeholder function that will be used to process the nav links,
// It will mix internal Security nav links with the external links to other plugins, in the correct order.
const processNavLinks = (navLinks: NavigationLink[]): ProjectNavigationLink[] => navLinks;

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 type { Observable } from 'rxjs';
import type { NavigationLink } from '@kbn/security-solution-plugin/public';
export interface ProjectNavigationLink extends NavigationLink {
// The appId for external links
appId?: string;
}
export type ProjectNavLinks = Observable<ProjectNavigationLink[]>;

View file

@ -0,0 +1,286 @@
/*
* 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 { ChromeNavLink } from '@kbn/core/public';
import { APP_UI_ID, SecurityPageName } from '@kbn/security-solution-plugin/common';
import { servicesMocks } from '../services.mock';
import { subscribeNavigationTree } from './navigation_tree';
import { BehaviorSubject } from 'rxjs';
import { mockProjectNavLinks } from '../services.mock';
import type { ProjectNavigationLink } from './links';
const mockChromeNavLinks = jest.fn((): ChromeNavLink[] => []);
const mockChromeGetNavLinks = jest.fn(() => new BehaviorSubject(mockChromeNavLinks()));
const mockChromeNavLinksGet = jest.fn((id: string): ChromeNavLink | undefined =>
mockChromeNavLinks().find((link) => link.id === id)
);
const mockChromeNavLinksHas = jest.fn((id: string): boolean =>
mockChromeNavLinks().some((link) => link.id === id)
);
const mockServices = {
...servicesMocks,
chrome: {
...servicesMocks.chrome,
navLinks: {
...servicesMocks.chrome.navLinks,
get: mockChromeNavLinksGet,
has: mockChromeNavLinksHas,
getNavLinks$: mockChromeGetNavLinks,
},
},
};
const link1Id = 'link-1' as SecurityPageName;
const link2Id = 'link-2' as SecurityPageName;
const link1: ProjectNavigationLink = { id: link1Id, title: 'link 1' };
const link2: ProjectNavigationLink = { id: link2Id, title: 'link 2' };
const chromeNavLink1: ChromeNavLink = {
id: `${APP_UI_ID}:${link1.id}`,
title: link1.title,
href: '/link1',
url: '/link1',
baseUrl: '',
};
const chromeNavLink2: ChromeNavLink = {
id: `${APP_UI_ID}:${link2.id}`,
title: link2.title,
href: '/link2',
url: '/link2',
baseUrl: '',
};
const waitForDebounce = async () => new Promise((resolve) => setTimeout(resolve, 150));
describe('subscribeNavigationTree', () => {
beforeEach(() => {
jest.clearAllMocks();
mockChromeNavLinks.mockReturnValue([chromeNavLink1, chromeNavLink2]);
});
it('should call serverless setNavigation', async () => {
mockProjectNavLinks.mockReturnValueOnce([link1]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).toHaveBeenCalledWith({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: chromeNavLink1.id,
title: link1.title,
path: ['root', chromeNavLink1.id],
deepLink: chromeNavLink1,
},
],
},
],
});
});
it('should call serverless setNavigation with external link', async () => {
const externalLink = { ...link1, appId: 'externalAppId' };
const chromeNavLinkExpected = {
...chromeNavLink1,
id: `${externalLink.appId}:${externalLink.id}`,
};
mockChromeNavLinks.mockReturnValue([chromeNavLinkExpected]);
mockProjectNavLinks.mockReturnValueOnce([externalLink]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).toHaveBeenCalledWith({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: chromeNavLinkExpected.id,
title: externalLink.title,
path: ['root', chromeNavLinkExpected.id],
deepLink: chromeNavLinkExpected,
},
],
},
],
});
});
it('should call serverless setNavigation with nested children', async () => {
mockProjectNavLinks.mockReturnValueOnce([{ ...link1, links: [link2] }]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).toHaveBeenCalledWith({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: chromeNavLink1.id,
title: link1.title,
path: ['root', chromeNavLink1.id],
deepLink: chromeNavLink1,
children: [
{
id: chromeNavLink2.id,
title: link2.title,
path: ['root', chromeNavLink1.id, chromeNavLink2.id],
deepLink: chromeNavLink2,
},
],
},
],
},
],
});
});
it('should not call serverless setNavigation when projectNavLinks is empty', async () => {
mockProjectNavLinks.mockReturnValueOnce([]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).not.toHaveBeenCalled();
});
it('should not call serverless setNavigation when chrome navLinks is empty', async () => {
mockChromeNavLinks.mockReturnValue([]);
mockProjectNavLinks.mockReturnValueOnce([link1]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).not.toHaveBeenCalled();
});
it('should debounce updates', async () => {
const id = 'expectedId' as SecurityPageName;
const linkExpected = { ...link1, id };
const chromeNavLinkExpected = { ...chromeNavLink1, id: `${APP_UI_ID}:${id}` };
const chromeGetNavLinks$ = new BehaviorSubject([chromeNavLink1]);
mockChromeGetNavLinks.mockReturnValue(chromeGetNavLinks$);
mockChromeNavLinks.mockReturnValue([chromeNavLink1, chromeNavLink2, chromeNavLinkExpected]);
mockProjectNavLinks.mockReturnValueOnce([linkExpected]);
subscribeNavigationTree(mockServices);
chromeGetNavLinks$.next([chromeNavLink1]);
chromeGetNavLinks$.next([chromeNavLink2]);
chromeGetNavLinks$.next([chromeNavLinkExpected]);
expect(mockServices.serverless.setNavigation).not.toHaveBeenCalled();
await waitForDebounce();
expect(mockServices.serverless.setNavigation).toHaveBeenCalledTimes(1);
expect(mockServices.serverless.setNavigation).toHaveBeenCalledWith({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: chromeNavLinkExpected.id,
title: link1.title,
path: ['root', chromeNavLinkExpected.id],
deepLink: chromeNavLinkExpected,
},
],
},
],
});
});
it('should not include links that are not in the chrome navLinks', async () => {
mockChromeNavLinks.mockReturnValue([chromeNavLink2]);
mockProjectNavLinks.mockReturnValueOnce([link1, link2]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).toHaveBeenCalledWith({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: chromeNavLink2.id,
title: link2.title,
path: ['root', chromeNavLink2.id],
deepLink: chromeNavLink2,
},
],
},
],
});
});
it('should set hidden breadcrumb for blacklisted links', async () => {
const chromeNavLinkTest = {
...chromeNavLink1,
id: `${APP_UI_ID}:${SecurityPageName.usersEvents}`, // userEvents link is blacklisted
};
mockChromeNavLinks.mockReturnValue([chromeNavLinkTest, chromeNavLink2]);
mockProjectNavLinks.mockReturnValueOnce([
{ ...link1, id: SecurityPageName.usersEvents },
link2,
]);
subscribeNavigationTree(mockServices);
await waitForDebounce();
expect(mockServices.serverless.setNavigation).toHaveBeenCalledWith({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: chromeNavLinkTest.id,
title: link1.title,
path: ['root', chromeNavLinkTest.id],
deepLink: chromeNavLinkTest,
breadcrumbStatus: 'hidden',
},
{
id: chromeNavLink2.id,
title: link2.title,
path: ['root', chromeNavLink2.id],
deepLink: chromeNavLink2,
},
],
},
],
});
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 { ChromeNavLinks, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import { APP_UI_ID, SecurityPageName } from '@kbn/security-solution-plugin/common';
import { combineLatest, skipWhile, debounceTime } from 'rxjs';
import type { Services } from '../services';
import type { ProjectNavigationLink } from './links/types';
// We need to hide breadcrumbs for some pages (tabs) because they appear duplicated.
// These breadcrumbs are incorrectly processed as trailing breadcrumbs in SecuritySolution, because of `SpyRoute` architecture limitations.
// They are navLinks tree with a SecurityPageName, so they should be treated as leading breadcrumbs in ESS as well.
// TODO: Improve the breadcrumbs logic in `use_breadcrumbs_nav` to avoid this workaround.
const HIDDEN_BREADCRUMBS = new Set<SecurityPageName>([
SecurityPageName.networkDns,
SecurityPageName.networkHttp,
SecurityPageName.networkTls,
SecurityPageName.networkAnomalies,
SecurityPageName.networkEvents,
SecurityPageName.usersAuthentications,
SecurityPageName.usersAnomalies,
SecurityPageName.usersRisk,
SecurityPageName.usersEvents,
SecurityPageName.uncommonProcesses,
SecurityPageName.hostsAnomalies,
SecurityPageName.hostsEvents,
SecurityPageName.hostsRisk,
SecurityPageName.sessions,
]);
export const subscribeNavigationTree = (services: Services): void => {
const { chrome, serverless, getProjectNavLinks$ } = services;
combineLatest([
getProjectNavLinks$().pipe(skipWhile((navLink) => navLink.length === 0)),
chrome.navLinks.getNavLinks$().pipe(skipWhile((chromeNavLinks) => chromeNavLinks.length === 0)),
])
.pipe(debounceTime(100)) // avoid multiple calls in a short time
.subscribe(([projectNavLinks]) => {
// The root link is temporary until the issue about having multiple links at first level is solved.
// TODO: Assign the navigationTree nodes when the issue is solved:
// const navigationTree = formatChromeProjectNavNodes(chrome.navLinks, projectNavLinks),
const navigationTree: ChromeProjectNavigationNode[] = [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: formatChromeProjectNavNodes(chrome.navLinks, projectNavLinks, ['root']),
},
];
serverless.setNavigation({ navigationTree });
});
};
const formatChromeProjectNavNodes = (
chromeNavLinks: ChromeNavLinks,
projectNavLinks: ProjectNavigationLink[],
path: string[] = []
): ChromeProjectNavigationNode[] =>
projectNavLinks.reduce<ChromeProjectNavigationNode[]>((navNodes, navLink) => {
const { id: deepLinkId, appId = APP_UI_ID, links, title } = navLink;
const id = deepLinkId ? `${appId}:${deepLinkId}` : appId;
if (chromeNavLinks.has(id)) {
const breadcrumbHidden = appId === APP_UI_ID && HIDDEN_BREADCRUMBS.has(deepLinkId);
const link: ChromeProjectNavigationNode = {
id,
title,
path: [...path, id],
deepLink: chromeNavLinks.get(id),
...(breadcrumbHidden && { breadcrumbStatus: 'hidden' }),
};
if (links?.length) {
link.children = formatChromeProjectNavNodes(chromeNavLinks, links, link.path);
}
navNodes.push(link);
}
return navNodes;
}, []);

View file

@ -11,12 +11,18 @@ import { coreMock } from '@kbn/core/public/mocks';
import { serverlessMock } from '@kbn/serverless/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { securitySolutionMock } from '@kbn/security-solution-plugin/public/mocks';
import { BehaviorSubject } from 'rxjs';
import type { ProjectNavigationLink } from './navigation/links';
import type { Services } from './services';
export const servicesMocks = {
export const mockProjectNavLinks = jest.fn((): ProjectNavigationLink[] => []);
export const servicesMocks: Services = {
...coreMock.createStart(),
serverless: serverlessMock.createStart(),
security: securityMock.createStart(),
securitySolution: securitySolutionMock.createStart(),
getProjectNavLinks$: jest.fn(() => new BehaviorSubject(mockProjectNavLinks())),
};
export const KibanaServicesProvider = React.memo(({ children }) => (

View file

@ -12,16 +12,27 @@ import {
useKibana as useKibanaReact,
} from '@kbn/kibana-react-plugin/public';
import type { ServerlessSecurityPluginStartDependencies } from './types';
import type { ServerlessSecurityPluginStartDependencies } from '../types';
import { getProjectNavLinks$, type ProjectNavLinks } from './navigation/links';
export type Services = CoreStart & ServerlessSecurityPluginStartDependencies;
interface InternalServices {
getProjectNavLinks$: () => ProjectNavLinks;
}
export type Services = CoreStart & ServerlessSecurityPluginStartDependencies & InternalServices;
export const KibanaServicesProvider: React.FC<{
core: CoreStart;
pluginsStart: ServerlessSecurityPluginStartDependencies;
}> = ({ core, pluginsStart, children }) => {
const services: Services = { ...core, ...pluginsStart };
services: Services;
}> = ({ services, children }) => {
return <KibanaContextProvider services={services}>{children}</KibanaContextProvider>;
};
export const useKibana = () => useKibanaReact<Services>();
export const createServices = (
core: CoreStart,
pluginsStart: ServerlessSecurityPluginStartDependencies
): Services => {
const { securitySolution } = pluginsStart;
const projectNavLinks$ = getProjectNavLinks$(securitySolution.getNavLinks$());
return { ...core, ...pluginsStart, getProjectNavLinks$: () => projectNavLinks$ };
};

View file

@ -7,21 +7,17 @@
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { KibanaServicesProvider, type Services } from '../../common/services';
import type { GetStartedComponent } from './types';
import { GetStarted } from './lazy';
import { KibanaServicesProvider } from '../../services';
import { ServerlessSecurityPluginStartDependencies } from '../../types';
import { SecurityProductTypes } from '../../../common/config';
export const getSecurityGetStartedComponent = (
core: CoreStart,
pluginsStart: ServerlessSecurityPluginStartDependencies,
services: Services,
productTypes: SecurityProductTypes
): GetStartedComponent => {
return () => (
<KibanaServicesProvider core={core} pluginsStart={pluginsStart}>
<KibanaServicesProvider services={services}>
<GetStarted productTypes={productTypes} />
</KibanaServicesProvider>
);

View file

@ -17,14 +17,6 @@ jest.mock('@elastic/eui', () => ({
useEuiShadow: jest.fn(),
}));
jest.mock('../../services', () => ({
useKibana: jest.fn(() => ({
services: {
storage: {},
},
})),
}));
jest.mock('../../lib/get_started/storage');
jest.mock('./use_setup_cards', () => ({

View file

@ -5,22 +5,14 @@
* 2.0.
*/
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import type {
SideNavComponent,
SideNavCompProps,
} from '@kbn/core-chrome-browser/src/project_navigation';
import { ServerlessSecurityPluginStartDependencies } from '../../types';
import type { SideNavComponent } from '@kbn/core-chrome-browser/src/project_navigation';
import { SecuritySideNavigation } from './lazy';
import { KibanaServicesProvider } from '../../services';
import { KibanaServicesProvider, type Services } from '../../common/services';
export const getSecuritySideNavComponent = (
core: CoreStart,
pluginsStart: ServerlessSecurityPluginStartDependencies
): SideNavComponent => {
return (_props: SideNavCompProps) => (
<KibanaServicesProvider core={core} pluginsStart={pluginsStart}>
export const getSecuritySideNavComponent = (services: Services): SideNavComponent => {
return () => (
<KibanaServicesProvider services={services}>
<SecuritySideNavigation />
</KibanaServicesProvider>
);

View file

@ -10,7 +10,7 @@ import { render } from '@testing-library/react';
import { SecuritySideNavigation } from './side_navigation';
import { useSideNavItems, useSideNavSelectedId } from '../../hooks/use_side_nav_items';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
import { KibanaServicesProvider } from '../../services.mock';
import { KibanaServicesProvider } from '../../common/services.mock';
jest.mock('../../hooks/use_side_nav_items');
const mockUseSideNavItems = useSideNavItems as jest.Mock;

View file

@ -8,13 +8,13 @@
import { MouseEvent } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { APP_UI_ID, SecurityPageName } from '@kbn/security-solution-plugin/common';
import { KibanaServicesProvider, servicesMocks } from '../services.mock';
import { KibanaServicesProvider, servicesMocks } from '../common/services.mock';
import { useGetLinkProps, useLinkProps } from './use_link_props';
const { getUrlForApp: mockGetUrlForApp, navigateToUrl: mockNavigateToUrl } =
servicesMocks.application;
const { getUrlForApp, navigateToUrl: mockNavigateToUrl } = servicesMocks.application;
const href = '/app/security/test';
const mockGetUrlForApp = getUrlForApp as jest.MockedFunction<typeof getUrlForApp>;
mockGetUrlForApp.mockReturnValue(href);
describe('useLinkProps', () => {

View file

@ -7,7 +7,7 @@
import { APP_UI_ID, type SecurityPageName } from '@kbn/security-solution-plugin/common';
import { useMemo, useCallback, type MouseEventHandler, type MouseEvent } from 'react';
import { useKibana, type Services } from '../services';
import { useKibana, type Services } from '../common/services';
interface LinkProps {
onClick: MouseEventHandler;

View file

@ -7,11 +7,10 @@
import { useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { useKibana } from '../services';
import { useKibana } from '../common/services';
export const useNavLinks = () => {
const { securitySolution } = useKibana().services;
const { getNavLinks$ } = securitySolution;
const navLinks$ = useMemo(() => getNavLinks$(), [getNavLinks$]);
return useObservable(navLinks$, []);
const { getProjectNavLinks$ } = useKibana().services;
const projectNavLinks$ = useMemo(() => getProjectNavLinks$(), [getProjectNavLinks$]);
return useObservable(projectNavLinks$, []);
};

View file

@ -7,18 +7,15 @@
import { renderHook } from '@testing-library/react-hooks';
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';
import { KibanaServicesProvider, servicesMocks } from '../services.mock';
import {
KibanaServicesProvider,
servicesMocks,
mockProjectNavLinks,
} from '../common/services.mock';
jest.mock('./use_link_props');
const mockNavLinks = jest.fn((): NavigationLink[] => []);
servicesMocks.securitySolution.getNavLinks$.mockImplementation(
() => new BehaviorSubject(mockNavLinks())
);
const mockUseLocation = jest.fn(() => ({ pathname: '/' }));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -36,11 +33,11 @@ describe('useSideNavItems', () => {
const items = result.current;
expect(items).toEqual([]);
expect(servicesMocks.securitySolution.getNavLinks$).toHaveBeenCalledTimes(1);
expect(servicesMocks.getProjectNavLinks$).toHaveBeenCalledTimes(1);
});
it('should return main items', async () => {
mockNavLinks.mockReturnValueOnce([
mockProjectNavLinks.mockReturnValueOnce([
{ id: SecurityPageName.alerts, title: 'Alerts' },
{ id: SecurityPageName.case, title: 'Cases' },
]);
@ -66,7 +63,7 @@ describe('useSideNavItems', () => {
});
it('should return secondary items', async () => {
mockNavLinks.mockReturnValueOnce([
mockProjectNavLinks.mockReturnValueOnce([
{
id: SecurityPageName.dashboards,
title: 'Dashboards',
@ -96,7 +93,7 @@ describe('useSideNavItems', () => {
});
it('should return get started link', async () => {
mockNavLinks.mockReturnValueOnce([
mockProjectNavLinks.mockReturnValueOnce([
{
id: SecurityPageName.landing,
title: 'Get Started',

View file

@ -8,8 +8,11 @@
import { useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
import { SolutionSideNavItem, SolutionSideNavItemPosition } from '@kbn/security-solution-side-nav';
import { useKibana } from '../services';
import {
SolutionSideNavItemPosition,
type SolutionSideNavItem,
} from '@kbn/security-solution-side-nav';
import { useKibana } from '../common/services';
import { type GetLinkProps, useGetLinkProps } from './use_link_props';
import { useNavLinks } from './use_nav_links';

View file

@ -17,6 +17,9 @@ import {
ServerlessSecurityPublicConfig,
} from './types';
import { registerUpsellings } from './components/upselling';
import { createServices } from './common/services';
import { subscribeNavigationTree } from './common/navigation/navigation_tree';
import { subscribeBreadcrumbs } from './common/navigation/breadcrumbs';
export class ServerlessSecurityPlugin
implements
@ -46,13 +49,18 @@ export class ServerlessSecurityPlugin
startDeps: ServerlessSecurityPluginStartDependencies
): ServerlessSecurityPluginStart {
const { securitySolution, serverless } = startDeps;
const { productTypes } = this.config;
const services = createServices(core, startDeps);
securitySolution.setIsSidebarEnabled(false);
securitySolution.setGetStartedPage(
getSecurityGetStartedComponent(core, startDeps, this.config.productTypes)
);
securitySolution.setGetStartedPage(getSecurityGetStartedComponent(services, productTypes));
serverless.setProjectHome('/app/security');
serverless.setSideNavComponent(getSecuritySideNavComponent(core, startDeps));
serverless.setSideNavComponent(getSecuritySideNavComponent(services));
subscribeNavigationTree(services);
subscribeBreadcrumbs(services);
return {};
}