mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
0d3fa769b5
commit
be1ee57a03
51 changed files with 1100 additions and 171 deletions
|
@ -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.
|
||||
|
|
|
@ -115,3 +115,4 @@ pageLoadAssetSize:
|
|||
expressionTagcloud: 27505
|
||||
expressions: 239290
|
||||
securitySolution: 231753
|
||||
customIntegrations: 28810
|
||||
|
|
9
src/plugins/custom_integrations/README.md
Executable file
9
src/plugins/custom_integrations/README.md
Executable 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.
|
61
src/plugins/custom_integrations/common/index.ts
Executable file
61
src/plugins/custom_integrations/common/index.ts
Executable 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`;
|
17
src/plugins/custom_integrations/jest.config.js
Normal file
17
src/plugins/custom_integrations/jest.config.js
Normal 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}'],
|
||||
};
|
16
src/plugins/custom_integrations/kibana.json
Executable file
16
src/plugins/custom_integrations/kibana.json
Executable 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": []
|
||||
}
|
16
src/plugins/custom_integrations/public/index.ts
Executable file
16
src/plugins/custom_integrations/public/index.ts
Executable 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';
|
21
src/plugins/custom_integrations/public/mocks.ts
Normal file
21
src/plugins/custom_integrations/public/mocks.ts
Normal 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,
|
||||
};
|
28
src/plugins/custom_integrations/public/plugin.test.ts
Normal file
28
src/plugins/custom_integrations/public/plugin.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
30
src/plugins/custom_integrations/public/plugin.ts
Executable file
30
src/plugins/custom_integrations/public/plugin.ts
Executable 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() {}
|
||||
}
|
18
src/plugins/custom_integrations/public/types.ts
Executable file
18
src/plugins/custom_integrations/public/types.ts
Executable 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 {}
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
26
src/plugins/custom_integrations/server/index.ts
Executable file
26
src/plugins/custom_integrations/server/index.ts
Executable 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({}),
|
||||
};
|
24
src/plugins/custom_integrations/server/mocks.ts
Normal file
24
src/plugins/custom_integrations/server/mocks.ts
Normal 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,
|
||||
};
|
31
src/plugins/custom_integrations/server/plugin.test.ts
Normal file
31
src/plugins/custom_integrations/server/plugin.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
55
src/plugins/custom_integrations/server/plugin.ts
Executable file
55
src/plugins/custom_integrations/server/plugin.ts
Executable 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() {}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
17
src/plugins/custom_integrations/server/types.ts
Executable file
17
src/plugins/custom_integrations/server/types.ts
Executable 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 {}
|
13
src/plugins/custom_integrations/tsconfig.json
Normal file
13
src/plugins/custom_integrations/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
|
@ -8,6 +8,6 @@
|
|||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["data", "share", "urlForwarding"],
|
||||
"optionalPlugins": ["usageCollection", "telemetry"],
|
||||
"optionalPlugins": ["usageCollection", "telemetry", "customIntegrations"],
|
||||
"requiredBundles": ["kibanaReact"]
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) },
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -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" },
|
||||
|
|
15
test/api_integration/apis/custom_integration/index.ts
Normal file
15
test/api_integration/apis/custom_integration/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
26
test/api_integration/apis/custom_integration/integrations.ts
Normal file
26
test/api_integration/apis/custom_integration/integrations.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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>>
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
18
x-pack/plugins/fleet/public/services/custom_integrations.ts
Normal file
18
x-pack/plugins/fleet/public/services/custom_integrations.ts
Normal 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;
|
||||
}
|
|
@ -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'],
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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": "インストールされている統合",
|
||||
|
|
|
@ -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": "已安装集成",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue