Sustainable Kibana Architecture: Remove dependencies between plugins that are related by _App Links_ (#199492)

## Summary

This PR introduces a Core API to check whether a given application has
been registered.
Plugins can use this for their _App Links_, without having to depend on
the referenced plugin(s) anymore.

This way, we can get rid of some inter-solution dependencies, and
categorise plugins more appropriately.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Gerard Soldevila 2024-11-14 12:49:21 +01:00 committed by GitHub
parent 560ae9ab30
commit ad56ec5f1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 243 additions and 197 deletions

View file

@ -26,14 +26,14 @@ import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { MockLifecycle } from './test_helpers/test_types';
import type { MockLifecycle } from './test_helpers/test_types';
import { ApplicationService } from './application_service';
import {
App,
AppDeepLink,
type App,
type AppDeepLink,
AppStatus,
AppUpdater,
PublicAppInfo,
type AppUpdater,
type PublicAppInfo,
} from '@kbn/core-application-browser';
import { act } from 'react-dom/test-utils';
import { DEFAULT_APP_VISIBILITY } from './utils';
@ -618,6 +618,26 @@ describe('#start()', () => {
});
});
describe('isAppRegistered', () => {
let isAppRegistered: any;
beforeEach(async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), createApp({ id: 'one_app' }));
register(Symbol(), createApp({ id: 'another_app', appRoute: '/custom/path' }));
const start = await service.start(startDeps);
isAppRegistered = start.isAppRegistered;
});
it('returns false for unregistered apps', () => {
expect(isAppRegistered('oneApp')).toEqual(false);
});
it('returns true for registered apps', () => {
expect(isAppRegistered('another_app')).toEqual(true);
});
});
describe('getUrlForApp', () => {
it('creates URL for unregistered appId', async () => {
service.setup(setupDeps);

View file

@ -327,6 +327,9 @@ export class ApplicationService {
takeUntil(this.stop$)
),
history: this.history!,
isAppRegistered: (appId: string): boolean => {
return applications$.value.get(appId) !== undefined;
},
getUrlForApp: (
appId,
{

View file

@ -51,6 +51,7 @@ const createStartContractMock = (): jest.Mocked<ApplicationStart> => {
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),
getUrlForApp: jest.fn(),
isAppRegistered: jest.fn(),
};
};
@ -92,6 +93,7 @@ const createInternalStartContractMock = (
currentActionMenu$: new BehaviorSubject<MountPoint | undefined>(undefined),
getComponent: jest.fn(),
getUrlForApp: jest.fn(),
isAppRegistered: jest.fn(),
navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)),
navigateToUrl: jest.fn(),
history: createHistoryMock(),

View file

@ -68,9 +68,12 @@ export interface ApplicationStart {
applications$: Observable<ReadonlyMap<string, PublicAppInfo>>;
/**
* Navigate to a given app
* Navigate to a given app.
* If a plugin is disabled any applications it registers won't be available either.
* Before rendering a UI element that a user could use to navigate to another application,
* first check if the destination application is actually available using the isAppRegistered API.
*
* @param appId
* @param appId - The identifier of the app to navigate to
* @param options - navigation options
*/
navigateToApp(appId: string, options?: NavigateToAppOptions): Promise<void>;
@ -114,6 +117,14 @@ export interface ApplicationStart {
*/
navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise<void>;
/**
* Checks whether a given application is registered.
*
* @param appId - The identifier of the app to check
* @returns true if the given appId is registered in the system, false otherwise.
*/
isAppRegistered(appId: string): boolean;
/**
* Returns the absolute path (or URL) to a given app, including the global base path.
*

View file

@ -143,6 +143,7 @@ export function createPluginStartContext<
navigateToApp: deps.application.navigateToApp,
navigateToUrl: deps.application.navigateToUrl,
getUrlForApp: deps.application.getUrlForApp,
isAppRegistered: deps.application.isAppRegistered,
currentLocation$: deps.application.currentLocation$,
},
customBranding: deps.customBranding,

View file

@ -32,6 +32,7 @@ export const createStartContractMock = (): jest.Mocked<ApplicationStart> => {
capabilities,
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),
isAppRegistered: jest.fn(),
getUrlForApp: jest.fn(),
};
};

View file

@ -156,6 +156,7 @@ exports[`SavedObjectsTable should render normally 1`] = `
},
},
"getUrlForApp": [MockFunction],
"isAppRegistered": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
},

View file

@ -2,9 +2,11 @@
"type": "plugin",
"id": "@kbn/enterprise-search-plugin",
"owner": "@elastic/search-kibana",
// Could be categorised as Search in the future, but it currently needs to run in Observability too
"group": "platform",
"visibility": "shared",
// TODO this is currently used from Observability too, must be refactored before solution-specific builds
// see x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/search_connector_tab.tsx
// cc sphilipse
"group": "search",
"visibility": "private",
"description": "Adds dashboards for discovering and managing Enterprise Search products.",
"plugin": {
"id": "enterpriseSearch",

View file

@ -80,8 +80,8 @@ export type EnterpriseSearchPublicStart = ReturnType<EnterpriseSearchPlugin['sta
interface PluginsSetup {
cloud?: CloudSetup;
licensing: LicensingPluginStart;
home?: HomePublicPluginSetup;
licensing: LicensingPluginStart;
security?: SecurityPluginSetup;
share?: SharePluginSetup;
}
@ -98,8 +98,8 @@ export interface PluginsStart {
ml?: MlPluginStart;
navigation: NavigationPublicPluginStart;
searchConnectors?: SearchConnectorsPluginStart;
searchPlayground?: SearchPlaygroundPluginStart;
searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart;
searchPlayground?: SearchPlaygroundPluginStart;
security?: SecurityPluginStart;
share?: SharePluginStart;
}

View file

@ -22,6 +22,7 @@ export const getApplication = () => {
navigateToApp: async (app: string) => {
action(`Navigate to: ${app}`);
},
isAppRegistered: (appId: string) => true,
getUrlForApp: (url: string) => url,
capabilities: {
catalogue: {},

View file

@ -21,7 +21,6 @@
"optionalPlugins": [
"home",
"serverless",
"enterpriseSearch"
],
"requiredBundles": [
"kibanaReact",

View file

@ -6,11 +6,10 @@
*/
import { i18n } from '@kbn/i18n';
import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { ServerlessPluginStart } from '@kbn/serverless/public';
import { EnterpriseSearchPublicStart } from '@kbn/enterprise-search-plugin/public';
import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { ManagementSetup } from '@kbn/management-plugin/public';
import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import type { ServerlessPluginStart } from '@kbn/serverless/public';
import type {
ObservabilityAIAssistantPublicSetup,
@ -32,7 +31,6 @@ export interface SetupDependencies {
export interface StartDependencies {
observabilityAIAssistant: ObservabilityAIAssistantPublicStart;
serverless?: ServerlessPluginStart;
enterpriseSearch?: EnterpriseSearchPublicStart;
}
export interface ConfigSchema {

View file

@ -16,7 +16,7 @@ export const SELECTED_CONNECTOR_LOCAL_STORAGE_KEY =
export function SearchConnectorTab() {
const { application } = useKibana().services;
const url = application.getUrlForApp('enterprise_search', { path: '/content/connectors' });
const url = application.getUrlForApp('enterpriseSearch', { path: '/content/connectors' });
return (
<>

View file

@ -22,9 +22,8 @@ export function SettingsPage() {
const { setBreadcrumbs } = useAppContext();
const {
services: {
application: { navigateToApp },
application: { navigateToApp, isAppRegistered },
serverless,
enterpriseSearch,
},
} = useKibana();
@ -98,7 +97,7 @@ export function SettingsPage() {
}
),
content: <SearchConnectorTab />,
disabled: enterpriseSearch == null,
disabled: !isAppRegistered('enterpriseSearch'),
},
];

View file

@ -19,7 +19,6 @@
"@kbn/core-chrome-browser",
"@kbn/observability-ai-assistant-plugin",
"@kbn/serverless",
"@kbn/enterprise-search-plugin",
"@kbn/management-settings-components-field-row",
"@kbn/observability-shared-plugin",
"@kbn/config-schema",

View file

@ -2,8 +2,11 @@
"type": "plugin",
"id": "@kbn/search-inference-endpoints",
"owner": "@elastic/search-kibana",
"group": "platform",
"visibility": "shared",
// TODO enterpriseSearch depends on it, and Observability has a menu entry for enterpriseSearch
// must be refactored / fixed before solution-specific builds
// cc sphilipse
"group": "search",
"visibility": "private",
"plugin": {
"id": "searchInferenceEndpoints",
"server": true,

View file

@ -2,9 +2,10 @@
"type": "plugin",
"id": "@kbn/search-playground",
"owner": "@elastic/search-kibana",
// @kbn/enterprise-search-plugin (platform) and @kbn/serverless-search (search) depend on it
"group": "platform",
"visibility": "shared",
// TODO @kbn/enterprise-search-plugin (platform) and @kbn/serverless-search (search) depend on it
// cc sphilipse
"group": "search",
"visibility": "private",
"plugin": {
"id": "searchPlayground",
"server": true,

View file

@ -35,7 +35,6 @@
"indexManagement",
"searchConnectors",
"searchInferenceEndpoints",
"searchPlayground",
"usageCollection"
],
"requiredBundles": ["kibanaReact"]

View file

@ -5,172 +5,179 @@
* 2.0.
*/
import type { NavigationTreeDefinition } from '@kbn/core-chrome-browser';
import type { AppDeepLinkId, NavigationTreeDefinition } from '@kbn/core-chrome-browser';
import type { ApplicationStart } from '@kbn/core-application-browser';
import { i18n } from '@kbn/i18n';
import { CONNECTORS_LABEL } from '../common/i18n_string';
export const navigationTree = (): NavigationTreeDefinition => ({
body: [
{
type: 'navGroup',
id: 'search_project_nav',
title: 'Elasticsearch',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [
{
id: 'data',
title: i18n.translate('xpack.serverlessSearch.nav.data', {
defaultMessage: 'Data',
}),
spaceBefore: 'm',
children: [
{
title: i18n.translate('xpack.serverlessSearch.nav.content.indices', {
defaultMessage: 'Index Management',
}),
link: 'management:index_management',
breadcrumbStatus:
'hidden' /* management sub-pages set their breadcrumbs themselves */,
getIsActive: ({ pathNameSerialized, prepend }) => {
return (
pathNameSerialized.startsWith(
prepend('/app/management/data/index_management/')
) ||
pathNameSerialized.startsWith(prepend('/app/elasticsearch/indices')) ||
pathNameSerialized.startsWith(prepend('/app/elasticsearch/start'))
);
export const navigationTree = ({ isAppRegistered }: ApplicationStart): NavigationTreeDefinition => {
function isAvailable<T>(appId: string, content: T): T[] {
return isAppRegistered(appId) ? [content] : [];
}
return {
body: [
{
type: 'navGroup',
id: 'search_project_nav',
title: 'Elasticsearch',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
isCollapsible: false,
breadcrumbStatus: 'hidden',
children: [
{
id: 'data',
title: i18n.translate('xpack.serverlessSearch.nav.data', {
defaultMessage: 'Data',
}),
spaceBefore: 'm',
children: [
{
title: i18n.translate('xpack.serverlessSearch.nav.content.indices', {
defaultMessage: 'Index Management',
}),
link: 'management:index_management',
breadcrumbStatus:
'hidden' /* management sub-pages set their breadcrumbs themselves */,
getIsActive: ({ pathNameSerialized, prepend }) => {
return (
pathNameSerialized.startsWith(
prepend('/app/management/data/index_management/')
) ||
pathNameSerialized.startsWith(prepend('/app/elasticsearch/indices')) ||
pathNameSerialized.startsWith(prepend('/app/elasticsearch/start'))
);
},
},
},
{
title: CONNECTORS_LABEL,
link: 'serverlessConnectors',
},
],
},
{
id: 'build',
title: i18n.translate('xpack.serverlessSearch.nav.build', {
defaultMessage: 'Build',
}),
spaceBefore: 'm',
children: [
{
id: 'dev_tools',
title: i18n.translate('xpack.serverlessSearch.nav.devTools', {
defaultMessage: 'Dev Tools',
}),
link: 'dev_tools',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dev_tools'));
{
title: CONNECTORS_LABEL,
link: 'serverlessConnectors',
},
},
{
id: 'searchPlayground',
title: i18n.translate('xpack.serverlessSearch.nav.build.searchPlayground', {
defaultMessage: 'Playground',
}),
link: 'searchPlayground',
},
],
},
{
id: 'relevance',
title: i18n.translate('xpack.serverlessSearch.nav.relevance', {
defaultMessage: 'Relevance',
}),
spaceBefore: 'm',
children: [
{
id: 'searchInferenceEndpoints',
title: i18n.translate(
'xpack.serverlessSearch.nav.relevance.searchInferenceEndpoints',
{
defaultMessage: 'Inference Endpoints',
}
),
link: 'searchInferenceEndpoints',
},
],
},
{
id: 'analyze',
title: i18n.translate('xpack.serverlessSearch.nav.analyze', {
defaultMessage: 'Analyze',
}),
spaceBefore: 'm',
children: [
{
link: 'discover',
},
{
link: 'dashboards',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dashboards'));
],
},
{
id: 'build',
title: i18n.translate('xpack.serverlessSearch.nav.build', {
defaultMessage: 'Build',
}),
spaceBefore: 'm',
children: [
{
id: 'dev_tools',
title: i18n.translate('xpack.serverlessSearch.nav.devTools', {
defaultMessage: 'Dev Tools',
}),
link: 'dev_tools',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dev_tools'));
},
},
},
],
},
{
id: 'otherTools',
title: i18n.translate('xpack.serverlessSearch.nav.otherTools', {
defaultMessage: 'Other tools',
}),
spaceBefore: 'm',
children: [{ link: 'maps' }],
},
],
},
],
footer: [
{
id: 'gettingStarted',
type: 'navItem',
title: i18n.translate('xpack.serverlessSearch.nav.gettingStarted', {
defaultMessage: 'Getting Started',
}),
link: 'serverlessElasticsearch',
icon: 'launch',
},
{
type: 'navGroup',
id: 'project_settings_project_nav',
title: i18n.translate('xpack.serverlessSearch.nav.projectSettings', {
defaultMessage: 'Project settings',
}),
icon: 'gear',
breadcrumbStatus: 'hidden',
children: [
{
link: 'ml:modelManagement',
title: i18n.translate('xpack.serverlessSearch.nav.trainedModels', {
defaultMessage: 'Trained models',
}),
},
{
link: 'management',
title: i18n.translate('xpack.serverlessSearch.nav.mngt', {
defaultMessage: 'Management',
}),
},
{
id: 'cloudLinkUserAndRoles',
cloudLink: 'userAndRoles',
},
{
id: 'cloudLinkDeployment',
cloudLink: 'deployment',
title: i18n.translate('xpack.serverlessSearch.nav.performance', {
defaultMessage: 'Performance',
}),
},
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
},
],
});
...isAvailable('searchPlayground', {
id: 'searchPlayground',
title: i18n.translate('xpack.serverlessSearch.nav.build.searchPlayground', {
defaultMessage: 'Playground',
}),
link: 'searchPlayground' as AppDeepLinkId,
}),
],
},
{
id: 'relevance',
title: i18n.translate('xpack.serverlessSearch.nav.relevance', {
defaultMessage: 'Relevance',
}),
spaceBefore: 'm',
children: [
{
id: 'searchInferenceEndpoints',
title: i18n.translate(
'xpack.serverlessSearch.nav.relevance.searchInferenceEndpoints',
{
defaultMessage: 'Inference Endpoints',
}
),
link: 'searchInferenceEndpoints',
},
],
},
{
id: 'analyze',
title: i18n.translate('xpack.serverlessSearch.nav.analyze', {
defaultMessage: 'Analyze',
}),
spaceBefore: 'm',
children: [
{
link: 'discover',
},
{
link: 'dashboards',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dashboards'));
},
},
],
},
{
id: 'otherTools',
title: i18n.translate('xpack.serverlessSearch.nav.otherTools', {
defaultMessage: 'Other tools',
}),
spaceBefore: 'm',
children: [{ link: 'maps' }],
},
],
},
],
footer: [
{
id: 'gettingStarted',
type: 'navItem',
title: i18n.translate('xpack.serverlessSearch.nav.gettingStarted', {
defaultMessage: 'Getting Started',
}),
link: 'serverlessElasticsearch',
icon: 'launch',
},
{
type: 'navGroup',
id: 'project_settings_project_nav',
title: i18n.translate('xpack.serverlessSearch.nav.projectSettings', {
defaultMessage: 'Project settings',
}),
icon: 'gear',
breadcrumbStatus: 'hidden',
children: [
{
link: 'ml:modelManagement',
title: i18n.translate('xpack.serverlessSearch.nav.trainedModels', {
defaultMessage: 'Trained models',
}),
},
{
link: 'management',
title: i18n.translate('xpack.serverlessSearch.nav.mngt', {
defaultMessage: 'Management',
}),
},
{
id: 'cloudLinkUserAndRoles',
cloudLink: 'userAndRoles',
},
{
id: 'cloudLinkDeployment',
cloudLink: 'deployment',
title: i18n.translate('xpack.serverlessSearch.nav.performance', {
defaultMessage: 'Performance',
}),
},
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
},
],
};
};

View file

@ -148,7 +148,7 @@ export class ServerlessSearchPlugin
const { serverless, management, indexManagement, security } = services;
serverless.setProjectHome(services.searchIndices.startRoute);
const navigationTree$ = of(navigationTree());
const navigationTree$ = of(navigationTree(core.application));
serverless.initNavigation('es', navigationTree$, { dataTestSubj: 'svlSearchSideNav' });
const extendCardNavDefinitions = serverless.getNavigationCards(

View file

@ -8,7 +8,6 @@
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { ConsolePluginStart } from '@kbn/console-plugin/public';
import type { SearchInferenceEndpointsPluginStart } from '@kbn/search-inference-endpoints/public';
import type { SearchPlaygroundPluginStart } from '@kbn/search-playground/public';
import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public';
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public';
@ -37,7 +36,6 @@ export interface ServerlessSearchPluginSetupDependencies {
export interface ServerlessSearchPluginStartDependencies {
cloud: CloudStart;
console: ConsolePluginStart;
searchPlayground: SearchPlaygroundPluginStart;
searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart;
management: ManagementStart;
security: SecurityPluginStart;

View file

@ -47,7 +47,6 @@
"@kbn/search-connectors-plugin",
"@kbn/index-management-shared-types",
"@kbn/react-kibana-context-render",
"@kbn/search-playground",
"@kbn/security-api-key-management",
"@kbn/search-inference-endpoints",
"@kbn/security-plugin-types-common",
@ -55,5 +54,6 @@
"@kbn/core-http-server",
"@kbn/logging",
"@kbn/security-plugin-types-public",
"@kbn/core-application-browser",
]
}

View file

@ -24,6 +24,7 @@ export const getDefaultServicesApplication = (
navigateToApp: async (app: string) => {
action(`Navigate to: ${app}`);
},
isAppRegistered: (appId: string) => true,
getUrlForApp: (url: string) => url,
capabilities: getDefaultCapabilities(),
applications$: of(applications),