diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 1ab7ab60d4a8e..edc1821f3b223 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -44,6 +44,10 @@ as uiSettings within the code. |Console provides the user with tools for storing and executing requests against Elasticsearch. +|{kib-repo}blob/{branch}/src/plugins/custom_integrations/README.md[customIntegrations] +|Register add-data cards + + |<> |- Registers the dashboard application. - Adds a dashboard embeddable that can be used in other applications. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index d4e1a2b36be74..3b6cc7f249931 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -115,3 +115,4 @@ pageLoadAssetSize: expressionTagcloud: 27505 expressions: 239290 securitySolution: 231753 + customIntegrations: 28810 diff --git a/src/plugins/custom_integrations/README.md b/src/plugins/custom_integrations/README.md new file mode 100755 index 0000000000000..e7af518e21ec1 --- /dev/null +++ b/src/plugins/custom_integrations/README.md @@ -0,0 +1,9 @@ +# customIntegrations + +Register add-data cards + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts new file mode 100755 index 0000000000000..24ed44f3e5cfe --- /dev/null +++ b/src/plugins/custom_integrations/common/index.ts @@ -0,0 +1,61 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'customIntegrations'; +export const PLUGIN_NAME = 'customIntegrations'; + +export interface CategoryCount { + count: number; + id: Category; +} + +export const CATEGORY_DISPLAY = { + aws: 'AWS', + azure: 'Azure', + cloud: 'Cloud', + config_management: 'Config management', + containers: 'Containers', + crm: 'CRM', + custom: 'Custom', + datastore: 'Datastore', + elastic_stack: 'Elastic Stack', + google_cloud: 'Google cloud', + kubernetes: 'Kubernetes', + languages: 'Languages', + message_queue: 'Message queue', + monitoring: 'Monitoring', + network: 'Network', + notification: 'Notification', + os_system: 'OS & System', + productivity: 'Productivity', + security: 'Security', + sample_data: 'Sample data', + support: 'Support', + ticketing: 'Ticketing', + version_control: 'Version control', + web: 'Web', + upload_file: 'Upload a file', + + updates_available: 'Updates available', +}; + +export type Category = keyof typeof CATEGORY_DISPLAY; + +export interface CustomIntegration { + id: string; + title: string; + description: string; + type: 'ui_link'; + uiInternalPath: string; + isBeta: boolean; + icons: Array<{ src: string; type: string }>; + categories: Category[]; + shipper: string; +} + +export const ROUTES_ADDABLECUSTOMINTEGRATIONS = `/api/${PLUGIN_ID}/appendCustomIntegrations`; diff --git a/src/plugins/custom_integrations/jest.config.js b/src/plugins/custom_integrations/jest.config.js new file mode 100644 index 0000000000000..15aed3f602073 --- /dev/null +++ b/src/plugins/custom_integrations/jest.config.js @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/custom_integrations'], + testRunner: 'jasmine2', + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/custom_integrations', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/src/plugins/data/{common,public,server}/**/*.{ts,tsx}'], +}; diff --git a/src/plugins/custom_integrations/kibana.json b/src/plugins/custom_integrations/kibana.json new file mode 100755 index 0000000000000..3a78270d9ef09 --- /dev/null +++ b/src/plugins/custom_integrations/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "customIntegrations", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Fleet", + "githubTeam": "fleet" + }, + "description": "Add custom data integrations so they can be displayed in the Fleet integrations app", + "ui": true, + "server": true, + "extraPublicDirs": [ + "common" + ], + "optionalPlugins": [] +} diff --git a/src/plugins/custom_integrations/public/index.ts b/src/plugins/custom_integrations/public/index.ts new file mode 100755 index 0000000000000..9e979dd6692bc --- /dev/null +++ b/src/plugins/custom_integrations/public/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationsPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin() { + return new CustomIntegrationsPlugin(); +} +export { CustomIntegrationsSetup, CustomIntegrationsStart } from './types'; diff --git a/src/plugins/custom_integrations/public/mocks.ts b/src/plugins/custom_integrations/public/mocks.ts new file mode 100644 index 0000000000000..e6462751368a3 --- /dev/null +++ b/src/plugins/custom_integrations/public/mocks.ts @@ -0,0 +1,21 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationsSetup } from './types'; + +function createCustomIntegrationsSetup(): jest.Mocked { + const mock = { + getAppendCustomIntegrations: jest.fn(), + }; + + return mock; +} + +export const customIntegrationsMock = { + createSetup: createCustomIntegrationsSetup, +}; diff --git a/src/plugins/custom_integrations/public/plugin.test.ts b/src/plugins/custom_integrations/public/plugin.test.ts new file mode 100644 index 0000000000000..32f25754fe8e0 --- /dev/null +++ b/src/plugins/custom_integrations/public/plugin.test.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationsPlugin } from './plugin'; + +import { coreMock } from '../../../core/public/mocks'; + +describe('CustomIntegrationsPlugin', () => { + beforeEach(() => {}); + + describe('setup', () => { + let mockCoreSetup: ReturnType; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + }); + + test('wires up tutorials provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { + const setup = new CustomIntegrationsPlugin().setup(mockCoreSetup); + expect(setup).toHaveProperty('getAppendCustomIntegrations'); + }); + }); +}); diff --git a/src/plugins/custom_integrations/public/plugin.ts b/src/plugins/custom_integrations/public/plugin.ts new file mode 100755 index 0000000000000..821c08ce84e31 --- /dev/null +++ b/src/plugins/custom_integrations/public/plugin.ts @@ -0,0 +1,30 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CustomIntegrationsSetup, CustomIntegrationsStart } from './types'; +import { CustomIntegration, ROUTES_ADDABLECUSTOMINTEGRATIONS } from '../common'; + +export class CustomIntegrationsPlugin + implements Plugin +{ + public setup(core: CoreSetup): CustomIntegrationsSetup { + // Return methods that should be available to other plugins + return { + async getAppendCustomIntegrations(): Promise { + return core.http.get(ROUTES_ADDABLECUSTOMINTEGRATIONS); + }, + } as CustomIntegrationsSetup; + } + + public start(core: CoreStart): CustomIntegrationsStart { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/custom_integrations/public/types.ts b/src/plugins/custom_integrations/public/types.ts new file mode 100755 index 0000000000000..911194171b4c4 --- /dev/null +++ b/src/plugins/custom_integrations/public/types.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegration } from '../common'; + +export interface CustomIntegrationsSetup { + getAppendCustomIntegrations: () => Promise; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CustomIntegrationsStart {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AppPluginStartDependencies {} diff --git a/src/plugins/custom_integrations/server/custom_integration_registry.test.ts b/src/plugins/custom_integrations/server/custom_integration_registry.test.ts new file mode 100644 index 0000000000000..2e211cfb4c93d --- /dev/null +++ b/src/plugins/custom_integrations/server/custom_integration_registry.test.ts @@ -0,0 +1,117 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationRegistry } from './custom_integration_registry'; +import { loggerMock, MockedLogger } from '@kbn/logging/mocks'; +import { CustomIntegration } from '../common'; + +describe('CustomIntegrationsRegistry', () => { + let mockLogger: MockedLogger; + + const integration: CustomIntegration = { + id: 'foo', + title: 'Foo', + description: 'test integration', + type: 'ui_link', + uiInternalPath: '/path/to/foo', + isBeta: false, + icons: [], + categories: ['upload_file'], + shipper: 'tests', + }; + + beforeEach(() => { + mockLogger = loggerMock.create(); + }); + + describe('register', () => { + describe('should log to console on duplicate id', () => { + test('with an error in dev', () => { + const registry = new CustomIntegrationRegistry(mockLogger, true); + registry.registerCustomIntegration(integration); + registry.registerCustomIntegration(integration); + expect(mockLogger.error.mock.calls.length).toBe(1); + }); + test('with a debug in prod', () => { + const registry = new CustomIntegrationRegistry(mockLogger, false); + registry.registerCustomIntegration(integration); + registry.registerCustomIntegration(integration); + expect(mockLogger.debug.mock.calls.length).toBe(1); + }); + }); + }); + + describe('getAppendCustomCategories', () => { + test('should return', () => { + const registry = new CustomIntegrationRegistry(mockLogger, true); + registry.registerCustomIntegration(integration); + registry.registerCustomIntegration({ ...integration, id: 'bar' }); + expect(registry.getAppendCustomIntegrations()).toEqual([ + { + categories: ['upload_file'], + description: 'test integration', + icons: [], + id: 'foo', + isBeta: false, + shipper: 'tests', + title: 'Foo', + type: 'ui_link', + uiInternalPath: '/path/to/foo', + }, + { + categories: ['upload_file'], + description: 'test integration', + icons: [], + id: 'bar', + isBeta: false, + shipper: 'tests', + title: 'Foo', + type: 'ui_link', + uiInternalPath: '/path/to/foo', + }, + ]); + }); + test('should ignore duplicate ids', () => { + const registry = new CustomIntegrationRegistry(mockLogger, true); + registry.registerCustomIntegration(integration); + registry.registerCustomIntegration(integration); + expect(registry.getAppendCustomIntegrations()).toEqual([ + { + categories: ['upload_file'], + description: 'test integration', + icons: [], + id: 'foo', + isBeta: false, + shipper: 'tests', + title: 'Foo', + type: 'ui_link', + uiInternalPath: '/path/to/foo', + }, + ]); + }); + test('should ignore integrations without category', () => { + const registry = new CustomIntegrationRegistry(mockLogger, true); + registry.registerCustomIntegration(integration); + registry.registerCustomIntegration({ ...integration, id: 'bar', categories: [] }); + + expect(registry.getAppendCustomIntegrations()).toEqual([ + { + categories: ['upload_file'], + description: 'test integration', + icons: [], + id: 'foo', + isBeta: false, + shipper: 'tests', + title: 'Foo', + type: 'ui_link', + uiInternalPath: '/path/to/foo', + }, + ]); + }); + }); +}); diff --git a/src/plugins/custom_integrations/server/custom_integration_registry.ts b/src/plugins/custom_integrations/server/custom_integration_registry.ts new file mode 100644 index 0000000000000..fa216ced5bd92 --- /dev/null +++ b/src/plugins/custom_integrations/server/custom_integration_registry.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Logger } from 'kibana/server'; +import { CustomIntegration } from '../common'; + +function isAddable(integration: CustomIntegration) { + return integration.categories.length; +} + +export class CustomIntegrationRegistry { + private readonly _integrations: CustomIntegration[]; + private readonly _logger: Logger; + private readonly _isDev: boolean; + + constructor(logger: Logger, isDev: boolean) { + this._integrations = []; + this._logger = logger; + this._isDev = isDev; + } + + registerCustomIntegration(customIntegration: CustomIntegration) { + if ( + this._integrations.some((integration: CustomIntegration) => { + return integration.id === customIntegration.id; + }) + ) { + const message = `Integration with id=${customIntegration.id} already exists.`; + if (this._isDev) { + this._logger.error(message); + } else { + this._logger.debug(message); + } + return; + } + + this._integrations.push(customIntegration); + } + + getAppendCustomIntegrations(): CustomIntegration[] { + return this._integrations.filter(isAddable); + } +} diff --git a/src/plugins/custom_integrations/server/index.ts b/src/plugins/custom_integrations/server/index.ts new file mode 100755 index 0000000000000..423a06009ac4b --- /dev/null +++ b/src/plugins/custom_integrations/server/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { PluginInitializerContext } from '../../../core/server'; +import { CustomIntegrationsPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new CustomIntegrationsPlugin(initializerContext); +} + +export { CustomIntegrationsPluginSetup, CustomIntegrationsPluginStart } from './types'; + +export type { Category, CategoryCount, CustomIntegration } from '../common'; + +export const config = { + schema: schema.object({}), +}; diff --git a/src/plugins/custom_integrations/server/mocks.ts b/src/plugins/custom_integrations/server/mocks.ts new file mode 100644 index 0000000000000..661c7e567aef6 --- /dev/null +++ b/src/plugins/custom_integrations/server/mocks.ts @@ -0,0 +1,24 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { MockedKeys } from '@kbn/utility-types/jest'; + +import { CustomIntegrationsPluginSetup } from '../server'; + +function createCustomIntegrationsSetup(): MockedKeys { + const mock = { + registerCustomIntegration: jest.fn(), + getAppendCustomIntegrations: jest.fn(), + }; + + return mock as MockedKeys; +} + +export const customIntegrationsMock = { + createSetup: createCustomIntegrationsSetup, +}; diff --git a/src/plugins/custom_integrations/server/plugin.test.ts b/src/plugins/custom_integrations/server/plugin.test.ts new file mode 100644 index 0000000000000..08f68a70a3c70 --- /dev/null +++ b/src/plugins/custom_integrations/server/plugin.test.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegrationsPlugin } from './plugin'; + +import { coreMock } from '../../../core/server/mocks'; + +describe('CustomIntegrationsPlugin', () => { + beforeEach(() => {}); + + describe('setup', () => { + let mockCoreSetup: ReturnType; + let initContext: ReturnType; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + initContext = coreMock.createPluginInitializerContext(); + }); + + test('wires up tutorials provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { + const setup = new CustomIntegrationsPlugin(initContext).setup(mockCoreSetup); + expect(setup).toHaveProperty('registerCustomIntegration'); + expect(setup).toHaveProperty('getAppendCustomIntegrations'); + }); + }); +}); diff --git a/src/plugins/custom_integrations/server/plugin.ts b/src/plugins/custom_integrations/server/plugin.ts new file mode 100755 index 0000000000000..f1ddd70b6945a --- /dev/null +++ b/src/plugins/custom_integrations/server/plugin.ts @@ -0,0 +1,55 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from 'kibana/server'; + +import { CustomIntegrationsPluginSetup, CustomIntegrationsPluginStart } from './types'; +import { CustomIntegration } from '../common'; +import { CustomIntegrationRegistry } from './custom_integration_registry'; +import { defineRoutes } from './routes/define_routes'; + +export class CustomIntegrationsPlugin + implements Plugin +{ + private readonly logger: Logger; + private readonly customIngegrationRegistry: CustomIntegrationRegistry; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.customIngegrationRegistry = new CustomIntegrationRegistry( + this.logger, + initializerContext.env.mode.dev + ); + } + + public setup(core: CoreSetup) { + this.logger.debug('customIntegrations: Setup'); + + const router = core.http.createRouter(); + defineRoutes(router, this.customIngegrationRegistry); + + return { + registerCustomIntegration: (integration: Omit) => { + this.customIngegrationRegistry.registerCustomIntegration({ + type: 'ui_link', + ...integration, + }); + }, + getAppendCustomIntegrations: (): CustomIntegration[] => { + return this.customIngegrationRegistry.getAppendCustomIntegrations(); + }, + } as CustomIntegrationsPluginSetup; + } + + public start(core: CoreStart) { + this.logger.debug('customIntegrations: Started'); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/custom_integrations/server/routes/define_routes.ts b/src/plugins/custom_integrations/server/routes/define_routes.ts new file mode 100644 index 0000000000000..f5e952a0c1ebd --- /dev/null +++ b/src/plugins/custom_integrations/server/routes/define_routes.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IRouter } from 'src/core/server'; +import { CustomIntegrationRegistry } from '../custom_integration_registry'; +import { ROUTES_ADDABLECUSTOMINTEGRATIONS } from '../../common'; + +export function defineRoutes( + router: IRouter, + customIntegrationsRegistry: CustomIntegrationRegistry +) { + router.get( + { + path: ROUTES_ADDABLECUSTOMINTEGRATIONS, + validate: false, + }, + async (context, request, response) => { + const integrations = customIntegrationsRegistry.getAppendCustomIntegrations(); + return response.ok({ + body: integrations, + }); + } + ); +} diff --git a/src/plugins/custom_integrations/server/types.ts b/src/plugins/custom_integrations/server/types.ts new file mode 100755 index 0000000000000..b21bfd157a96e --- /dev/null +++ b/src/plugins/custom_integrations/server/types.ts @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomIntegration } from '../common'; + +export interface CustomIntegrationsPluginSetup { + registerCustomIntegration(customIntegration: Omit): void; + getAppendCustomIntegrations(): CustomIntegration[]; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CustomIntegrationsPluginStart {} diff --git a/src/plugins/custom_integrations/tsconfig.json b/src/plugins/custom_integrations/tsconfig.json new file mode 100644 index 0000000000000..2ce7bf9c8112c --- /dev/null +++ b/src/plugins/custom_integrations/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "public/**/*", "server/**/*"], + "references": [ + { "path": "../../core/tsconfig.json" } + ] +} diff --git a/src/plugins/home/kibana.json b/src/plugins/home/kibana.json index b3bd915bee143..3f1916f3142ff 100644 --- a/src/plugins/home/kibana.json +++ b/src/plugins/home/kibana.json @@ -8,6 +8,6 @@ "server": true, "ui": true, "requiredPlugins": ["data", "share", "urlForwarding"], - "optionalPlugins": ["usageCollection", "telemetry"], + "optionalPlugins": ["usageCollection", "telemetry", "customIntegrations"], "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/home/server/plugin.test.ts b/src/plugins/home/server/plugin.test.ts index 88e07970cc440..fdf671e10ad09 100644 --- a/src/plugins/home/server/plugin.test.ts +++ b/src/plugins/home/server/plugin.test.ts @@ -7,38 +7,51 @@ */ import { registryForTutorialsMock, registryForSampleDataMock } from './plugin.test.mocks'; -import { HomeServerPlugin } from './plugin'; +import { HomeServerPlugin, HomeServerPluginSetupDependencies } from './plugin'; import { coreMock, httpServiceMock } from '../../../core/server/mocks'; +import { customIntegrationsMock } from '../../custom_integrations/server/mocks'; describe('HomeServerPlugin', () => { + let homeServerPluginSetupDependenciesMock: HomeServerPluginSetupDependencies; + let mockCoreSetup: ReturnType; + beforeEach(() => { registryForTutorialsMock.setup.mockClear(); registryForTutorialsMock.start.mockClear(); registryForSampleDataMock.setup.mockClear(); registryForSampleDataMock.start.mockClear(); + + homeServerPluginSetupDependenciesMock = { + customIntegrations: customIntegrationsMock.createSetup(), + }; + mockCoreSetup = coreMock.createSetup(); }); describe('setup', () => { - let mockCoreSetup: ReturnType; let initContext: ReturnType; let routerMock: ReturnType; beforeEach(() => { - mockCoreSetup = coreMock.createSetup(); routerMock = httpServiceMock.createRouter(); mockCoreSetup.http.createRouter.mockReturnValue(routerMock); initContext = coreMock.createPluginInitializerContext(); }); test('wires up tutorials provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { - const setup = new HomeServerPlugin(initContext).setup(mockCoreSetup, {}); + const setup = new HomeServerPlugin(initContext).setup( + mockCoreSetup, + homeServerPluginSetupDependenciesMock + ); expect(setup).toHaveProperty('tutorials'); expect(setup.tutorials).toHaveProperty('registerTutorial'); expect(setup.tutorials).toHaveProperty('addScopedTutorialContextFactory'); }); test('wires up sample data provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { - const setup = new HomeServerPlugin(initContext).setup(mockCoreSetup, {}); + const setup = new HomeServerPlugin(initContext).setup( + mockCoreSetup, + homeServerPluginSetupDependenciesMock + ); expect(setup).toHaveProperty('sampleData'); expect(setup.sampleData).toHaveProperty('registerSampleDataset'); expect(setup.sampleData).toHaveProperty('getSampleDatasets'); @@ -48,7 +61,7 @@ describe('HomeServerPlugin', () => { }); test('registers the `/api/home/hits_status` route', () => { - new HomeServerPlugin(initContext).setup(mockCoreSetup, {}); + new HomeServerPlugin(initContext).setup(mockCoreSetup, homeServerPluginSetupDependenciesMock); expect(routerMock.post).toHaveBeenCalledTimes(1); expect(routerMock.post).toHaveBeenCalledWith( @@ -63,7 +76,9 @@ describe('HomeServerPlugin', () => { describe('start', () => { const initContext = coreMock.createPluginInitializerContext(); test('is defined', () => { - const start = new HomeServerPlugin(initContext).start(); + const plugin = new HomeServerPlugin(initContext); + plugin.setup(mockCoreSetup, homeServerPluginSetupDependenciesMock); // setup() must always be called before start() + const start = plugin.start(); expect(start).toBeDefined(); expect(start).toHaveProperty('tutorials'); expect(start).toHaveProperty('sampleData'); diff --git a/src/plugins/home/server/plugin.ts b/src/plugins/home/server/plugin.ts index a1463c4c8138b..7c830dd8d5bc3 100644 --- a/src/plugins/home/server/plugin.ts +++ b/src/plugins/home/server/plugin.ts @@ -19,9 +19,11 @@ import { UsageCollectionSetup } from '../../usage_collection/server'; import { capabilitiesProvider } from './capabilities_provider'; import { sampleDataTelemetry } from './saved_objects'; import { registerRoutes } from './routes'; +import { CustomIntegrationsPluginSetup } from '../../custom_integrations/server'; -interface HomeServerPluginSetupDependencies { +export interface HomeServerPluginSetupDependencies { usageCollection?: UsageCollectionSetup; + customIntegrations?: CustomIntegrationsPluginSetup; } export class HomeServerPlugin implements Plugin { @@ -37,7 +39,7 @@ export class HomeServerPlugin implements Plugin; diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts index a82699c231ad4..5c7ec0a3382bf 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts @@ -18,6 +18,8 @@ import { TutorialsCategory, ScopedTutorialContextFactory, } from './lib/tutorials_registry_types'; +import { CustomIntegrationsPluginSetup } from '../../../../custom_integrations/server'; +import { customIntegrationsMock } from '../../../../custom_integrations/server/mocks'; const INVALID_TUTORIAL: TutorialSchema = { id: 'test', @@ -69,6 +71,11 @@ describe('TutorialsRegistry', () => { let mockCoreSetup: MockedKeys; let testProvider: TutorialProvider; let testScopedTutorialContextFactory: ScopedTutorialContextFactory; + let mockCustomIntegrationsPluginSetup: MockedKeys; + + beforeEach(() => { + mockCustomIntegrationsPluginSetup = customIntegrationsMock.createSetup(); + }); describe('GET /api/kibana/home/tutorials', () => { beforeEach(() => { @@ -83,13 +90,13 @@ describe('TutorialsRegistry', () => { describe('setup', () => { test('exposes proper contract', () => { - const setup = new TutorialsRegistry().setup(mockCoreSetup); + const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup); expect(setup).toHaveProperty('registerTutorial'); expect(setup).toHaveProperty('addScopedTutorialContextFactory'); }); test('registerTutorial throws when registering a tutorial with an invalid schema', () => { - const setup = new TutorialsRegistry().setup(mockCoreSetup); + const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup); testProvider = ({}) => invalidTutorialProvider; expect(() => setup.registerTutorial(testProvider)).toThrowErrorMatchingInlineSnapshot( `"Unable to register tutorial spec because its invalid. Error: [name]: is not allowed to be empty"` @@ -97,13 +104,34 @@ describe('TutorialsRegistry', () => { }); test('registerTutorial registers a tutorial with a valid schema', () => { - const setup = new TutorialsRegistry().setup(mockCoreSetup); + const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup); testProvider = ({}) => validTutorialProvider; expect(() => setup.registerTutorial(testProvider)).not.toThrowError(); + + // @ts-expect-error + expect(mockCustomIntegrationsPluginSetup.registerCustomIntegration.mock.calls).toEqual([ + [ + { + id: 'test', + title: 'new tutorial provider', + categories: [], + uiInternalPath: '/app/home#/tutorial/test', + description: 'short description', + icons: [ + { + src: 'alert', + type: 'eui', + }, + ], + shipper: 'tutorial', + isBeta: false, + }, + ], + ]); }); test('addScopedTutorialContextFactory throws when given a scopedTutorialContextFactory that is not a function', () => { - const setup = new TutorialsRegistry().setup(mockCoreSetup); + const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup); const testItem = {} as TutorialProvider; expect(() => setup.addScopedTutorialContextFactory(testItem) @@ -113,7 +141,7 @@ describe('TutorialsRegistry', () => { }); test('addScopedTutorialContextFactory adds a scopedTutorialContextFactory when given a function', () => { - const setup = new TutorialsRegistry().setup(mockCoreSetup); + const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup); testScopedTutorialContextFactory = ({}) => 'string'; expect(() => setup.addScopedTutorialContextFactory(testScopedTutorialContextFactory) diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.ts index 05f5600af307a..8f7ecd7d7ccf5 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.ts @@ -12,14 +12,46 @@ import { TutorialContextFactory, ScopedTutorialContextFactory, } from './lib/tutorials_registry_types'; -import { tutorialSchema } from './lib/tutorial_schema'; +import { TutorialSchema, tutorialSchema } from './lib/tutorial_schema'; import { builtInTutorials } from '../../tutorials/register'; +import { CustomIntegrationsPluginSetup } from '../../../../custom_integrations/server'; +import { Category, CATEGORY_DISPLAY } from '../../../../custom_integrations/common'; +import { HOME_APP_BASE_PATH } from '../../../common/constants'; + +function registerTutorialWithCustomIntegrations( + customIntegrations: CustomIntegrationsPluginSetup, + tutorial: TutorialSchema +) { + const allowedCategories: Category[] = (tutorial.integrationBrowserCategories ?? []).filter( + (category) => { + return CATEGORY_DISPLAY.hasOwnProperty(category); + } + ) as Category[]; + + customIntegrations.registerCustomIntegration({ + id: tutorial.id, + title: tutorial.name, + categories: allowedCategories, + uiInternalPath: `${HOME_APP_BASE_PATH}#/tutorial/${tutorial.id}`, + description: tutorial.shortDescription, + icons: tutorial.euiIconType + ? [ + { + type: 'eui', + src: tutorial.euiIconType, + }, + ] + : [], + shipper: 'tutorial', + isBeta: false, + }); +} export class TutorialsRegistry { private tutorialProviders: TutorialProvider[] = []; // pre-register all the tutorials we know we want in here private readonly scopedTutorialContextFactories: TutorialContextFactory[] = []; - public setup(core: CoreSetup) { + public setup(core: CoreSetup, customIntegrations?: CustomIntegrationsPluginSetup) { const router = core.http.createRouter(); router.get( { path: '/api/kibana/home/tutorials', validate: false }, @@ -31,7 +63,6 @@ export class TutorialsRegistry { }, initialContext ); - return res.ok({ body: this.tutorialProviders.map((tutorialProvider) => { return tutorialProvider(scopedContext); // All the tutorialProviders need to be refactored so that they don't need the server. @@ -41,13 +72,17 @@ export class TutorialsRegistry { ); return { registerTutorial: (specProvider: TutorialProvider) => { + const emptyContext = {}; + let tutorial: TutorialSchema; try { - const emptyContext = {}; - tutorialSchema.validate(specProvider(emptyContext)); + tutorial = tutorialSchema.validate(specProvider(emptyContext)); } catch (error) { throw new Error(`Unable to register tutorial spec because its invalid. ${error}`); } + if (customIntegrations && tutorial) { + registerTutorialWithCustomIntegrations(customIntegrations, tutorial); + } this.tutorialProviders.push(specProvider); }, diff --git a/src/plugins/home/tsconfig.json b/src/plugins/home/tsconfig.json index f43c40e35349d..fa98b98ff8e1c 100644 --- a/src/plugins/home/tsconfig.json +++ b/src/plugins/home/tsconfig.json @@ -11,6 +11,7 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, + { "path": "../custom_integrations/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, { "path": "../share/tsconfig.json" }, { "path": "../url_forwarding/tsconfig.json" }, diff --git a/test/api_integration/apis/custom_integration/index.ts b/test/api_integration/apis/custom_integration/index.ts new file mode 100644 index 0000000000000..d3d34fc3ccfce --- /dev/null +++ b/test/api_integration/apis/custom_integration/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('custom integrations', () => { + loadTestFile(require.resolve('./integrations')); + }); +} diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts new file mode 100644 index 0000000000000..d8f098fdc1fcf --- /dev/null +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('get list of append integrations', () => { + it('should return list of custom integrations that can be appended', async () => { + const resp = await supertest + .get(`/api/customIntegrations/appendCustomIntegrations`) + .set('kbn-xsrf', 'kibana') + .expect(200); + + expect(resp.body).to.be.an('array'); + expect(resp.body.length).to.be.above(0); + }); + }); +} diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index 998c0b834d224..a6b8b746f68cf 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('apis', () => { loadTestFile(require.resolve('./console')); loadTestFile(require.resolve('./core')); + loadTestFile(require.resolve('./custom_integration')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); loadTestFile(require.resolve('./index_pattern_field_editor')); diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 66852bc965b07..371304765a5f8 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -361,6 +361,18 @@ export type PackageListItem = Installable & { id: string; }; +export interface IntegrationCardItem { + uiInternalPathUrl: string; + release?: 'beta' | 'experimental' | 'ga'; + description: string; + name: string; + title: string; + version: string; + icons: PackageSpecIcon[]; + integration: string; + id: string; +} + export type PackagesGroupedByStatus = Record, PackageList>; export type PackageInfo = | Installable> diff --git a/x-pack/plugins/fleet/kibana.json b/x-pack/plugins/fleet/kibana.json index f9ad1b0b966a4..c4782156b1982 100644 --- a/x-pack/plugins/fleet/kibana.json +++ b/x-pack/plugins/fleet/kibana.json @@ -8,7 +8,7 @@ "server": true, "ui": true, "configPath": ["xpack", "fleet"], - "requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation"], + "requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"], "extraPublicDirs": ["common"], "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"] diff --git a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx index 82a058906cee5..032554a4ec439 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_links.tsx @@ -6,7 +6,6 @@ */ import { useStartServices } from '../../../hooks/use_core'; -import { PLUGIN_ID } from '../../../constants'; import { epmRouteService } from '../../../services'; import type { PackageSpecIcon, PackageSpecScreenshot, RegistryImage } from '../../../../common'; @@ -16,7 +15,6 @@ const removeRelativePath = (relativePath: string): string => export function useLinks() { const { http } = useStartServices(); return { - toAssets: (path: string) => http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`), toSharedAssets: (path: string) => http.basePath.prepend(`/plugins/kibanaReact/assets/${path}`), toPackageImage: ( img: PackageSpecIcon | PackageSpecScreenshot | RegistryImage, diff --git a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx index 06c4bac5c7e8e..a8ed849f50a8a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx @@ -8,12 +8,12 @@ import { Search as LocalSearch, AllSubstringsIndexStrategy } from 'js-search'; import { useEffect, useRef } from 'react'; -import type { PackageList } from '../../../types'; +import type { IntegrationCardItem } from '../../../../common/types/models'; export const searchIdField = 'id'; export const fieldsToSearch = ['name', 'title', 'description']; -export function useLocalSearch(packageList: PackageList) { +export function useLocalSearch(packageList: IntegrationCardItem[]) { const localSearchRef = useRef(null); useEffect(() => { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx index e8814b8b8c877..69c70bba5be1d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx @@ -29,8 +29,9 @@ const args: Args = { release: 'ga', id: 'id', version: '1.0.0', - download: '/', - path: 'path', + uiInternalPathUrl: '/', + icons: [], + integration: '', }; const argTypes = { @@ -44,6 +45,8 @@ const argTypes = { export const NotInstalled = ({ width, ...props }: Args) => (
+ {/* + // @ts-ignore */}
); @@ -51,6 +54,7 @@ export const NotInstalled = ({ width, ...props }: Args) => ( export const Installed = ({ width, ...props }: Args) => { const savedObject: SavedObject = { id: props.id, + // @ts-expect-error type: props.type || '', attributes: { name: props.name, @@ -68,6 +72,8 @@ export const Installed = ({ width, ...props }: Args) => { return (
+ {/* + // @ts-ignore */}
); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index c2d6d0f1e028b..8c7cd47e950f0 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -9,13 +9,12 @@ import React from 'react'; import styled from 'styled-components'; import { EuiCard } from '@elastic/eui'; -import type { PackageListItem } from '../../../types'; -import { useLink } from '../../../hooks'; -import { PackageIcon } from '../../../components'; +import { CardIcon } from '../../../../../components/package_icon'; +import type { IntegrationCardItem } from '../../../../../../common/types/models/epm'; -import { RELEASE_BADGE_LABEL, RELEASE_BADGE_DESCRIPTION } from './release_badge'; +import { RELEASE_BADGE_DESCRIPTION, RELEASE_BADGE_LABEL } from './release_badge'; -export type PackageCardProps = PackageListItem; +export type PackageCardProps = IntegrationCardItem; // adding the `href` causes EuiCard to use a `a` instead of a `button` // `a` tags use `euiLinkColor` which results in blueish Badge text @@ -28,25 +27,21 @@ export function PackageCard({ name, title, version, - release, - status, icons, integration, - ...restProps + uiInternalPathUrl, + release, }: PackageCardProps) { - const { getHref } = useLink(); - let urlVersion = version; - // if this is an installed package, link to the version installed - if ('savedObject' in restProps) { - urlVersion = restProps.savedObject.attributes.version || version; - } + const betaBadgeLabel = release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined; + const betaBadgeLabelTooltipContent = + release && release !== 'ga' ? RELEASE_BADGE_DESCRIPTION[release] : undefined; return ( } - href={getHref('integration_details_overview', { - pkgkey: `${name}-${urlVersion}`, - ...(integration ? { integration } : {}), - })} - betaBadgeLabel={release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined} - betaBadgeTooltipContent={ - release && release !== 'ga' ? RELEASE_BADGE_DESCRIPTION[release] : undefined - } + href={uiInternalPathUrl} + betaBadgeLabel={betaBadgeLabel} + betaBadgeTooltipContent={betaBadgeLabelTooltipContent} /> ); } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx index d84e286b6f560..737a83d5f5da8 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx @@ -13,6 +13,8 @@ import type { SavedObject } from 'src/core/public'; import type { Installation } from '../../../../../../common'; +import type { IntegrationCardItem } from '../../../../../../common'; + import type { ListProps } from './package_list_grid'; import { PackageListGrid } from './package_list_grid'; @@ -57,77 +59,79 @@ export const EmptyList = (props: Args) => ( export const List = (props: Args) => ( void; onSearchChange: (search: string) => void; @@ -77,7 +78,7 @@ export function PackageListGrid({ } else { const filteredList = searchTerm ? list.filter((item) => - (localSearchRef.current!.search(searchTerm) as PackageList) + (localSearchRef.current!.search(searchTerm) as IntegrationCardItem[]) .map((match) => match[searchIdField]) .includes(item[searchIdField]) ) @@ -141,7 +142,7 @@ function ControlsColumn({ controls, title }: ControlsColumnProps) { } interface GridColumnProps { - list: PackageList; + list: IntegrationCardItem[]; showMissingIntegrationMessage?: boolean; } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx index e4d81f1d04118..daa0520ebc041 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx @@ -8,8 +8,18 @@ import { EuiFacetButton, EuiFacetGroup } from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; + import { Loading } from '../../../../components'; -import type { CategorySummaryItem, CategorySummaryList } from '../../../../types'; +import type { CategoryCount } from '../../../../../../../../../../src/plugins/custom_integrations/common'; +import { CATEGORY_DISPLAY } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +interface ALL_CATEGORY { + id: ''; + count: number; +} + +export type CategoryFacet = CategoryCount | ALL_CATEGORY; export function CategoryFacets({ isLoading, @@ -18,26 +28,41 @@ export function CategoryFacets({ onCategoryChange, }: { isLoading?: boolean; - categories: CategorySummaryList; + categories: CategoryFacet[]; selectedCategory: string; - onCategoryChange: (category: CategorySummaryItem) => unknown; + onCategoryChange: (category: CategoryFacet) => unknown; }) { const controls = ( {isLoading ? ( ) : ( - categories.map((category) => ( - onCategoryChange(category)} - > - {category.title} - - )) + categories.map((category) => { + let title; + + if (category.id === 'updates_available') { + title = i18n.translate('xpack.fleet.epmList.updatesAvailableFilterLinkText', { + defaultMessage: 'Updates available', + }); + } else if (category.id === '') { + title = i18n.translate('xpack.fleet.epmList.allPackagesFilterLinkText', { + defaultMessage: 'All', + }); + } else { + title = CATEGORY_DISPLAY[category.id]; + } + return ( + onCategoryChange(category)} + > + {title} + + ); + }) )} ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index 5e94fbda2c22a..48a9dc0f6b63c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -11,18 +11,35 @@ import semverLt from 'semver/functions/lt'; import { i18n } from '@kbn/i18n'; import { installationStatuses } from '../../../../../../../common/constants'; +import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../../constants'; import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM, pagePathGetters, } from '../../../../constants'; -import { useGetCategories, useGetPackages, useBreadcrumbs } from '../../../../hooks'; +import { + useGetCategories, + useGetPackages, + useBreadcrumbs, + useGetAddableCustomIntegrations, + useLink, +} from '../../../../hooks'; import { doesPackageHaveIntegrations } from '../../../../services'; import { DefaultLayout } from '../../../../layouts'; -import type { CategorySummaryItem, PackageList } from '../../../../types'; +import type { PackageList } from '../../../../types'; import { PackageListGrid } from '../../components/package_list_grid'; +import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import type { PackageListItem } from '../../../../types'; + +import type { IntegrationCardItem } from '../../../../../../../common/types/models'; + +import type { Category } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import { mergeAndReplaceCategoryCounts } from './util'; import { CategoryFacets } from './category_facets'; +import type { CategoryFacet } from './category_facets'; export interface CategoryParams { category?: string; @@ -36,10 +53,43 @@ function getParams(params: CategoryParams, search: string) { return { selectedCategory, searchParam }; } -function categoryExists(category: string, categories: CategorySummaryItem[]) { +function categoryExists(category: string, categories: CategoryFacet[]) { return categories.some((c) => c.id === category); } +function mapToCard( + getAbsolutePath: (p: string) => string, + getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => string, + item: CustomIntegration | PackageListItem +): IntegrationCardItem { + let uiInternalPathUrl; + if (item.type === 'ui_link') { + uiInternalPathUrl = getAbsolutePath(item.uiInternalPath); + } else { + let urlVersion = item.version; + if ('savedObject' in item) { + urlVersion = item.savedObject.attributes.version || item.version; + } + const url = getHref('integration_details_overview', { + pkgkey: `${item.name}-${urlVersion}`, + ...(item.integration ? { integration: item.integration } : {}), + }); + uiInternalPathUrl = url; + } + + return { + id: `${item.type === 'ui_link' ? 'ui_link' : 'epr'}-${item.id}`, + description: item.description, + icons: !item.icons || !item.icons.length ? [] : item.icons, + integration: 'integration' in item ? item.integration || '' : '', + name: 'name' in item ? item.name || '' : '', + title: item.title, + version: 'version' in item ? item.version || '' : '', + release: 'release' in item ? item.release : undefined, + uiInternalPathUrl, + }; +} + export const EPMHomePage: React.FC = memo(() => { return ( @@ -89,6 +139,7 @@ const InstalledPackages: React.FC = memo(() => { const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages({ experimental: true, }); + const { getHref, getAbsolutePath } = useLink(); const { selectedCategory, searchParam } = getParams( useParams(), @@ -103,7 +154,7 @@ const InstalledPackages: React.FC = memo(() => { history.push(url); } function setSearchTerm(search: string) { - // Use .replace so the browser's back button is tied to single keystroke + // Use .replace so the browser's back button is not tied to single keystroke history.replace( pagePathGetters.integrations_installed({ category: selectedCategory, @@ -135,20 +186,14 @@ const InstalledPackages: React.FC = memo(() => { [] ); - const categories = useMemo( + const categories: CategoryFacet[] = useMemo( () => [ { id: '', - title: i18n.translate('xpack.fleet.epmList.allFilterLinkText', { - defaultMessage: 'All', - }), count: allInstalledPackages.length, }, { id: 'updates_available', - title: i18n.translate('xpack.fleet.epmList.updatesAvailableFilterLinkText', { - defaultMessage: 'Updates available', - }), count: updatablePackages.length, }, ], @@ -166,10 +211,16 @@ const InstalledPackages: React.FC = memo(() => { setSelectedCategory(id)} + onCategoryChange={({ id }: CategoryFacet) => setSelectedCategory(id)} /> ); + const cards = ( + selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages + ).map((item) => { + return mapToCard(getAbsolutePath, getHref, item); + }); + return ( { onSearchChange={setSearchTerm} initialSearch={searchParam} title={title} - list={selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages} + list={cards} /> ); }); @@ -190,6 +241,8 @@ const AvailablePackages: React.FC = memo(() => { useLocation().search ); const history = useHistory(); + const { getHref, getAbsolutePath } = useLink(); + function setSelectedCategory(categoryId: string) { const url = pagePathGetters.integrations_all({ category: categoryId, @@ -198,7 +251,7 @@ const AvailablePackages: React.FC = memo(() => { history.push(url); } function setSearchTerm(search: string) { - // Use .replace so the browser's back button is tied to single keystroke + // Use .replace so the browser's back button is not tied to single keystroke history.replace( pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1] ); @@ -213,16 +266,27 @@ const AvailablePackages: React.FC = memo(() => { const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ include_policy_templates: true, }); - const packages = useMemo( + const eprPackages = useMemo( () => packageListToIntegrationsList(categoryPackagesRes?.response || []), [categoryPackagesRes] ); - const allPackages = useMemo( + const allEprPackages = useMemo( () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), [allCategoryPackagesRes] ); + const { loading: isLoadingAddableCustomIntegrations, value: addableCustomIntegrations } = + useGetAddableCustomIntegrations(); + const filteredAddableIntegrations = addableCustomIntegrations + ? addableCustomIntegrations.filter((integration: CustomIntegration) => { + if (!selectedCategory) { + return true; + } + return integration.categories.indexOf(selectedCategory as Category) >= 0; + }) + : []; + const title = useMemo( () => i18n.translate('xpack.fleet.epmList.allTitle', { @@ -231,19 +295,39 @@ const AvailablePackages: React.FC = memo(() => { [] ); - const categories = useMemo( - () => [ + const eprAndCustomPackages: Array = [ + ...eprPackages, + ...filteredAddableIntegrations, + ]; + eprAndCustomPackages.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + + const categories = useMemo(() => { + const eprAndCustomCategories: CategoryFacet[] = + isLoadingCategories || + isLoadingAddableCustomIntegrations || + !addableCustomIntegrations || + !categoriesRes + ? [] + : mergeAndReplaceCategoryCounts( + categoriesRes.response as CategoryFacet[], + addableCustomIntegrations + ); + return [ { id: '', - title: i18n.translate('xpack.fleet.epmList.allPackagesFilterLinkText', { - defaultMessage: 'All', - }), - count: allPackages?.length || 0, + count: (allEprPackages?.length || 0) + (addableCustomIntegrations?.length || 0), }, - ...(categoriesRes ? categoriesRes.response : []), - ], - [allPackages?.length, categoriesRes] - ); + ...(eprAndCustomCategories ? eprAndCustomCategories : []), + ] as CategoryFacet[]; + }, [ + allEprPackages?.length, + addableCustomIntegrations, + categoriesRes, + isLoadingAddableCustomIntegrations, + isLoadingCategories, + ]); if (!categoryExists(selectedCategory, categories)) { history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); @@ -252,22 +336,26 @@ const AvailablePackages: React.FC = memo(() => { const controls = categories ? ( { + onCategoryChange={({ id }: CategoryFacet) => { setSelectedCategory(id); }} /> ) : null; + const cards = eprAndCustomPackages.map((item) => { + return mapToCard(getAbsolutePath, getHref, item); + }); + return ( { + const match = merged.find((c) => { + return c.id === category; + }); + + if (match) { + match.count += count; + } else { + merged.push({ + id: category as Category, + count, + }); + } + }; + + eprCounts.forEach((facet) => { + addIfMissing(facet.id, facet.count); + }); + addableIntegrations.forEach((integration) => { + integration.categories.forEach((cat) => { + addIfMissing(cat, 1); + }); + }); + + merged.sort((a, b) => { + return a.id.localeCompare(b.id); + }); + + return merged; +} diff --git a/x-pack/plugins/fleet/public/components/package_icon.tsx b/x-pack/plugins/fleet/public/components/package_icon.tsx index df0bd9864b60f..85ae7971f46c2 100644 --- a/x-pack/plugins/fleet/public/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/components/package_icon.tsx @@ -17,3 +17,14 @@ export const PackageIcon: React.FunctionComponent; }; + +export const CardIcon: React.FunctionComponent> = ( + props +) => { + const { icons } = props; + if (icons && icons.length === 1 && icons[0].type === 'eui') { + return ; + } else { + return ; + } +}; diff --git a/x-pack/plugins/fleet/public/hooks/use_link.ts b/x-pack/plugins/fleet/public/hooks/use_link.ts index 846ca9d0fdafa..b04f51ef790fe 100644 --- a/x-pack/plugins/fleet/public/hooks/use_link.ts +++ b/x-pack/plugins/fleet/public/hooks/use_link.ts @@ -23,6 +23,9 @@ export const useLink = () => { getPath: (page: StaticPage | DynamicPage, values: DynamicPagePathValues = {}): string => { return getSeparatePaths(page, values)[1]; }, + getAbsolutePath: (path: string): string => { + return core.http.basePath.prepend(`${path}`); + }, getAssetsPath: (path: string) => core.http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`), getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => { diff --git a/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts b/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts index c194d9914acff..a86c68c00c646 100644 --- a/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts +++ b/x-pack/plugins/fleet/public/hooks/use_package_icon_type.ts @@ -46,7 +46,7 @@ export const usePackageIconType = ({ setIconType(CACHED_ICONS.get(cacheKey) || ''); return; } - const svgIcons = (paramIcons || iconList)?.filter( + const svgIcons = (paramIcons && paramIcons.length ? paramIcons : iconList)?.filter( (iconDef) => iconDef.type === 'image/svg+xml' ); const localIconSrc = diff --git a/x-pack/plugins/fleet/public/hooks/use_request/epm.ts b/x-pack/plugins/fleet/public/hooks/use_request/epm.ts index 8599b4f2c703c..650667000409a 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/epm.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/epm.ts @@ -5,6 +5,8 @@ * 2.0. */ +import useAsync from 'react-use/lib/useAsync'; + import { epmRouteService } from '../../services'; import type { GetCategoriesRequest, @@ -18,8 +20,15 @@ import type { } from '../../types'; import type { GetStatsResponse } from '../../../common'; +import { getCustomIntegrations } from '../../services/custom_integrations'; + import { useRequest, sendRequest } from './use_request'; +export function useGetAddableCustomIntegrations() { + const customIntegrations = getCustomIntegrations(); + return useAsync(customIntegrations.getAppendCustomIntegrations, []); +} + export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => { return useRequest({ path: epmRouteService.getCategoriesPath(), diff --git a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts index 5d1567936bcb0..5f3ee5c188b45 100644 --- a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts +++ b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts @@ -9,6 +9,7 @@ import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { licensingMock } from '../../../licensing/public/mocks'; import { homePluginMock } from '../../../../../src/plugins/home/public/mocks'; import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks'; +import { customIntegrationsMock } from '../../../../../src/plugins/custom_integrations/public/mocks'; import type { MockedFleetSetupDeps, MockedFleetStartDeps } from './types'; @@ -17,6 +18,7 @@ export const createSetupDepsMock = (): MockedFleetSetupDeps => { licensing: licensingMock.createSetup(), data: dataPluginMock.createSetupContract(), home: homePluginMock.createSetupContract(), + customIntegrations: customIntegrationsMock.createSetup(), }; }; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index 2c723a3269737..b5a1983b6c974 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -17,6 +17,8 @@ import { i18n } from '@kbn/i18n'; import type { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public'; +import type { CustomIntegrationsSetup } from '../../../../src/plugins/custom_integrations/public'; + import type { DataPublicPluginSetup, DataPublicPluginStart, @@ -47,6 +49,8 @@ import { LazyCustomLogsAssetsExtension } from './lazy_custom_logs_assets_extensi export { FleetConfigType } from '../common/types'; +import { setCustomIntegrations } from './services/custom_integrations'; + // We need to provide an object instead of void so that dependent plugins know when Fleet // is disabled. // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -66,6 +70,7 @@ export interface FleetSetupDeps { home?: HomePublicPluginSetup; cloud?: CloudSetup; globalSearch?: GlobalSearchPluginSetup; + customIntegrations: CustomIntegrationsSetup; } export interface FleetStartDeps { @@ -94,6 +99,8 @@ export class FleetPlugin implements Plugin