[Fleet] Add custom integrations API (#112481)

Add a new plugin `custom_integrations`. This plugin allows for the registration of data-integrations tutorials. The Fleet-integrations app will display these alongside the existing Elastic Agent integrations.
This commit is contained in:
Thomas Neirynck 2021-09-27 11:54:43 -04:00 committed by GitHub
parent 0d3fa769b5
commit be1ee57a03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1100 additions and 171 deletions

View file

@ -44,6 +44,10 @@ as uiSettings within the code.
|Console provides the user with tools for storing and executing requests against Elasticsearch. |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
|<<kibana-dashboard-plugin>> |<<kibana-dashboard-plugin>>
|- Registers the dashboard application. |- Registers the dashboard application.
- Adds a dashboard embeddable that can be used in other applications. - Adds a dashboard embeddable that can be used in other applications.

View file

@ -115,3 +115,4 @@ pageLoadAssetSize:
expressionTagcloud: 27505 expressionTagcloud: 27505
expressions: 239290 expressions: 239290
securitySolution: 231753 securitySolution: 231753
customIntegrations: 28810

View file

@ -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.

View file

@ -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`;

View file

@ -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: ['<rootDir>/src/plugins/custom_integrations'],
testRunner: 'jasmine2',
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/custom_integrations',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/src/plugins/data/{common,public,server}/**/*.{ts,tsx}'],
};

View file

@ -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": []
}

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 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';

View file

@ -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<CustomIntegrationsSetup> {
const mock = {
getAppendCustomIntegrations: jest.fn(),
};
return mock;
}
export const customIntegrationsMock = {
createSetup: createCustomIntegrationsSetup,
};

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 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<typeof coreMock.createSetup>;
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');
});
});
});

View file

@ -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<CustomIntegrationsSetup, CustomIntegrationsStart>
{
public setup(core: CoreSetup): CustomIntegrationsSetup {
// Return methods that should be available to other plugins
return {
async getAppendCustomIntegrations(): Promise<CustomIntegration[]> {
return core.http.get(ROUTES_ADDABLECUSTOMINTEGRATIONS);
},
} as CustomIntegrationsSetup;
}
public start(core: CoreStart): CustomIntegrationsStart {
return {};
}
public stop() {}
}

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 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<CustomIntegration[]>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface CustomIntegrationsStart {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface AppPluginStartDependencies {}

View file

@ -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',
},
]);
});
});
});

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 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);
}
}

View file

@ -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({}),
};

View file

@ -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<CustomIntegrationsPluginSetup> {
const mock = {
registerCustomIntegration: jest.fn(),
getAppendCustomIntegrations: jest.fn(),
};
return mock as MockedKeys<CustomIntegrationsPluginSetup>;
}
export const customIntegrationsMock = {
createSetup: createCustomIntegrationsSetup,
};

View file

@ -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<typeof coreMock.createSetup>;
let initContext: ReturnType<typeof coreMock.createPluginInitializerContext>;
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');
});
});
});

View file

@ -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<CustomIntegrationsPluginSetup, CustomIntegrationsPluginStart>
{
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<CustomIntegration, 'type'>) => {
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() {}
}

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 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,
});
}
);
}

View file

@ -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<CustomIntegration, 'type'>): void;
getAppendCustomIntegrations(): CustomIntegration[];
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface CustomIntegrationsPluginStart {}

View file

@ -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" }
]
}

View file

@ -8,6 +8,6 @@
"server": true, "server": true,
"ui": true, "ui": true,
"requiredPlugins": ["data", "share", "urlForwarding"], "requiredPlugins": ["data", "share", "urlForwarding"],
"optionalPlugins": ["usageCollection", "telemetry"], "optionalPlugins": ["usageCollection", "telemetry", "customIntegrations"],
"requiredBundles": ["kibanaReact"] "requiredBundles": ["kibanaReact"]
} }

View file

@ -7,38 +7,51 @@
*/ */
import { registryForTutorialsMock, registryForSampleDataMock } from './plugin.test.mocks'; import { registryForTutorialsMock, registryForSampleDataMock } from './plugin.test.mocks';
import { HomeServerPlugin } from './plugin'; import { HomeServerPlugin, HomeServerPluginSetupDependencies } from './plugin';
import { coreMock, httpServiceMock } from '../../../core/server/mocks'; import { coreMock, httpServiceMock } from '../../../core/server/mocks';
import { customIntegrationsMock } from '../../custom_integrations/server/mocks';
describe('HomeServerPlugin', () => { describe('HomeServerPlugin', () => {
let homeServerPluginSetupDependenciesMock: HomeServerPluginSetupDependencies;
let mockCoreSetup: ReturnType<typeof coreMock.createSetup>;
beforeEach(() => { beforeEach(() => {
registryForTutorialsMock.setup.mockClear(); registryForTutorialsMock.setup.mockClear();
registryForTutorialsMock.start.mockClear(); registryForTutorialsMock.start.mockClear();
registryForSampleDataMock.setup.mockClear(); registryForSampleDataMock.setup.mockClear();
registryForSampleDataMock.start.mockClear(); registryForSampleDataMock.start.mockClear();
homeServerPluginSetupDependenciesMock = {
customIntegrations: customIntegrationsMock.createSetup(),
};
mockCoreSetup = coreMock.createSetup();
}); });
describe('setup', () => { describe('setup', () => {
let mockCoreSetup: ReturnType<typeof coreMock.createSetup>;
let initContext: ReturnType<typeof coreMock.createPluginInitializerContext>; let initContext: ReturnType<typeof coreMock.createPluginInitializerContext>;
let routerMock: ReturnType<typeof httpServiceMock.createRouter>; let routerMock: ReturnType<typeof httpServiceMock.createRouter>;
beforeEach(() => { beforeEach(() => {
mockCoreSetup = coreMock.createSetup();
routerMock = httpServiceMock.createRouter(); routerMock = httpServiceMock.createRouter();
mockCoreSetup.http.createRouter.mockReturnValue(routerMock); mockCoreSetup.http.createRouter.mockReturnValue(routerMock);
initContext = coreMock.createPluginInitializerContext(); initContext = coreMock.createPluginInitializerContext();
}); });
test('wires up tutorials provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { 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).toHaveProperty('tutorials');
expect(setup.tutorials).toHaveProperty('registerTutorial'); expect(setup.tutorials).toHaveProperty('registerTutorial');
expect(setup.tutorials).toHaveProperty('addScopedTutorialContextFactory'); expect(setup.tutorials).toHaveProperty('addScopedTutorialContextFactory');
}); });
test('wires up sample data provider service and returns registerTutorial and 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).toHaveProperty('sampleData');
expect(setup.sampleData).toHaveProperty('registerSampleDataset'); expect(setup.sampleData).toHaveProperty('registerSampleDataset');
expect(setup.sampleData).toHaveProperty('getSampleDatasets'); expect(setup.sampleData).toHaveProperty('getSampleDatasets');
@ -48,7 +61,7 @@ describe('HomeServerPlugin', () => {
}); });
test('registers the `/api/home/hits_status` route', () => { 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).toHaveBeenCalledTimes(1);
expect(routerMock.post).toHaveBeenCalledWith( expect(routerMock.post).toHaveBeenCalledWith(
@ -63,7 +76,9 @@ describe('HomeServerPlugin', () => {
describe('start', () => { describe('start', () => {
const initContext = coreMock.createPluginInitializerContext(); const initContext = coreMock.createPluginInitializerContext();
test('is defined', () => { 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).toBeDefined();
expect(start).toHaveProperty('tutorials'); expect(start).toHaveProperty('tutorials');
expect(start).toHaveProperty('sampleData'); expect(start).toHaveProperty('sampleData');

View file

@ -19,9 +19,11 @@ import { UsageCollectionSetup } from '../../usage_collection/server';
import { capabilitiesProvider } from './capabilities_provider'; import { capabilitiesProvider } from './capabilities_provider';
import { sampleDataTelemetry } from './saved_objects'; import { sampleDataTelemetry } from './saved_objects';
import { registerRoutes } from './routes'; import { registerRoutes } from './routes';
import { CustomIntegrationsPluginSetup } from '../../custom_integrations/server';
interface HomeServerPluginSetupDependencies { export interface HomeServerPluginSetupDependencies {
usageCollection?: UsageCollectionSetup; usageCollection?: UsageCollectionSetup;
customIntegrations?: CustomIntegrationsPluginSetup;
} }
export class HomeServerPlugin implements Plugin<HomeServerPluginSetup, HomeServerPluginStart> { export class HomeServerPlugin implements Plugin<HomeServerPluginSetup, HomeServerPluginStart> {
@ -37,7 +39,7 @@ export class HomeServerPlugin implements Plugin<HomeServerPluginSetup, HomeServe
registerRoutes(router); registerRoutes(router);
return { return {
tutorials: { ...this.tutorialsRegistry.setup(core) }, tutorials: { ...this.tutorialsRegistry.setup(core, plugins.customIntegrations) },
sampleData: { ...this.sampleDataRegistry.setup(core, plugins.usageCollection) }, sampleData: { ...this.sampleDataRegistry.setup(core, plugins.usageCollection) },
}; };
} }

View file

@ -154,6 +154,8 @@ export const tutorialSchema = schema.object({
savedObjects: schema.maybe(schema.arrayOf(schema.any())), savedObjects: schema.maybe(schema.arrayOf(schema.any())),
savedObjectsInstallMsg: schema.maybe(schema.string()), savedObjectsInstallMsg: schema.maybe(schema.string()),
customStatusCheckName: schema.maybe(schema.string()), customStatusCheckName: schema.maybe(schema.string()),
integrationBrowserCategories: schema.maybe(schema.arrayOf(schema.string())),
}); });
export type TutorialSchema = TypeOf<typeof tutorialSchema>; export type TutorialSchema = TypeOf<typeof tutorialSchema>;

View file

@ -18,6 +18,8 @@ import {
TutorialsCategory, TutorialsCategory,
ScopedTutorialContextFactory, ScopedTutorialContextFactory,
} from './lib/tutorials_registry_types'; } from './lib/tutorials_registry_types';
import { CustomIntegrationsPluginSetup } from '../../../../custom_integrations/server';
import { customIntegrationsMock } from '../../../../custom_integrations/server/mocks';
const INVALID_TUTORIAL: TutorialSchema = { const INVALID_TUTORIAL: TutorialSchema = {
id: 'test', id: 'test',
@ -69,6 +71,11 @@ describe('TutorialsRegistry', () => {
let mockCoreSetup: MockedKeys<CoreSetup>; let mockCoreSetup: MockedKeys<CoreSetup>;
let testProvider: TutorialProvider; let testProvider: TutorialProvider;
let testScopedTutorialContextFactory: ScopedTutorialContextFactory; let testScopedTutorialContextFactory: ScopedTutorialContextFactory;
let mockCustomIntegrationsPluginSetup: MockedKeys<CustomIntegrationsPluginSetup>;
beforeEach(() => {
mockCustomIntegrationsPluginSetup = customIntegrationsMock.createSetup();
});
describe('GET /api/kibana/home/tutorials', () => { describe('GET /api/kibana/home/tutorials', () => {
beforeEach(() => { beforeEach(() => {
@ -83,13 +90,13 @@ describe('TutorialsRegistry', () => {
describe('setup', () => { describe('setup', () => {
test('exposes proper contract', () => { test('exposes proper contract', () => {
const setup = new TutorialsRegistry().setup(mockCoreSetup); const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup);
expect(setup).toHaveProperty('registerTutorial'); expect(setup).toHaveProperty('registerTutorial');
expect(setup).toHaveProperty('addScopedTutorialContextFactory'); expect(setup).toHaveProperty('addScopedTutorialContextFactory');
}); });
test('registerTutorial throws when registering a tutorial with an invalid schema', () => { 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; testProvider = ({}) => invalidTutorialProvider;
expect(() => setup.registerTutorial(testProvider)).toThrowErrorMatchingInlineSnapshot( expect(() => setup.registerTutorial(testProvider)).toThrowErrorMatchingInlineSnapshot(
`"Unable to register tutorial spec because its invalid. Error: [name]: is not allowed to be empty"` `"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', () => { test('registerTutorial registers a tutorial with a valid schema', () => {
const setup = new TutorialsRegistry().setup(mockCoreSetup); const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup);
testProvider = ({}) => validTutorialProvider; testProvider = ({}) => validTutorialProvider;
expect(() => setup.registerTutorial(testProvider)).not.toThrowError(); 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', () => { 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; const testItem = {} as TutorialProvider;
expect(() => expect(() =>
setup.addScopedTutorialContextFactory(testItem) setup.addScopedTutorialContextFactory(testItem)
@ -113,7 +141,7 @@ describe('TutorialsRegistry', () => {
}); });
test('addScopedTutorialContextFactory adds a scopedTutorialContextFactory when given a function', () => { test('addScopedTutorialContextFactory adds a scopedTutorialContextFactory when given a function', () => {
const setup = new TutorialsRegistry().setup(mockCoreSetup); const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup);
testScopedTutorialContextFactory = ({}) => 'string'; testScopedTutorialContextFactory = ({}) => 'string';
expect(() => expect(() =>
setup.addScopedTutorialContextFactory(testScopedTutorialContextFactory) setup.addScopedTutorialContextFactory(testScopedTutorialContextFactory)

View file

@ -12,14 +12,46 @@ import {
TutorialContextFactory, TutorialContextFactory,
ScopedTutorialContextFactory, ScopedTutorialContextFactory,
} from './lib/tutorials_registry_types'; } from './lib/tutorials_registry_types';
import { tutorialSchema } from './lib/tutorial_schema'; import { TutorialSchema, tutorialSchema } from './lib/tutorial_schema';
import { builtInTutorials } from '../../tutorials/register'; 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 { export class TutorialsRegistry {
private tutorialProviders: TutorialProvider[] = []; // pre-register all the tutorials we know we want in here private tutorialProviders: TutorialProvider[] = []; // pre-register all the tutorials we know we want in here
private readonly scopedTutorialContextFactories: TutorialContextFactory[] = []; private readonly scopedTutorialContextFactories: TutorialContextFactory[] = [];
public setup(core: CoreSetup) { public setup(core: CoreSetup, customIntegrations?: CustomIntegrationsPluginSetup) {
const router = core.http.createRouter(); const router = core.http.createRouter();
router.get( router.get(
{ path: '/api/kibana/home/tutorials', validate: false }, { path: '/api/kibana/home/tutorials', validate: false },
@ -31,7 +63,6 @@ export class TutorialsRegistry {
}, },
initialContext initialContext
); );
return res.ok({ return res.ok({
body: this.tutorialProviders.map((tutorialProvider) => { body: this.tutorialProviders.map((tutorialProvider) => {
return tutorialProvider(scopedContext); // All the tutorialProviders need to be refactored so that they don't need the server. 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 { return {
registerTutorial: (specProvider: TutorialProvider) => { registerTutorial: (specProvider: TutorialProvider) => {
const emptyContext = {};
let tutorial: TutorialSchema;
try { try {
const emptyContext = {}; tutorial = tutorialSchema.validate(specProvider(emptyContext));
tutorialSchema.validate(specProvider(emptyContext));
} catch (error) { } catch (error) {
throw new Error(`Unable to register tutorial spec because its invalid. ${error}`); throw new Error(`Unable to register tutorial spec because its invalid. ${error}`);
} }
if (customIntegrations && tutorial) {
registerTutorialWithCustomIntegrations(customIntegrations, tutorial);
}
this.tutorialProviders.push(specProvider); this.tutorialProviders.push(specProvider);
}, },

View file

@ -11,6 +11,7 @@
"references": [ "references": [
{ "path": "../../core/tsconfig.json" }, { "path": "../../core/tsconfig.json" },
{ "path": "../data/tsconfig.json" }, { "path": "../data/tsconfig.json" },
{ "path": "../custom_integrations/tsconfig.json" },
{ "path": "../kibana_react/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" },
{ "path": "../share/tsconfig.json" }, { "path": "../share/tsconfig.json" },
{ "path": "../url_forwarding/tsconfig.json" }, { "path": "../url_forwarding/tsconfig.json" },

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 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'));
});
}

View file

@ -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);
});
});
}

View file

@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('apis', () => { describe('apis', () => {
loadTestFile(require.resolve('./console')); loadTestFile(require.resolve('./console'));
loadTestFile(require.resolve('./core')); loadTestFile(require.resolve('./core'));
loadTestFile(require.resolve('./custom_integration'));
loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./general'));
loadTestFile(require.resolve('./home')); loadTestFile(require.resolve('./home'));
loadTestFile(require.resolve('./index_pattern_field_editor')); loadTestFile(require.resolve('./index_pattern_field_editor'));

View file

@ -361,6 +361,18 @@ export type PackageListItem = Installable<RegistrySearchResult> & {
id: string; 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<ValueOf<InstallationStatus>, PackageList>; export type PackagesGroupedByStatus = Record<ValueOf<InstallationStatus>, PackageList>;
export type PackageInfo = export type PackageInfo =
| Installable<Merge<RegistryPackage, EpmPackageAdditions>> | Installable<Merge<RegistryPackage, EpmPackageAdditions>>

View file

@ -8,7 +8,7 @@
"server": true, "server": true,
"ui": true, "ui": true,
"configPath": ["xpack", "fleet"], "configPath": ["xpack", "fleet"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations"],
"optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"],
"extraPublicDirs": ["common"], "extraPublicDirs": ["common"],
"requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"] "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"]

View file

@ -6,7 +6,6 @@
*/ */
import { useStartServices } from '../../../hooks/use_core'; import { useStartServices } from '../../../hooks/use_core';
import { PLUGIN_ID } from '../../../constants';
import { epmRouteService } from '../../../services'; import { epmRouteService } from '../../../services';
import type { PackageSpecIcon, PackageSpecScreenshot, RegistryImage } from '../../../../common'; import type { PackageSpecIcon, PackageSpecScreenshot, RegistryImage } from '../../../../common';
@ -16,7 +15,6 @@ const removeRelativePath = (relativePath: string): string =>
export function useLinks() { export function useLinks() {
const { http } = useStartServices(); const { http } = useStartServices();
return { return {
toAssets: (path: string) => http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`),
toSharedAssets: (path: string) => http.basePath.prepend(`/plugins/kibanaReact/assets/${path}`), toSharedAssets: (path: string) => http.basePath.prepend(`/plugins/kibanaReact/assets/${path}`),
toPackageImage: ( toPackageImage: (
img: PackageSpecIcon | PackageSpecScreenshot | RegistryImage, img: PackageSpecIcon | PackageSpecScreenshot | RegistryImage,

View file

@ -8,12 +8,12 @@
import { Search as LocalSearch, AllSubstringsIndexStrategy } from 'js-search'; import { Search as LocalSearch, AllSubstringsIndexStrategy } from 'js-search';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { PackageList } from '../../../types'; import type { IntegrationCardItem } from '../../../../common/types/models';
export const searchIdField = 'id'; export const searchIdField = 'id';
export const fieldsToSearch = ['name', 'title', 'description']; export const fieldsToSearch = ['name', 'title', 'description'];
export function useLocalSearch(packageList: PackageList) { export function useLocalSearch(packageList: IntegrationCardItem[]) {
const localSearchRef = useRef<LocalSearch | null>(null); const localSearchRef = useRef<LocalSearch | null>(null);
useEffect(() => { useEffect(() => {

View file

@ -29,8 +29,9 @@ const args: Args = {
release: 'ga', release: 'ga',
id: 'id', id: 'id',
version: '1.0.0', version: '1.0.0',
download: '/', uiInternalPathUrl: '/',
path: 'path', icons: [],
integration: '',
}; };
const argTypes = { const argTypes = {
@ -44,6 +45,8 @@ const argTypes = {
export const NotInstalled = ({ width, ...props }: Args) => ( export const NotInstalled = ({ width, ...props }: Args) => (
<div style={{ width }}> <div style={{ width }}>
{/*
// @ts-ignore */}
<PackageCard {...props} status="not_installed" /> <PackageCard {...props} status="not_installed" />
</div> </div>
); );
@ -51,6 +54,7 @@ export const NotInstalled = ({ width, ...props }: Args) => (
export const Installed = ({ width, ...props }: Args) => { export const Installed = ({ width, ...props }: Args) => {
const savedObject: SavedObject<Installation> = { const savedObject: SavedObject<Installation> = {
id: props.id, id: props.id,
// @ts-expect-error
type: props.type || '', type: props.type || '',
attributes: { attributes: {
name: props.name, name: props.name,
@ -68,6 +72,8 @@ export const Installed = ({ width, ...props }: Args) => {
return ( return (
<div style={{ width }}> <div style={{ width }}>
{/*
// @ts-ignore */}
<PackageCard {...props} status="installed" savedObject={savedObject} /> <PackageCard {...props} status="installed" savedObject={savedObject} />
</div> </div>
); );

View file

@ -9,13 +9,12 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { EuiCard } from '@elastic/eui'; import { EuiCard } from '@elastic/eui';
import type { PackageListItem } from '../../../types'; import { CardIcon } from '../../../../../components/package_icon';
import { useLink } from '../../../hooks'; import type { IntegrationCardItem } from '../../../../../../common/types/models/epm';
import { PackageIcon } from '../../../components';
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` // adding the `href` causes EuiCard to use a `a` instead of a `button`
// `a` tags use `euiLinkColor` which results in blueish Badge text // `a` tags use `euiLinkColor` which results in blueish Badge text
@ -28,25 +27,21 @@ export function PackageCard({
name, name,
title, title,
version, version,
release,
status,
icons, icons,
integration, integration,
...restProps uiInternalPathUrl,
release,
}: PackageCardProps) { }: PackageCardProps) {
const { getHref } = useLink(); const betaBadgeLabel = release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined;
let urlVersion = version; const betaBadgeLabelTooltipContent =
// if this is an installed package, link to the version installed release && release !== 'ga' ? RELEASE_BADGE_DESCRIPTION[release] : undefined;
if ('savedObject' in restProps) {
urlVersion = restProps.savedObject.attributes.version || version;
}
return ( return (
<Card <Card
title={title || ''} title={title || ''}
description={description} description={description}
icon={ icon={
<PackageIcon <CardIcon
icons={icons} icons={icons}
packageName={name} packageName={name}
integrationName={integration} integrationName={integration}
@ -54,14 +49,9 @@ export function PackageCard({
size="xl" size="xl"
/> />
} }
href={getHref('integration_details_overview', { href={uiInternalPathUrl}
pkgkey: `${name}-${urlVersion}`, betaBadgeLabel={betaBadgeLabel}
...(integration ? { integration } : {}), betaBadgeTooltipContent={betaBadgeLabelTooltipContent}
})}
betaBadgeLabel={release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined}
betaBadgeTooltipContent={
release && release !== 'ga' ? RELEASE_BADGE_DESCRIPTION[release] : undefined
}
/> />
); );
} }

View file

@ -13,6 +13,8 @@ import type { SavedObject } from 'src/core/public';
import type { Installation } from '../../../../../../common'; import type { Installation } from '../../../../../../common';
import type { IntegrationCardItem } from '../../../../../../common';
import type { ListProps } from './package_list_grid'; import type { ListProps } from './package_list_grid';
import { PackageListGrid } from './package_list_grid'; import { PackageListGrid } from './package_list_grid';
@ -57,77 +59,79 @@ export const EmptyList = (props: Args) => (
export const List = (props: Args) => ( export const List = (props: Args) => (
<PackageListGrid <PackageListGrid
list={[ list={
{ [
title: 'Package One', {
description: 'Not Installed Description', title: 'Package One',
name: 'beats', description: 'Not Installed Description',
release: 'ga', name: 'beats',
id: 'id', release: 'ga',
version: '1.0.0', id: 'id',
download: '/', version: '1.0.0',
path: 'path', uiInternalPath: '/',
status: 'not_installed', path: 'path',
}, status: 'not_installed',
{ },
title: 'Package Two', {
description: 'Not Installed Description', title: 'Package Two',
name: 'aws', description: 'Not Installed Description',
release: 'beta', name: 'aws',
id: 'id', release: 'beta',
version: '1.0.0', id: 'id',
download: '/', version: '1.0.0',
path: 'path', uiInternalPath: '/',
status: 'not_installed', path: 'path',
}, status: 'not_installed',
{ },
title: 'Package Three', {
description: 'Not Installed Description', title: 'Package Three',
name: 'azure', description: 'Not Installed Description',
release: 'experimental', name: 'azure',
id: 'id', release: 'experimental',
version: '1.0.0', id: 'id',
download: '/', version: '1.0.0',
path: 'path', uiInternalPath: '/',
status: 'not_installed', path: 'path',
}, status: 'not_installed',
{ },
title: 'Package Four', {
description: 'Installed Description', title: 'Package Four',
name: 'elastic', description: 'Installed Description',
release: 'ga', name: 'elastic',
id: 'id', release: 'ga',
version: '1.0.0', id: 'id',
download: '/', version: '1.0.0',
path: 'path', uiInternalPath: '/',
status: 'installed', path: 'path',
savedObject, status: 'installed',
}, savedObject,
{ },
title: 'Package Five', {
description: 'Installed Description', title: 'Package Five',
name: 'unknown', description: 'Installed Description',
release: 'beta', name: 'unknown',
id: 'id', release: 'beta',
version: '1.0.0', id: 'id',
download: '/', version: '1.0.0',
path: 'path', uiInternalPath: '/',
status: 'installed', path: 'path',
savedObject, status: 'installed',
}, savedObject,
{ },
title: 'Package Six', {
description: 'Installed Description', title: 'Package Six',
name: 'kibana', description: 'Installed Description',
release: 'experimental', name: 'kibana',
id: 'id', release: 'experimental',
version: '1.0.0', id: 'id',
download: '/', version: '1.0.0',
path: 'path', uiInternalPath: '/',
status: 'installed', path: 'path',
savedObject, status: 'installed',
}, savedObject,
]} },
] as unknown as IntegrationCardItem[]
}
onSearchChange={action('onSearchChange')} onSearchChange={action('onSearchChange')}
setSelectedCategory={action('setSelectedCategory')} setSelectedCategory={action('setSelectedCategory')}
{...props} {...props}

View file

@ -23,16 +23,17 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { useStartServices } from '../../../../../hooks'; import { useStartServices } from '../../../../../hooks';
import { Loading } from '../../../components'; import { Loading } from '../../../components';
import type { PackageList } from '../../../types';
import { useLocalSearch, searchIdField } from '../../../hooks'; import { useLocalSearch, searchIdField } from '../../../hooks';
import type { IntegrationCardItem } from '../../../../../../common/types/models';
import { PackageCard } from './package_card'; import { PackageCard } from './package_card';
export interface ListProps { export interface ListProps {
isLoading?: boolean; isLoading?: boolean;
controls?: ReactNode; controls?: ReactNode;
title: string; title: string;
list: PackageList; list: IntegrationCardItem[];
initialSearch?: string; initialSearch?: string;
setSelectedCategory: (category: string) => void; setSelectedCategory: (category: string) => void;
onSearchChange: (search: string) => void; onSearchChange: (search: string) => void;
@ -77,7 +78,7 @@ export function PackageListGrid({
} else { } else {
const filteredList = searchTerm const filteredList = searchTerm
? list.filter((item) => ? list.filter((item) =>
(localSearchRef.current!.search(searchTerm) as PackageList) (localSearchRef.current!.search(searchTerm) as IntegrationCardItem[])
.map((match) => match[searchIdField]) .map((match) => match[searchIdField])
.includes(item[searchIdField]) .includes(item[searchIdField])
) )
@ -141,7 +142,7 @@ function ControlsColumn({ controls, title }: ControlsColumnProps) {
} }
interface GridColumnProps { interface GridColumnProps {
list: PackageList; list: IntegrationCardItem[];
showMissingIntegrationMessage?: boolean; showMissingIntegrationMessage?: boolean;
} }

View file

@ -8,8 +8,18 @@
import { EuiFacetButton, EuiFacetGroup } from '@elastic/eui'; import { EuiFacetButton, EuiFacetGroup } from '@elastic/eui';
import React from 'react'; import React from 'react';
import { i18n } from '@kbn/i18n';
import { Loading } from '../../../../components'; 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({ export function CategoryFacets({
isLoading, isLoading,
@ -18,26 +28,41 @@ export function CategoryFacets({
onCategoryChange, onCategoryChange,
}: { }: {
isLoading?: boolean; isLoading?: boolean;
categories: CategorySummaryList; categories: CategoryFacet[];
selectedCategory: string; selectedCategory: string;
onCategoryChange: (category: CategorySummaryItem) => unknown; onCategoryChange: (category: CategoryFacet) => unknown;
}) { }) {
const controls = ( const controls = (
<EuiFacetGroup> <EuiFacetGroup>
{isLoading ? ( {isLoading ? (
<Loading /> <Loading />
) : ( ) : (
categories.map((category) => ( categories.map((category) => {
<EuiFacetButton let title;
isSelected={category.id === selectedCategory}
key={category.id} if (category.id === 'updates_available') {
id={category.id} title = i18n.translate('xpack.fleet.epmList.updatesAvailableFilterLinkText', {
quantity={category.count} defaultMessage: 'Updates available',
onClick={() => onCategoryChange(category)} });
> } else if (category.id === '') {
{category.title} title = i18n.translate('xpack.fleet.epmList.allPackagesFilterLinkText', {
</EuiFacetButton> defaultMessage: 'All',
)) });
} else {
title = CATEGORY_DISPLAY[category.id];
}
return (
<EuiFacetButton
isSelected={category.id === selectedCategory}
key={category.id}
id={category.id}
quantity={category.count}
onClick={() => onCategoryChange(category)}
>
{title}
</EuiFacetButton>
);
})
)} )}
</EuiFacetGroup> </EuiFacetGroup>
); );

View file

@ -11,18 +11,35 @@ import semverLt from 'semver/functions/lt';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { installationStatuses } from '../../../../../../../common/constants'; import { installationStatuses } from '../../../../../../../common/constants';
import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../../constants';
import { import {
INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_ROUTING_PATHS,
INTEGRATIONS_SEARCH_QUERYPARAM, INTEGRATIONS_SEARCH_QUERYPARAM,
pagePathGetters, pagePathGetters,
} from '../../../../constants'; } from '../../../../constants';
import { useGetCategories, useGetPackages, useBreadcrumbs } from '../../../../hooks'; import {
useGetCategories,
useGetPackages,
useBreadcrumbs,
useGetAddableCustomIntegrations,
useLink,
} from '../../../../hooks';
import { doesPackageHaveIntegrations } from '../../../../services'; import { doesPackageHaveIntegrations } from '../../../../services';
import { DefaultLayout } from '../../../../layouts'; import { DefaultLayout } from '../../../../layouts';
import type { CategorySummaryItem, PackageList } from '../../../../types'; import type { PackageList } from '../../../../types';
import { PackageListGrid } from '../../components/package_list_grid'; 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 { CategoryFacets } from './category_facets';
import type { CategoryFacet } from './category_facets';
export interface CategoryParams { export interface CategoryParams {
category?: string; category?: string;
@ -36,10 +53,43 @@ function getParams(params: CategoryParams, search: string) {
return { selectedCategory, searchParam }; return { selectedCategory, searchParam };
} }
function categoryExists(category: string, categories: CategorySummaryItem[]) { function categoryExists(category: string, categories: CategoryFacet[]) {
return categories.some((c) => c.id === category); 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(() => { export const EPMHomePage: React.FC = memo(() => {
return ( return (
<Switch> <Switch>
@ -89,6 +139,7 @@ const InstalledPackages: React.FC = memo(() => {
const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages({ const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages({
experimental: true, experimental: true,
}); });
const { getHref, getAbsolutePath } = useLink();
const { selectedCategory, searchParam } = getParams( const { selectedCategory, searchParam } = getParams(
useParams<CategoryParams>(), useParams<CategoryParams>(),
@ -103,7 +154,7 @@ const InstalledPackages: React.FC = memo(() => {
history.push(url); history.push(url);
} }
function setSearchTerm(search: string) { 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( history.replace(
pagePathGetters.integrations_installed({ pagePathGetters.integrations_installed({
category: selectedCategory, category: selectedCategory,
@ -135,20 +186,14 @@ const InstalledPackages: React.FC = memo(() => {
[] []
); );
const categories = useMemo( const categories: CategoryFacet[] = useMemo(
() => [ () => [
{ {
id: '', id: '',
title: i18n.translate('xpack.fleet.epmList.allFilterLinkText', {
defaultMessage: 'All',
}),
count: allInstalledPackages.length, count: allInstalledPackages.length,
}, },
{ {
id: 'updates_available', id: 'updates_available',
title: i18n.translate('xpack.fleet.epmList.updatesAvailableFilterLinkText', {
defaultMessage: 'Updates available',
}),
count: updatablePackages.length, count: updatablePackages.length,
}, },
], ],
@ -166,10 +211,16 @@ const InstalledPackages: React.FC = memo(() => {
<CategoryFacets <CategoryFacets
categories={categories} categories={categories}
selectedCategory={selectedCategory} selectedCategory={selectedCategory}
onCategoryChange={({ id }: CategorySummaryItem) => setSelectedCategory(id)} onCategoryChange={({ id }: CategoryFacet) => setSelectedCategory(id)}
/> />
); );
const cards = (
selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages
).map((item) => {
return mapToCard(getAbsolutePath, getHref, item);
});
return ( return (
<PackageListGrid <PackageListGrid
isLoading={isLoadingPackages} isLoading={isLoadingPackages}
@ -178,7 +229,7 @@ const InstalledPackages: React.FC = memo(() => {
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
initialSearch={searchParam} initialSearch={searchParam}
title={title} title={title}
list={selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages} list={cards}
/> />
); );
}); });
@ -190,6 +241,8 @@ const AvailablePackages: React.FC = memo(() => {
useLocation().search useLocation().search
); );
const history = useHistory(); const history = useHistory();
const { getHref, getAbsolutePath } = useLink();
function setSelectedCategory(categoryId: string) { function setSelectedCategory(categoryId: string) {
const url = pagePathGetters.integrations_all({ const url = pagePathGetters.integrations_all({
category: categoryId, category: categoryId,
@ -198,7 +251,7 @@ const AvailablePackages: React.FC = memo(() => {
history.push(url); history.push(url);
} }
function setSearchTerm(search: string) { 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( history.replace(
pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1] pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1]
); );
@ -213,16 +266,27 @@ const AvailablePackages: React.FC = memo(() => {
const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({
include_policy_templates: true, include_policy_templates: true,
}); });
const packages = useMemo( const eprPackages = useMemo(
() => packageListToIntegrationsList(categoryPackagesRes?.response || []), () => packageListToIntegrationsList(categoryPackagesRes?.response || []),
[categoryPackagesRes] [categoryPackagesRes]
); );
const allPackages = useMemo( const allEprPackages = useMemo(
() => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []),
[allCategoryPackagesRes] [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( const title = useMemo(
() => () =>
i18n.translate('xpack.fleet.epmList.allTitle', { i18n.translate('xpack.fleet.epmList.allTitle', {
@ -231,19 +295,39 @@ const AvailablePackages: React.FC = memo(() => {
[] []
); );
const categories = useMemo( const eprAndCustomPackages: Array<CustomIntegration | PackageListItem> = [
() => [ ...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: '', id: '',
title: i18n.translate('xpack.fleet.epmList.allPackagesFilterLinkText', { count: (allEprPackages?.length || 0) + (addableCustomIntegrations?.length || 0),
defaultMessage: 'All',
}),
count: allPackages?.length || 0,
}, },
...(categoriesRes ? categoriesRes.response : []), ...(eprAndCustomCategories ? eprAndCustomCategories : []),
], ] as CategoryFacet[];
[allPackages?.length, categoriesRes] }, [
); allEprPackages?.length,
addableCustomIntegrations,
categoriesRes,
isLoadingAddableCustomIntegrations,
isLoadingCategories,
]);
if (!categoryExists(selectedCategory, categories)) { if (!categoryExists(selectedCategory, categories)) {
history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]);
@ -252,22 +336,26 @@ const AvailablePackages: React.FC = memo(() => {
const controls = categories ? ( const controls = categories ? (
<CategoryFacets <CategoryFacets
isLoading={isLoadingCategories || isLoadingAllPackages} isLoading={isLoadingCategories || isLoadingAllPackages || isLoadingAddableCustomIntegrations}
categories={categories} categories={categories}
selectedCategory={selectedCategory} selectedCategory={selectedCategory}
onCategoryChange={({ id }: CategorySummaryItem) => { onCategoryChange={({ id }: CategoryFacet) => {
setSelectedCategory(id); setSelectedCategory(id);
}} }}
/> />
) : null; ) : null;
const cards = eprAndCustomPackages.map((item) => {
return mapToCard(getAbsolutePath, getHref, item);
});
return ( return (
<PackageListGrid <PackageListGrid
isLoading={isLoadingCategoryPackages} isLoading={isLoadingCategoryPackages}
title={title} title={title}
controls={controls} controls={controls}
initialSearch={searchParam} initialSearch={searchParam}
list={packages} list={cards}
setSelectedCategory={setSelectedCategory} setSelectedCategory={setSelectedCategory}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
showMissingIntegrationMessage showMissingIntegrationMessage

View file

@ -0,0 +1,50 @@
/*
* 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 {
CustomIntegration,
Category,
} from '../../../../../../../../../../src/plugins/custom_integrations/common';
import type { CategoryFacet } from './category_facets';
export function mergeAndReplaceCategoryCounts(
eprCounts: CategoryFacet[],
addableIntegrations: CustomIntegration[]
): CategoryFacet[] {
const merged: CategoryFacet[] = [];
const addIfMissing = (category: string, count: number) => {
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;
}

View file

@ -17,3 +17,14 @@ export const PackageIcon: React.FunctionComponent<UsePackageIconType & Omit<EuiI
const iconType = usePackageIconType({ packageName, integrationName, version, icons, tryApi }); const iconType = usePackageIconType({ packageName, integrationName, version, icons, tryApi });
return <EuiIcon size="s" type={iconType} {...euiIconProps} />; return <EuiIcon size="s" type={iconType} {...euiIconProps} />;
}; };
export const CardIcon: React.FunctionComponent<UsePackageIconType & Omit<EuiIconProps, 'type'>> = (
props
) => {
const { icons } = props;
if (icons && icons.length === 1 && icons[0].type === 'eui') {
return <EuiIcon size={'xl'} type={icons[0].src} />;
} else {
return <PackageIcon {...props} />;
}
};

View file

@ -23,6 +23,9 @@ export const useLink = () => {
getPath: (page: StaticPage | DynamicPage, values: DynamicPagePathValues = {}): string => { getPath: (page: StaticPage | DynamicPage, values: DynamicPagePathValues = {}): string => {
return getSeparatePaths(page, values)[1]; return getSeparatePaths(page, values)[1];
}, },
getAbsolutePath: (path: string): string => {
return core.http.basePath.prepend(`${path}`);
},
getAssetsPath: (path: string) => getAssetsPath: (path: string) =>
core.http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`), core.http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`),
getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => { getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => {

View file

@ -46,7 +46,7 @@ export const usePackageIconType = ({
setIconType(CACHED_ICONS.get(cacheKey) || ''); setIconType(CACHED_ICONS.get(cacheKey) || '');
return; return;
} }
const svgIcons = (paramIcons || iconList)?.filter( const svgIcons = (paramIcons && paramIcons.length ? paramIcons : iconList)?.filter(
(iconDef) => iconDef.type === 'image/svg+xml' (iconDef) => iconDef.type === 'image/svg+xml'
); );
const localIconSrc = const localIconSrc =

View file

@ -5,6 +5,8 @@
* 2.0. * 2.0.
*/ */
import useAsync from 'react-use/lib/useAsync';
import { epmRouteService } from '../../services'; import { epmRouteService } from '../../services';
import type { import type {
GetCategoriesRequest, GetCategoriesRequest,
@ -18,8 +20,15 @@ import type {
} from '../../types'; } from '../../types';
import type { GetStatsResponse } from '../../../common'; import type { GetStatsResponse } from '../../../common';
import { getCustomIntegrations } from '../../services/custom_integrations';
import { useRequest, sendRequest } from './use_request'; import { useRequest, sendRequest } from './use_request';
export function useGetAddableCustomIntegrations() {
const customIntegrations = getCustomIntegrations();
return useAsync(customIntegrations.getAppendCustomIntegrations, []);
}
export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => { export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
return useRequest<GetCategoriesResponse>({ return useRequest<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(), path: epmRouteService.getCategoriesPath(),

View file

@ -9,6 +9,7 @@ import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks'; import { licensingMock } from '../../../licensing/public/mocks';
import { homePluginMock } from '../../../../../src/plugins/home/public/mocks'; import { homePluginMock } from '../../../../../src/plugins/home/public/mocks';
import { navigationPluginMock } from '../../../../../src/plugins/navigation/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'; import type { MockedFleetSetupDeps, MockedFleetStartDeps } from './types';
@ -17,6 +18,7 @@ export const createSetupDepsMock = (): MockedFleetSetupDeps => {
licensing: licensingMock.createSetup(), licensing: licensingMock.createSetup(),
data: dataPluginMock.createSetupContract(), data: dataPluginMock.createSetupContract(),
home: homePluginMock.createSetupContract(), home: homePluginMock.createSetupContract(),
customIntegrations: customIntegrationsMock.createSetup(),
}; };
}; };

View file

@ -17,6 +17,8 @@ import { i18n } from '@kbn/i18n';
import type { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import type { NavigationPublicPluginStart } from 'src/plugins/navigation/public';
import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public'; import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public';
import type { CustomIntegrationsSetup } from '../../../../src/plugins/custom_integrations/public';
import type { import type {
DataPublicPluginSetup, DataPublicPluginSetup,
DataPublicPluginStart, DataPublicPluginStart,
@ -47,6 +49,8 @@ import { LazyCustomLogsAssetsExtension } from './lazy_custom_logs_assets_extensi
export { FleetConfigType } from '../common/types'; 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 // We need to provide an object instead of void so that dependent plugins know when Fleet
// is disabled. // is disabled.
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -66,6 +70,7 @@ export interface FleetSetupDeps {
home?: HomePublicPluginSetup; home?: HomePublicPluginSetup;
cloud?: CloudSetup; cloud?: CloudSetup;
globalSearch?: GlobalSearchPluginSetup; globalSearch?: GlobalSearchPluginSetup;
customIntegrations: CustomIntegrationsSetup;
} }
export interface FleetStartDeps { export interface FleetStartDeps {
@ -94,6 +99,8 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
const kibanaVersion = this.kibanaVersion; const kibanaVersion = this.kibanaVersion;
const extensions = this.extensions; const extensions = this.extensions;
setCustomIntegrations(deps.customIntegrations);
// Set up http client // Set up http client
setHttpClient(core.http); setHttpClient(core.http);

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 type { CustomIntegrationsSetup } from '../../../../../src/plugins/custom_integrations/public';
let customIntegrations: CustomIntegrationsSetup;
export function setCustomIntegrations(value: CustomIntegrationsSetup): void {
customIntegrations = value;
}
export function getCustomIntegrations(): CustomIntegrationsSetup {
return customIntegrations;
}

View file

@ -78,5 +78,6 @@ Indexing EMS administrative boundaries in Elasticsearch allows for search on bou
previewImagePath: '/plugins/maps/assets/boundaries_screenshot.png', previewImagePath: '/plugins/maps/assets/boundaries_screenshot.png',
onPrem: instructions, onPrem: instructions,
elasticCloud: instructions, elasticCloud: instructions,
integrationBrowserCategories: ['upload_file'],
}); });
} }

View file

@ -10812,7 +10812,6 @@
"xpack.fleet.epm.updateAvailableTooltip": "更新が利用可能です", "xpack.fleet.epm.updateAvailableTooltip": "更新が利用可能です",
"xpack.fleet.epm.usedByLabel": "エージェントポリシー", "xpack.fleet.epm.usedByLabel": "エージェントポリシー",
"xpack.fleet.epm.versionLabel": "バージョン", "xpack.fleet.epm.versionLabel": "バージョン",
"xpack.fleet.epmList.allFilterLinkText": "すべて",
"xpack.fleet.epmList.allPackagesFilterLinkText": "すべて", "xpack.fleet.epmList.allPackagesFilterLinkText": "すべて",
"xpack.fleet.epmList.allTitle": "カテゴリで参照", "xpack.fleet.epmList.allTitle": "カテゴリで参照",
"xpack.fleet.epmList.installedTitle": "インストールされている統合", "xpack.fleet.epmList.installedTitle": "インストールされている統合",

View file

@ -10926,7 +10926,6 @@
"xpack.fleet.epm.updateAvailableTooltip": "有可用更新", "xpack.fleet.epm.updateAvailableTooltip": "有可用更新",
"xpack.fleet.epm.usedByLabel": "代理策略", "xpack.fleet.epm.usedByLabel": "代理策略",
"xpack.fleet.epm.versionLabel": "版本", "xpack.fleet.epm.versionLabel": "版本",
"xpack.fleet.epmList.allFilterLinkText": "全部",
"xpack.fleet.epmList.allPackagesFilterLinkText": "全部", "xpack.fleet.epmList.allPackagesFilterLinkText": "全部",
"xpack.fleet.epmList.allTitle": "按类别浏览", "xpack.fleet.epmList.allTitle": "按类别浏览",
"xpack.fleet.epmList.installedTitle": "已安装集成", "xpack.fleet.epmList.installedTitle": "已安装集成",