[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.
|{kib-repo}blob/{branch}/src/plugins/custom_integrations/README.md[customIntegrations]
|Register add-data cards
|<<kibana-dashboard-plugin>>
|- Registers the dashboard application.
- Adds a dashboard embeddable that can be used in other applications.

View file

@ -115,3 +115,4 @@ pageLoadAssetSize:
expressionTagcloud: 27505
expressions: 239290
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,
"ui": true,
"requiredPlugins": ["data", "share", "urlForwarding"],
"optionalPlugins": ["usageCollection", "telemetry"],
"optionalPlugins": ["usageCollection", "telemetry", "customIntegrations"],
"requiredBundles": ["kibanaReact"]
}

View file

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

View file

@ -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<HomeServerPluginSetup, HomeServerPluginStart> {
@ -37,7 +39,7 @@ export class HomeServerPlugin implements Plugin<HomeServerPluginSetup, HomeServe
registerRoutes(router);
return {
tutorials: { ...this.tutorialsRegistry.setup(core) },
tutorials: { ...this.tutorialsRegistry.setup(core, plugins.customIntegrations) },
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())),
savedObjectsInstallMsg: schema.maybe(schema.string()),
customStatusCheckName: schema.maybe(schema.string()),
integrationBrowserCategories: schema.maybe(schema.arrayOf(schema.string())),
});
export type TutorialSchema = TypeOf<typeof tutorialSchema>;

View file

@ -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<CoreSetup>;
let testProvider: TutorialProvider;
let testScopedTutorialContextFactory: ScopedTutorialContextFactory;
let mockCustomIntegrationsPluginSetup: MockedKeys<CustomIntegrationsPluginSetup>;
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)

View file

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

View file

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

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', () => {
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'));

View file

@ -361,6 +361,18 @@ export type PackageListItem = Installable<RegistrySearchResult> & {
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 PackageInfo =
| Installable<Merge<RegistryPackage, EpmPackageAdditions>>

View file

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

View file

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

View file

@ -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<LocalSearch | null>(null);
useEffect(() => {

View file

@ -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) => (
<div style={{ width }}>
{/*
// @ts-ignore */}
<PackageCard {...props} status="not_installed" />
</div>
);
@ -51,6 +54,7 @@ export const NotInstalled = ({ width, ...props }: Args) => (
export const Installed = ({ width, ...props }: Args) => {
const savedObject: SavedObject<Installation> = {
id: props.id,
// @ts-expect-error
type: props.type || '',
attributes: {
name: props.name,
@ -68,6 +72,8 @@ export const Installed = ({ width, ...props }: Args) => {
return (
<div style={{ width }}>
{/*
// @ts-ignore */}
<PackageCard {...props} status="installed" savedObject={savedObject} />
</div>
);

View file

@ -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 (
<Card
title={title || ''}
description={description}
icon={
<PackageIcon
<CardIcon
icons={icons}
packageName={name}
integrationName={integration}
@ -54,14 +49,9 @@ export function PackageCard({
size="xl"
/>
}
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}
/>
);
}

View file

@ -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) => (
<PackageListGrid
list={[
{
title: 'Package One',
description: 'Not Installed Description',
name: 'beats',
release: 'ga',
id: 'id',
version: '1.0.0',
download: '/',
path: 'path',
status: 'not_installed',
},
{
title: 'Package Two',
description: 'Not Installed Description',
name: 'aws',
release: 'beta',
id: 'id',
version: '1.0.0',
download: '/',
path: 'path',
status: 'not_installed',
},
{
title: 'Package Three',
description: 'Not Installed Description',
name: 'azure',
release: 'experimental',
id: 'id',
version: '1.0.0',
download: '/',
path: 'path',
status: 'not_installed',
},
{
title: 'Package Four',
description: 'Installed Description',
name: 'elastic',
release: 'ga',
id: 'id',
version: '1.0.0',
download: '/',
path: 'path',
status: 'installed',
savedObject,
},
{
title: 'Package Five',
description: 'Installed Description',
name: 'unknown',
release: 'beta',
id: 'id',
version: '1.0.0',
download: '/',
path: 'path',
status: 'installed',
savedObject,
},
{
title: 'Package Six',
description: 'Installed Description',
name: 'kibana',
release: 'experimental',
id: 'id',
version: '1.0.0',
download: '/',
path: 'path',
status: 'installed',
savedObject,
},
]}
list={
[
{
title: 'Package One',
description: 'Not Installed Description',
name: 'beats',
release: 'ga',
id: 'id',
version: '1.0.0',
uiInternalPath: '/',
path: 'path',
status: 'not_installed',
},
{
title: 'Package Two',
description: 'Not Installed Description',
name: 'aws',
release: 'beta',
id: 'id',
version: '1.0.0',
uiInternalPath: '/',
path: 'path',
status: 'not_installed',
},
{
title: 'Package Three',
description: 'Not Installed Description',
name: 'azure',
release: 'experimental',
id: 'id',
version: '1.0.0',
uiInternalPath: '/',
path: 'path',
status: 'not_installed',
},
{
title: 'Package Four',
description: 'Installed Description',
name: 'elastic',
release: 'ga',
id: 'id',
version: '1.0.0',
uiInternalPath: '/',
path: 'path',
status: 'installed',
savedObject,
},
{
title: 'Package Five',
description: 'Installed Description',
name: 'unknown',
release: 'beta',
id: 'id',
version: '1.0.0',
uiInternalPath: '/',
path: 'path',
status: 'installed',
savedObject,
},
{
title: 'Package Six',
description: 'Installed Description',
name: 'kibana',
release: 'experimental',
id: 'id',
version: '1.0.0',
uiInternalPath: '/',
path: 'path',
status: 'installed',
savedObject,
},
] as unknown as IntegrationCardItem[]
}
onSearchChange={action('onSearchChange')}
setSelectedCategory={action('setSelectedCategory')}
{...props}

View file

@ -23,16 +23,17 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { useStartServices } from '../../../../../hooks';
import { Loading } from '../../../components';
import type { PackageList } from '../../../types';
import { useLocalSearch, searchIdField } from '../../../hooks';
import type { IntegrationCardItem } from '../../../../../../common/types/models';
import { PackageCard } from './package_card';
export interface ListProps {
isLoading?: boolean;
controls?: ReactNode;
title: string;
list: PackageList;
list: IntegrationCardItem[];
initialSearch?: string;
setSelectedCategory: (category: string) => 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;
}

View file

@ -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 = (
<EuiFacetGroup>
{isLoading ? (
<Loading />
) : (
categories.map((category) => (
<EuiFacetButton
isSelected={category.id === selectedCategory}
key={category.id}
id={category.id}
quantity={category.count}
onClick={() => onCategoryChange(category)}
>
{category.title}
</EuiFacetButton>
))
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 (
<EuiFacetButton
isSelected={category.id === selectedCategory}
key={category.id}
id={category.id}
quantity={category.count}
onClick={() => onCategoryChange(category)}
>
{title}
</EuiFacetButton>
);
})
)}
</EuiFacetGroup>
);

View file

@ -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 (
<Switch>
@ -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<CategoryParams>(),
@ -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(() => {
<CategoryFacets
categories={categories}
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 (
<PackageListGrid
isLoading={isLoadingPackages}
@ -178,7 +229,7 @@ const InstalledPackages: React.FC = memo(() => {
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<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: '',
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 ? (
<CategoryFacets
isLoading={isLoadingCategories || isLoadingAllPackages}
isLoading={isLoadingCategories || isLoadingAllPackages || isLoadingAddableCustomIntegrations}
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={({ id }: CategorySummaryItem) => {
onCategoryChange={({ id }: CategoryFacet) => {
setSelectedCategory(id);
}}
/>
) : null;
const cards = eprAndCustomPackages.map((item) => {
return mapToCard(getAbsolutePath, getHref, item);
});
return (
<PackageListGrid
isLoading={isLoadingCategoryPackages}
title={title}
controls={controls}
initialSearch={searchParam}
list={packages}
list={cards}
setSelectedCategory={setSelectedCategory}
onSearchChange={setSearchTerm}
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 });
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 => {
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) => {

View file

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

View file

@ -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<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(),

View file

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

View file

@ -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<FleetSetup, FleetStart, FleetSetupDep
const kibanaVersion = this.kibanaVersion;
const extensions = this.extensions;
setCustomIntegrations(deps.customIntegrations);
// Set up http client
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',
onPrem: instructions,
elasticCloud: instructions,
integrationBrowserCategories: ['upload_file'],
});
}

View file

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

View file

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