diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index 5cf6efe324ac3..38fed4aca19dc 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -25,7 +25,7 @@ pipeline { durabilityHint('PERFORMANCE_OPTIMIZED') } triggers { - issueCommentTrigger('(?i).*jenkins\\W+run\\W+(?:the\\W+)?e2e(?:\\W+please)?.*') + issueCommentTrigger('(?i)(retest|.*jenkins\\W+run\\W+(?:the\\W+)?e2e?.*)') } parameters { booleanParam(name: 'FORCE', defaultValue: false, description: 'Whether to force the run.') @@ -60,8 +60,14 @@ pipeline { } } steps { + notifyStatus('Starting services', 'PENDING') dir("${APM_ITS}"){ - sh './scripts/compose.py start master --no-kibana --no-xpack-secure' + sh './scripts/compose.py start master --no-kibana' + } + } + post { + unsuccessful { + notifyStatus('Environmental issue', 'FAILURE') } } } @@ -77,10 +83,16 @@ pipeline { JENKINS_NODE_COOKIE = 'dontKillMe' } steps { + notifyStatus('Preparing kibana', 'PENDING') dir("${BASE_DIR}"){ sh script: "${CYPRESS_DIR}/ci/prepare-kibana.sh" } } + post { + unsuccessful { + notifyStatus('Kibana warm up failed', 'FAILURE') + } + } } stage('Smoke Tests'){ options { skipDefaultCheckout() } @@ -91,6 +103,7 @@ pipeline { } } steps{ + notifyStatus('Running smoke tests', 'PENDING') dir("${BASE_DIR}"){ sh ''' jobs -l @@ -112,6 +125,12 @@ pipeline { archiveArtifacts(allowEmptyArchive: false, artifacts: 'apm-its.log') } } + unsuccessful { + notifyStatus('Test failures', 'FAILURE') + } + success { + notifyStatus('Tests passed', 'SUCCESS') + } } } } @@ -123,3 +142,7 @@ pipeline { } } } + +def notifyStatus(String description, String status) { + withGithubNotify.notify('end2end-for-apm-ui', description, status, getBlueoceanDisplayURL()) +} diff --git a/examples/ui_action_examples/public/hello_world_trigger.ts b/examples/ui_action_examples/public/hello_world_trigger.ts index 999a7d9864707..929c9aecab17b 100644 --- a/examples/ui_action_examples/public/hello_world_trigger.ts +++ b/examples/ui_action_examples/public/hello_world_trigger.ts @@ -18,11 +18,9 @@ */ import { Trigger } from '../../../src/plugins/ui_actions/public'; -import { HELLO_WORLD_ACTION_TYPE } from './hello_world_action'; export const HELLO_WORLD_TRIGGER_ID = 'HELLO_WORLD_TRIGGER_ID'; export const helloWorldTrigger: Trigger = { id: HELLO_WORLD_TRIGGER_ID, - actionIds: [HELLO_WORLD_ACTION_TYPE], }; diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index ef0689227d6bd..bf62b4d973d4d 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -19,7 +19,7 @@ import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public'; -import { createHelloWorldAction } from './hello_world_action'; +import { createHelloWorldAction, HELLO_WORLD_ACTION_TYPE } from './hello_world_action'; import { helloWorldTrigger } from './hello_world_trigger'; interface UiActionExamplesSetupDependencies { @@ -33,8 +33,9 @@ interface UiActionExamplesStartDependencies { export class UiActionExamplesPlugin implements Plugin { - public setup(core: CoreSetup, deps: UiActionExamplesSetupDependencies) { - deps.uiActions.registerTrigger(helloWorldTrigger); + public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) { + uiActions.registerTrigger(helloWorldTrigger); + uiActions.attachAction(helloWorldTrigger.id, HELLO_WORLD_ACTION_TYPE); } public start(coreStart: CoreStart, deps: UiActionExamplesStartDependencies) { diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index 9c5f967a466bf..981ad97a31b46 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -56,15 +56,12 @@ export class UiActionsExplorerPlugin implements Plugin, deps: SetupDeps) { deps.uiActions.registerTrigger({ id: COUNTRY_TRIGGER, - actionIds: [], }); deps.uiActions.registerTrigger({ id: PHONE_TRIGGER, - actionIds: [], }); deps.uiActions.registerTrigger({ id: USER_TRIGGER, - actionIds: [], }); deps.uiActions.registerAction(lookUpWeatherAction); deps.uiActions.registerAction(viewInMapsAction); diff --git a/renovate.json5 b/renovate.json5 index 642c4a98b5799..58a64a5d0f967 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -913,6 +913,14 @@ '@types/tslib', ], }, + { + groupSlug: 'use-resize-observer', + groupName: 'use-resize-observer related packages', + packageNames: [ + 'use-resize-observer', + '@types/use-resize-observer', + ], + }, { groupSlug: 'uuid', groupName: 'uuid related packages', diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index ffdc04d156ca0..49c4d690c6876 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -55,13 +55,14 @@ const defaultContext: CoreContext = { configService, }; +export const createCoreContext = (overrides: Partial = {}): CoreContext => ({ + ...defaultContext, + ...overrides, +}); + /** * Creates a concrete HttpServer with a mocked context. */ export const createHttpServer = (overrides: Partial = {}): HttpService => { - const context = { - ...defaultContext, - ...overrides, - }; - return new HttpService(context); + return new HttpService(createCoreContext(overrides)); }; diff --git a/src/core/server/legacy/legacy_service.mock.ts b/src/core/server/legacy/legacy_service.mock.ts index 44405dc391d8e..26ec52185a5d8 100644 --- a/src/core/server/legacy/legacy_service.mock.ts +++ b/src/core/server/legacy/legacy_service.mock.ts @@ -29,6 +29,7 @@ const createDiscoverPluginsMock = (): LegacyServiceDiscoverPlugins => ({ savedObjectMappings: [], savedObjectMigrations: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }, navLinks: [], pluginExtendedConfig: { diff --git a/src/core/server/legacy/plugins/get_nav_links.test.ts b/src/core/server/legacy/plugins/get_nav_links.test.ts index dcb19020f769e..44d080ec37a25 100644 --- a/src/core/server/legacy/plugins/get_nav_links.test.ts +++ b/src/core/server/legacy/plugins/get_nav_links.test.ts @@ -35,6 +35,7 @@ const createLegacyExports = ({ savedObjectSchemas: {}, savedObjectMigrations: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }); const createPluginSpecs = (...ids: string[]): LegacyPluginSpec[] => diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 9a7868d568ea0..d6554babab53e 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -25,6 +25,7 @@ import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service. import { httpServiceMock } from './http/http_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; +import { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { InternalCoreSetup, InternalCoreStart } from './internal_types'; @@ -36,7 +37,6 @@ export { configServiceMock } from './config/config_service.mock'; export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; export { httpServiceMock } from './http/http_service.mock'; export { loggingServiceMock } from './logging/logging_service.mock'; -export { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; @@ -168,10 +168,31 @@ function createInternalCoreStartMock() { return startDeps; } +function createCoreRequestHandlerContextMock() { + return { + rendering: { + render: jest.fn(), + }, + savedObjects: { + client: savedObjectsClientMock.create(), + }, + elasticsearch: { + adminClient: elasticsearchServiceMock.createScopedClusterClient(), + dataClient: elasticsearchServiceMock.createScopedClusterClient(), + }, + uiSettings: { + client: uiSettingsServiceMock.createClient(), + }, + }; +} + export const coreMock = { createSetup: createCoreSetupMock, createStart: createCoreStartMock, createInternalSetup: createInternalCoreSetupMock, createInternalStart: createInternalCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, + createRequestHandlerContext: createCoreRequestHandlerContextMock, }; + +export { savedObjectsClientMock }; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index fafa04447ddfe..1088478add137 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -492,7 +492,6 @@ describe('getSortedObjectsForExport()', () => { const exportStream = await getSortedObjectsForExport({ exportSizeLimit: 10000, savedObjectsClient, - types: ['index-pattern', 'search'], objects: [ { type: 'index-pattern', @@ -591,7 +590,6 @@ describe('getSortedObjectsForExport()', () => { const exportStream = await getSortedObjectsForExport({ exportSizeLimit: 10000, savedObjectsClient, - types: ['index-pattern', 'search'], objects: [ { type: 'search', @@ -672,7 +670,6 @@ describe('getSortedObjectsForExport()', () => { const exportOpts = { exportSizeLimit: 1, savedObjectsClient, - types: ['index-pattern', 'search'], objects: [ { type: 'index-pattern', diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index a4dfacfd9e34f..4b4cf1146aca0 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -84,6 +84,9 @@ async function fetchObjectsToExport({ savedObjectsClient: SavedObjectsClientContract; namespace?: string; }) { + if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { + throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); + } if (objects && objects.length > 0) { if (objects.length > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 529ee9599f178..5be4458bdf2af 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -68,5 +68,5 @@ export { SavedObjectMigrationMap, SavedObjectMigrationFn } from './migrations'; export { SavedObjectsType } from './types'; -export { config } from './saved_objects_config'; +export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 9b4fe10a35100..494f834717def 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -30,14 +30,14 @@ import { docValidator, PropertyValidators } from '../../validation'; import { buildActiveMappings, CallCluster, IndexMigrator } from '../core'; import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator'; import { createIndexMap } from '../core/build_index_map'; -import { SavedObjectsConfigType } from '../../saved_objects_config'; +import { SavedObjectsMigrationConfigType } from '../../saved_objects_config'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; export interface KibanaMigratorOptions { callCluster: CallCluster; typeRegistry: ISavedObjectTypeRegistry; - savedObjectsConfig: SavedObjectsConfigType; + savedObjectsConfig: SavedObjectsMigrationConfigType; kibanaConfig: KibanaConfigType; kibanaVersion: string; logger: Logger; @@ -51,7 +51,7 @@ export type IKibanaMigrator = Pick; */ export class KibanaMigrator { private readonly callCluster: CallCluster; - private readonly savedObjectsConfig: SavedObjectsConfigType; + private readonly savedObjectsConfig: SavedObjectsMigrationConfigType; private readonly documentMigrator: VersionedTransformer; private readonly kibanaConfig: KibanaConfigType; private readonly log: Logger; diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts new file mode 100644 index 0000000000000..af1a7bd2af9b7 --- /dev/null +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerBulkCreateRoute = (router: IRouter) => { + router.post( + { + path: '/_bulk_create', + validate: { + query: schema.object({ + overwrite: schema.boolean({ defaultValue: false }), + }), + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.maybe(schema.string()), + attributes: schema.recordOf(schema.string(), schema.any()), + version: schema.maybe(schema.string()), + migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }) + ), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { overwrite } = req.query; + const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite }); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/bulk_get.ts b/src/core/server/saved_objects/routes/bulk_get.ts new file mode 100644 index 0000000000000..067388dcf9220 --- /dev/null +++ b/src/core/server/saved_objects/routes/bulk_get.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerBulkGetRoute = (router: IRouter) => { + router.post( + { + path: '/_bulk_get', + validate: { + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + fields: schema.maybe(schema.arrayOf(schema.string())), + }) + ), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await context.core.savedObjects.client.bulkGet(req.body); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts new file mode 100644 index 0000000000000..c112833b29f3f --- /dev/null +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerBulkUpdateRoute = (router: IRouter) => { + router.put( + { + path: '/_bulk_update', + validate: { + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + attributes: schema.recordOf(schema.string(), schema.any()), + version: schema.maybe(schema.string()), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }) + ), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObject = await context.core.savedObjects.client.bulkUpdate(req.body); + return res.ok({ body: savedObject }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts new file mode 100644 index 0000000000000..6cf906a3b2895 --- /dev/null +++ b/src/core/server/saved_objects/routes/create.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerCreateRoute = (router: IRouter) => { + router.post( + { + path: '/{type}/{id?}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.maybe(schema.string()), + }), + query: schema.object({ + overwrite: schema.boolean({ defaultValue: false }), + }), + body: schema.object({ + attributes: schema.recordOf(schema.string(), schema.any()), + migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const { overwrite } = req.query; + const { attributes, migrationVersion, references } = req.body; + + const options = { id, overwrite, migrationVersion, references }; + const result = await context.core.savedObjects.client.create(type, attributes, options); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.ts b/src/core/server/saved_objects/routes/delete.ts similarity index 59% rename from src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.ts rename to src/core/server/saved_objects/routes/delete.ts index 8be0db7561db9..d119455336212 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -17,16 +17,24 @@ * under the License. */ -import { UiActionsApiPure } from '../types'; -import { Action } from '../actions/action'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; -export const getTriggerCompatibleActions: UiActionsApiPure['getTriggerCompatibleActions'] = ({ - api, -}) => async (triggerId, context) => { - const actions = api.getTriggerActions!(triggerId); - const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context))); - return actions.reduce( - (acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc), - [] +export const registerDeleteRoute = (router: IRouter) => { + router.delete( + { + path: '/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const result = await context.core.savedObjects.client.delete(type, id); + return res.ok({ body: result }); + }) ); }; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts new file mode 100644 index 0000000000000..ab287332d8a65 --- /dev/null +++ b/src/core/server/saved_objects/routes/export.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import stringify from 'json-stable-stringify'; +import { + createPromiseFromStreams, + createMapStream, + createConcatStream, +} from '../../../../legacy/utils/streams'; +import { IRouter } from '../../http'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { getSortedObjectsForExport } from '../export'; + +export const registerExportRoute = ( + router: IRouter, + config: SavedObjectConfig, + supportedTypes: string[] +) => { + const { maxImportExportSize } = config; + + const typeSchema = schema.string({ + validate: (type: string) => { + if (!supportedTypes.includes(type)) { + return `${type} is not exportable`; + } + }, + }); + + router.post( + { + path: '/_export', + validate: { + body: schema.object({ + type: schema.maybe(schema.oneOf([typeSchema, schema.arrayOf(typeSchema)])), + objects: schema.maybe( + schema.arrayOf( + schema.object({ + type: typeSchema, + id: schema.string(), + }), + { maxSize: maxImportExportSize } + ) + ), + search: schema.maybe(schema.string()), + includeReferencesDeep: schema.boolean({ defaultValue: false }), + excludeExportDetails: schema.boolean({ defaultValue: false }), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body; + const exportStream = await getSortedObjectsForExport({ + savedObjectsClient, + types: typeof type === 'string' ? [type] : type, + search, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails, + }); + + const docsToExport: string[] = await createPromiseFromStreams([ + exportStream, + createMapStream((obj: unknown) => { + return stringify(obj); + }), + createConcatStream([]), + ]); + + return res.ok({ + body: docsToExport.join('\n'), + headers: { + 'Content-Disposition': `attachment; filename="export.ndjson"`, + 'Content-Type': 'application/ndjson', + }, + }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts new file mode 100644 index 0000000000000..5c1c2c9a9ab87 --- /dev/null +++ b/src/core/server/saved_objects/routes/find.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerFindRoute = (router: IRouter) => { + router.get( + { + path: '/_find', + validate: { + query: schema.object({ + per_page: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + type: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + search: schema.maybe(schema.string()), + default_search_operator: schema.oneOf([schema.literal('OR'), schema.literal('AND')], { + defaultValue: 'OR', + }), + search_fields: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), + sort_field: schema.maybe(schema.string()), + has_reference: schema.maybe( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + filter: schema.maybe(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const query = req.query; + const result = await context.core.savedObjects.client.find({ + perPage: query.per_page, + page: query.page, + type: Array.isArray(query.type) ? query.type : [query.type], + search: query.search, + defaultSearchOperator: query.default_search_operator, + searchFields: + typeof query.search_fields === 'string' ? [query.search_fields] : query.search_fields, + sortField: query.sort_field, + hasReference: query.has_reference, + fields: typeof query.fields === 'string' ? [query.fields] : query.fields, + filter: query.filter, + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/plugins/ui_actions/public/triggers/attach_action.ts b/src/core/server/saved_objects/routes/get.ts similarity index 58% rename from src/plugins/ui_actions/public/triggers/attach_action.ts rename to src/core/server/saved_objects/routes/get.ts index 6c0beeae2bcd7..f1b974c70b1a9 100644 --- a/src/plugins/ui_actions/public/triggers/attach_action.ts +++ b/src/core/server/saved_objects/routes/get.ts @@ -17,21 +17,24 @@ * under the License. */ -import { UiActionsApiPure } from '../types'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; -export const attachAction: UiActionsApiPure['attachAction'] = ({ triggers }) => ( - triggerId, - actionId -) => { - const trigger = triggers.get(triggerId); - - if (!trigger) { - throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` - ); - } - - if (!trigger.actionIds.find(id => id === actionId)) { - trigger.actionIds.push(actionId); - } +export const registerGetRoute = (router: IRouter) => { + router.get( + { + path: '/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const savedObject = await context.core.savedObjects.client.get(type, id); + return res.ok({ body: savedObject }); + }) + ); }; diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts new file mode 100644 index 0000000000000..e3f249dca05f7 --- /dev/null +++ b/src/core/server/saved_objects/routes/import.ts @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Readable } from 'stream'; +import { extname } from 'path'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { importSavedObjects } from '../import'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { createSavedObjectsStreamFromNdJson } from './utils'; + +interface FileStream extends Readable { + hapi: { + filename: string; + }; +} + +export const registerImportRoute = ( + router: IRouter, + config: SavedObjectConfig, + supportedTypes: string[] +) => { + const { maxImportExportSize, maxImportPayloadBytes } = config; + + router.post( + { + path: '/_import', + options: { + body: { + maxBytes: maxImportPayloadBytes, + output: 'stream', + accepts: 'multipart/form-data', + }, + }, + validate: { + query: schema.object({ + overwrite: schema.boolean({ defaultValue: false }), + }), + body: schema.object({ + file: schema.stream(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { overwrite } = req.query; + const file = req.body.file as FileStream; + const fileExtension = extname(file.hapi.filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); + } + + const result = await importSavedObjects({ + supportedTypes, + savedObjectsClient: context.core.savedObjects.client, + readStream: createSavedObjectsStreamFromNdJson(file), + objectLimit: maxImportExportSize, + overwrite, + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts new file mode 100644 index 0000000000000..f2f57798dd5f0 --- /dev/null +++ b/src/core/server/saved_objects/routes/index.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { InternalHttpServiceSetup } from '../../http'; +import { Logger } from '../../logging'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { registerGetRoute } from './get'; +import { registerCreateRoute } from './create'; +import { registerDeleteRoute } from './delete'; +import { registerFindRoute } from './find'; +import { registerUpdateRoute } from './update'; +import { registerBulkGetRoute } from './bulk_get'; +import { registerBulkCreateRoute } from './bulk_create'; +import { registerBulkUpdateRoute } from './bulk_update'; +import { registerLogLegacyImportRoute } from './log_legacy_import'; +import { registerExportRoute } from './export'; +import { registerImportRoute } from './import'; +import { registerResolveImportErrorsRoute } from './resolve_import_errors'; + +export function registerRoutes({ + http, + logger, + config, + importableExportableTypes, +}: { + http: InternalHttpServiceSetup; + logger: Logger; + config: SavedObjectConfig; + importableExportableTypes: string[]; +}) { + const router = http.createRouter('/api/saved_objects/'); + + registerGetRoute(router); + registerCreateRoute(router); + registerDeleteRoute(router); + registerFindRoute(router); + registerUpdateRoute(router); + registerBulkGetRoute(router); + registerBulkCreateRoute(router); + registerBulkUpdateRoute(router); + registerLogLegacyImportRoute(router, logger); + registerExportRoute(router, config, importableExportableTypes); + registerImportRoute(router, config, importableExportableTypes); + registerResolveImportErrorsRoute(router, config, importableExportableTypes); +} diff --git a/src/legacy/server/saved_objects/routes/bulk_create.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts similarity index 55% rename from src/legacy/server/saved_objects/routes/bulk_create.test.ts rename to src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts index b49554995aab6..5b52665b6268e 100644 --- a/src/legacy/server/saved_objects/routes/bulk_create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts @@ -17,52 +17,36 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createBulkCreateRoute } from './bulk_create'; -// Disable lint errors for imports from src/core/* until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { savedObjectsClientMock } from '../../../../core/server/saved_objects/service/saved_objects_client.mock'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkCreateRoute } from '../bulk_create'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('POST /api/saved_objects/_bulk_create', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - savedObjectsClient.bulkCreate.mockImplementation(() => Promise.resolve('' as any)); - server = createMockServer(); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: [] }); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + const router = httpSetup.createRouter('/api/saved_objects/'); + registerBulkCreateRoute(router); - server.route(createBulkCreateRoute(prereqs)); + await server.start(); }); - afterEach(() => { - savedObjectsClient.bulkCreate.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_create', - payload: [ - { - id: 'abc123', - type: 'index-pattern', - attributes: { - title: 'my_title', - }, - }, - ], - }; - const clientResponse = { saved_objects: [ { @@ -75,14 +59,22 @@ describe('POST /api/saved_objects/_bulk_create', () => { }, ], }; + savedObjectsClient.bulkCreate.mockResolvedValue(clientResponse); - savedObjectsClient.bulkCreate.mockImplementation(() => Promise.resolve(clientResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_create') + .send([ + { + id: 'abc123', + type: 'index-pattern', + attributes: { + title: 'my_title', + }, + }, + ]) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); + expect(result.body).toEqual(clientResponse); }); it('calls upon savedObjectClient.bulkCreate', async () => { @@ -105,24 +97,20 @@ describe('POST /api/saved_objects/_bulk_create', () => { }, ]; - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_create', - payload: docs, - }; - - await server.inject(request); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_create') + .send(docs) + .expect(200); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); const args = savedObjectsClient.bulkCreate.mock.calls[0]; expect(args[0]).toEqual(docs); }); it('passes along the overwrite option', async () => { - await server.inject({ - method: 'POST', - url: '/api/saved_objects/_bulk_create?overwrite=true', - payload: [ + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_create?overwrite=true') + .send([ { id: 'abc1234', type: 'index-pattern', @@ -131,11 +119,10 @@ describe('POST /api/saved_objects/_bulk_create', () => { }, references: [], }, - ], - }); - - expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + ]) + .expect(200); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); const args = savedObjectsClient.bulkCreate.mock.calls[0]; expect(args[1]).toEqual({ overwrite: true }); }); diff --git a/src/legacy/server/saved_objects/routes/bulk_get.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts similarity index 53% rename from src/legacy/server/saved_objects/routes/bulk_get.test.ts rename to src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts index e154649e2cf04..845bae47b41f2 100644 --- a/src/legacy/server/saved_objects/routes/bulk_get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts @@ -17,50 +17,38 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createBulkGetRoute } from './bulk_get'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkGetRoute } from '../bulk_get'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('POST /api/saved_objects/_bulk_get', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - savedObjectsClient.bulkGet.mockImplementation(() => - Promise.resolve({ - saved_objects: [], - }) - ); - server = createMockServer(); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [], + }); + const router = httpSetup.createRouter('/api/saved_objects/'); + registerBulkGetRoute(router); - server.route(createBulkGetRoute(prereqs)); + await server.start(); }); - afterEach(() => { - savedObjectsClient.bulkGet.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_get', - payload: [ - { - id: 'abc123', - type: 'index-pattern', - }, - ], - }; - const clientResponse = { saved_objects: [ { @@ -73,14 +61,19 @@ describe('POST /api/saved_objects/_bulk_get', () => { }, ], }; - savedObjectsClient.bulkGet.mockImplementation(() => Promise.resolve(clientResponse)); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_get') + .send([ + { + id: 'abc123', + type: 'index-pattern', + }, + ]) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); + expect(result.body).toEqual(clientResponse); }); it('calls upon savedObjectClient.bulkGet', async () => { @@ -91,14 +84,12 @@ describe('POST /api/saved_objects/_bulk_get', () => { }, ]; - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_get', - payload: docs, - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_get') + .send(docs) + .expect(200); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(docs); }); }); diff --git a/src/legacy/server/saved_objects/routes/bulk_update.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts similarity index 65% rename from src/legacy/server/saved_objects/routes/bulk_update.test.ts rename to src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts index dc21ab08035ce..6356fc787a8d8 100644 --- a/src/legacy/server/saved_objects/routes/bulk_update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts @@ -17,56 +17,35 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createBulkUpdateRoute } from './bulk_update'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkUpdateRoute } from '../bulk_update'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('PUT /api/saved_objects/_bulk_update', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - server = createMockServer(); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + const router = httpSetup.createRouter('/api/saved_objects/'); + registerBulkUpdateRoute(router); - server.route(createBulkUpdateRoute(prereqs)); + await server.start(); }); - afterEach(() => { - savedObjectsClient.bulkUpdate.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/_bulk_update', - payload: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - attributes: { - title: 'An existing visualization', - }, - }, - { - type: 'dashboard', - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - attributes: { - title: 'An existing dashboard', - }, - }, - ], - }; - const time = Date.now().toLocaleString(); const clientResponse = [ { @@ -90,23 +69,37 @@ describe('PUT /api/saved_objects/_bulk_update', () => { }, }, ]; + savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: clientResponse }); - savedObjectsClient.bulkUpdate.mockImplementation(() => - Promise.resolve({ saved_objects: clientResponse }) - ); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .put('/api/saved_objects/_bulk_update') + .send([ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + attributes: { + title: 'An existing visualization', + }, + }, + { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + attributes: { + title: 'An existing dashboard', + }, + }, + ]) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual({ saved_objects: clientResponse }); + expect(result.body).toEqual({ saved_objects: clientResponse }); }); it('calls upon savedObjectClient.bulkUpdate', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/_bulk_update', - payload: [ + savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: [] }); + + await supertest(httpSetup.server.listener) + .put('/api/saved_objects/_bulk_update') + .send([ { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -121,13 +114,10 @@ describe('PUT /api/saved_objects/_bulk_update', () => { title: 'An existing dashboard', }, }, - ], - }; - - savedObjectsClient.bulkUpdate.mockImplementation(() => Promise.resolve({ saved_objects: [] })); - - await server.inject(request); + ]) + .expect(200); + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith([ { type: 'visualization', diff --git a/src/core/server/saved_objects/routes/integration_tests/create.test.ts b/src/core/server/saved_objects/routes/integration_tests/create.test.ts new file mode 100644 index 0000000000000..5a53a30209281 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/create.test.ts @@ -0,0 +1,120 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerCreateRoute } from '../create'; +import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +describe('POST /api/saved_objects/{type}', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const clientResponse = { + id: 'logstash-*', + type: 'index-pattern', + title: 'logstash-*', + version: 'foo', + references: [], + attributes: {}, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse)); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerCreateRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern') + .send({ + attributes: { + title: 'Testing', + }, + }) + .expect(200); + + expect(result.body).toEqual(clientResponse); + }); + + it('requires attributes', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern') + .send({}) + .expect(400); + + // expect(response.validation.keys).toContain('attributes'); + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.attributes]: expected value of type [object] but got [undefined]"` + ); + }); + + it('calls upon savedObjectClient.create', async () => { + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern') + .send({ + attributes: { + title: 'Testing', + }, + }) + .expect(200); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + { title: 'Testing' }, + { overwrite: false, id: undefined, migrationVersion: undefined } + ); + }); + + it('can specify an id', async () => { + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern/logstash-*') + .send({ + attributes: { + title: 'Testing', + }, + }) + .expect(200); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + + const args = savedObjectsClient.create.mock.calls[0]; + expect(args).toEqual([ + 'index-pattern', + { title: 'Testing' }, + { overwrite: false, id: 'logstash-*' }, + ]); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts new file mode 100644 index 0000000000000..d4ce4d421dde1 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerDeleteRoute } from '../delete'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +describe('DELETE /api/saved_objects/{type}/{id}', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerDeleteRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .delete('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(result.body).toEqual({}); + }); + + it('calls upon savedObjectClient.delete', async () => { + await supertest(httpSetup.server.listener) + .delete('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*'); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts new file mode 100644 index 0000000000000..b52a8957176cc --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('../../export', () => ({ + getSortedObjectsForExport: jest.fn(), +})); + +import * as exportMock from '../../export'; +import { createListStream } from '../../../../../legacy/utils/streams'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { SavedObjectConfig } from '../../saved_objects_config'; +import { registerExportRoute } from '../export'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; +const getSortedObjectsForExport = exportMock.getSortedObjectsForExport as jest.Mock; +const allowedTypes = ['index-pattern', 'search']; +const config = { + maxImportPayloadBytes: 10485760, + maxImportExportSize: 10000, +} as SavedObjectConfig; + +describe('POST /api/saved_objects/_export', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer()); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerExportRoute(router, config, allowedTypes); + + await server.start(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await server.stop(); + }); + + it('formats successful response', async () => { + const sortedObjects = [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'search', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + ], + }, + ]; + getSortedObjectsForExport.mockResolvedValueOnce(createListStream(sortedObjects)); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_export') + .send({ + type: 'search', + search: 'my search string', + includeReferencesDeep: true, + }); + + expect(result.status).toBe(200); + expect(result.header).toEqual( + expect.objectContaining({ + 'content-disposition': 'attachment; filename="export.ndjson"', + 'content-type': 'application/ndjson', + }) + ); + + const objects = (result.text as string).split('\n').map(row => JSON.parse(row)); + expect(objects).toEqual(sortedObjects); + expect(getSortedObjectsForExport.mock.calls[0][0]).toEqual( + expect.objectContaining({ + excludeExportDetails: false, + exportSizeLimit: 10000, + includeReferencesDeep: true, + objects: undefined, + search: 'my search string', + types: ['search'], + }) + ); + }); +}); diff --git a/src/legacy/server/saved_objects/routes/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts similarity index 55% rename from src/legacy/server/saved_objects/routes/find.test.ts rename to src/core/server/saved_objects/routes/integration_tests/find.test.ts index 4bf5f57fec199..907bf44c7748f 100644 --- a/src/legacy/server/saved_objects/routes/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -17,14 +17,21 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createFindRoute } from './find'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import querystring from 'querystring'; + +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerFindRoute } from '../find'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('GET /api/saved_objects/_find', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; const clientResponse = { total: 0, @@ -32,48 +39,34 @@ describe('GET /api/saved_objects/_find', () => { per_page: 0, page: 0, }; - beforeEach(() => { - savedObjectsClient.find.mockImplementation(() => Promise.resolve(clientResponse)); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - server.route(createFindRoute(prereqs)); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.find.mockResolvedValue(clientResponse); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerFindRoute(router); + + await server.start(); }); - afterEach(() => { - savedObjectsClient.find.mockReset(); + afterEach(async () => { + await server.stop(); }); it('returns with status 400 when type is missing', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find', - }; + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find') + .expect(400); - const { payload, statusCode } = await server.inject(request); - - expect(statusCode).toEqual(400); - expect(JSON.parse(payload)).toMatchObject({ - statusCode: 400, - error: 'Bad Request', - message: 'child "type" fails because ["type" is required]', - }); + expect(result.body.message).toContain( + '[request query.type]: expected at least one defined value' + ); }); it('formats successful response', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=index-pattern', - }; - const findResponse = { total: 2, per_page: 2, @@ -99,23 +92,19 @@ describe('GET /api/saved_objects/_find', () => { }, ], }; + savedObjectsClient.find.mockResolvedValue(findResponse); - savedObjectsClient.find.mockImplementation(() => Promise.resolve(findResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern') + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(findResponse); + expect(result.body).toEqual(findResponse); }); it('calls upon savedObjectClient.find with defaults', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&type=bar', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&type=bar') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -129,12 +118,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter page/per_page', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&per_page=10&page=50', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&per_page=10&page=50') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -142,13 +128,41 @@ describe('GET /api/saved_objects/_find', () => { expect(options).toEqual({ perPage: 10, page: 50, type: ['foo'], defaultSearchOperator: 'OR' }); }); - it('accepts the query parameter search_fields', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&search_fields=title', - }; + it('accepts the optional query parameter has_reference', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options.hasReference).toBe(undefined); + }); + + it('accepts the query parameter has_reference', async () => { + const references = querystring.escape( + JSON.stringify({ + id: '1', + type: 'reference', + }) + ); + await supertest(httpSetup.server.listener) + .get(`/api/saved_objects/_find?type=foo&has_reference=${references}`) + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options.hasReference).toEqual({ + id: '1', + type: 'reference', + }); + }); - await server.inject(request); + it('accepts the query parameter search_fields', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&search_fields=title') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -163,12 +177,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter fields as a string', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&fields=title', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&fields=title') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -183,12 +194,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter fields as an array', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&fields=title&fields=description', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&fields=title&fields=description') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -203,12 +211,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter type as a string', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=index-pattern', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -222,12 +227,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter type as an array', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=index-pattern&type=visualization', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&type=visualization') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); diff --git a/src/core/server/saved_objects/routes/integration_tests/get.test.ts b/src/core/server/saved_objects/routes/integration_tests/get.test.ts new file mode 100644 index 0000000000000..1e3405d7a318f --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/get.test.ts @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import supertest from 'supertest'; +import { registerGetRoute } from '../get'; +import { ContextService } from '../../../context'; +import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; +import { HttpService, InternalHttpServiceSetup } from '../../../http'; +import { createHttpServer, createCoreContext } from '../../../http/test_utils'; +import { coreMock } from '../../../mocks'; + +const coreId = Symbol('core'); + +describe('GET /api/saved_objects/{type}/{id}', () => { + let server: HttpService; + let httpSetup: InternalHttpServiceSetup; + let handlerContext: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(async () => { + const coreContext = createCoreContext({ coreId }); + server = createHttpServer(coreContext); + + const contextService = new ContextService(coreContext); + httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), + }); + + handlerContext = coreMock.createRequestHandlerContext(); + savedObjectsClient = handlerContext.savedObjects.client; + + httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { + return handlerContext; + }); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerGetRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const clientResponse = { + id: 'logstash-*', + title: 'logstash-*', + type: 'logstash-type', + attributes: {}, + timeFieldName: '@timestamp', + notExpandable: true, + references: [], + }; + + savedObjectsClient.get.mockResolvedValue(clientResponse); + + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(result.body).toEqual(clientResponse); + }); + + it('calls upon savedObjectClient.get', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(savedObjectsClient.get).toHaveBeenCalled(); + + const args = savedObjectsClient.get.mock.calls[0]; + expect(args).toEqual(['index-pattern', 'logstash-*']); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts new file mode 100644 index 0000000000000..2c8d568b750c6 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -0,0 +1,320 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerImportRoute } from '../import'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { SavedObjectConfig } from '../../saved_objects_config'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const config = { + maxImportPayloadBytes: 10485760, + maxImportExportSize: 10000, +} as SavedObjectConfig; + +describe('POST /api/saved_objects/_import', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const emptyResponse = { + saved_objects: [], + total: 0, + per_page: 0, + page: 0, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.find.mockResolvedValue(emptyResponse); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerImportRoute(router, config, allowedTypes); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') + .send( + [ + '--BOUNDARY', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '', + '--BOUNDARY--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 0, + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + }); + + it('defaults migrationVersion to empty object', async () => { + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: { + title: 'my-pattern-*', + }, + references: [], + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 1, + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; + expect(firstBulkCreateCallArray).toHaveLength(1); + expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); + }); + + it('imports an index pattern and dashboard, ignoring empty lines in the file', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: { + title: 'my-pattern-*', + }, + references: [], + }, + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + references: [], + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '', + '', + '', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + }); + }); + + it('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: {}, + references: [], + error: { + statusCode: 409, + message: 'version conflict, document already exists', + }, + }, + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + references: [], + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 1, + errors: [ + { + id: 'my-pattern', + type: 'index-pattern', + title: 'my-pattern-*', + error: { + type: 'conflict', + }, + }, + ], + }); + }); + + it('imports a visualization with missing references', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: 'my-pattern-*', + type: 'index-pattern', + error: { + statusCode: 404, + message: 'Not found', + }, + references: [], + attributes: {}, + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 0, + errors: [ + { + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + error: { + type: 'missing_references', + references: [ + { + type: 'index-pattern', + id: 'my-pattern-*', + }, + ], + blocking: [ + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "my-pattern-*", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts new file mode 100644 index 0000000000000..4bbe3271e0232 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerLogLegacyImportRoute } from '../log_legacy_import'; +import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +describe('POST /api/saved_objects/_log_legacy_import', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let logger: ReturnType; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer()); + logger = loggingServiceMock.createLogger(); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerLogLegacyImportRoute(router, logger); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('logs a warning when called', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_log_legacy_import') + .expect(200); + + expect(result.body).toEqual({ success: true }); + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + Array [ + Array [ + "Importing saved objects from a .json file has been deprecated", + ], + ] + `); + }); +}); diff --git a/src/legacy/server/saved_objects/routes/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts similarity index 51% rename from src/legacy/server/saved_objects/routes/resolve_import_errors.test.ts rename to src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 44fa46bccfce5..c2974395217f8 100644 --- a/src/legacy/server/saved_objects/routes/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -17,84 +17,66 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createResolveImportErrorsRoute } from './resolve_import_errors'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; +import { SavedObjectConfig } from '../../saved_objects_config'; + +type setupServerReturn = UnwrapPromise>; + +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const config = { + maxImportPayloadBytes: 10485760, + maxImportExportSize: 10000, +} as SavedObjectConfig; describe('POST /api/saved_objects/_resolve_import_errors', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - server = createMockServer(); - jest.resetAllMocks(); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + const router = httpSetup.createRouter('/api/saved_objects/'); + registerResolveImportErrorsRoute(router, config, allowedTypes); - server.route( - createResolveImportErrorsRoute(prereqs, server, [ - 'index-pattern', - 'visualization', - 'dashboard', - ]) - ); + await server.start(); }); - test('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--BOUNDARY', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '', - '--BOUNDARY', - 'Content-Disposition: form-data; name="retries"', - '', - '[]', - '--BOUNDARY--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=BOUNDARY', - }, - }; - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 0 }); + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') + .send( + [ + '--BOUNDARY', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '', + '--BOUNDARY', + 'Content-Disposition: form-data; name="retries"', + '', + '[]', + '--BOUNDARY--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 0 }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); }); - test('defaults migrationVersion to empty object', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"dashboard","id":"my-dashboard"}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; + it('defaults migrationVersion to empty object', async () => { savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -107,37 +89,35 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"dashboard","id":"my-dashboard"}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1); const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; expect(firstBulkCreateCallArray).toHaveLength(1); expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); }); - test('retries importing a dashboard', async () => { + it('retries importing a dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"dashboard","id":"my-dashboard"}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -150,10 +130,27 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"dashboard","id":"my-dashboard"}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ @@ -183,28 +180,8 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { `); }); - test('resolves conflicts for dashboard', async () => { + it('resolves conflicts for dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"dashboard","id":"my-dashboard","overwrite":true}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -217,10 +194,28 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"dashboard","id":"my-dashboard","overwrite":true}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ @@ -251,27 +246,8 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { `); }); - test('resolves conflicts by replacing the visualization references', async () => { + it('resolves conflicts by replacing the visualization references', async () => { // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -300,10 +276,27 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ diff --git a/src/plugins/ui_actions/public/triggers/get_trigger.test.ts b/src/core/server/saved_objects/routes/integration_tests/test_utils.ts similarity index 51% rename from src/plugins/ui_actions/public/triggers/get_trigger.test.ts rename to src/core/server/saved_objects/routes/integration_tests/test_utils.ts index 88dd5a8990c9d..093b36a413214 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/test_utils.ts @@ -17,32 +17,29 @@ * under the License. */ -import { createApi } from '../api'; -import { createDeps } from '../tests/helpers'; +import { ContextService } from '../../../context'; +import { createHttpServer, createCoreContext } from '../../../http/test_utils'; +import { coreMock } from '../../../mocks'; -test('can get Trigger from registry', () => { - const deps = createDeps(); - const { api } = createApi(deps); - api.registerTrigger({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); +const coreId = Symbol('core'); - const trigger = api.getTrigger('bar'); +export const setupServer = async () => { + const coreContext = createCoreContext({ coreId }); + const contextService = new ContextService(coreContext); - expect(trigger).toEqual({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', + const server = createHttpServer(coreContext); + const httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), }); -}); + const handlerContext = coreMock.createRequestHandlerContext(); -test('throws if trigger does not exist', () => { - const deps = createDeps(); - const { api } = createApi(deps); + httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { + return handlerContext; + }); - expect(() => api.getTrigger('foo')).toThrowError('Trigger [triggerId = foo] does not exist.'); -}); + return { + server, + httpSetup, + handlerContext, + }; +}; diff --git a/src/legacy/server/saved_objects/routes/update.test.ts b/src/core/server/saved_objects/routes/integration_tests/update.test.ts similarity index 56% rename from src/legacy/server/saved_objects/routes/update.test.ts rename to src/core/server/saved_objects/routes/integration_tests/update.test.ts index aaeaff489d30a..b0c3d68090db6 100644 --- a/src/legacy/server/saved_objects/routes/update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/update.test.ts @@ -17,16 +17,21 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createUpdateRoute } from './update'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerUpdateRoute } from '../update'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('PUT /api/saved_objects/{type}/{id?}', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { + beforeEach(async () => { const clientResponse = { id: 'logstash-*', title: 'logstash-*', @@ -36,37 +41,22 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { notExpandable: true, references: [], }; - savedObjectsClient.update.mockImplementation(() => Promise.resolve(clientResponse)); - server = createMockServer(); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.update.mockResolvedValue(clientResponse); - server.route(createUpdateRoute(prereqs)); + const router = httpSetup.createRouter('/api/saved_objects/'); + registerUpdateRoute(router); + + await server.start(); }); - afterEach(() => { - savedObjectsClient.update.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/index-pattern/logstash-*', - payload: { - attributes: { - title: 'Testing', - }, - references: [], - }, - }; - const clientResponse = { id: 'logstash-*', title: 'logstash-*', @@ -76,27 +66,29 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { attributes: {}, references: [], }; + savedObjectsClient.update.mockResolvedValue(clientResponse); - savedObjectsClient.update.mockImplementation(() => Promise.resolve(clientResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .put('/api/saved_objects/index-pattern/logstash-*') + .send({ + attributes: { + title: 'Testing', + }, + references: [], + }) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); + expect(result.body).toEqual(clientResponse); }); it('calls upon savedObjectClient.update', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/index-pattern/logstash-*', - payload: { + await supertest(httpSetup.server.listener) + .put('/api/saved_objects/index-pattern/logstash-*') + .send({ attributes: { title: 'Testing' }, version: 'foo', - }, - }; - - await server.inject(request); + }) + .expect(200); expect(savedObjectsClient.update).toHaveBeenCalledWith( 'index-pattern', diff --git a/src/legacy/server/saved_objects/routes/log_legacy_import.ts b/src/core/server/saved_objects/routes/log_legacy_import.ts similarity index 65% rename from src/legacy/server/saved_objects/routes/log_legacy_import.ts rename to src/core/server/saved_objects/routes/log_legacy_import.ts index 038c03d30e030..459b38abb9874 100644 --- a/src/legacy/server/saved_objects/routes/log_legacy_import.ts +++ b/src/core/server/saved_objects/routes/log_legacy_import.ts @@ -17,18 +17,18 @@ * under the License. */ -import Hapi from 'hapi'; +import { IRouter } from '../../http'; +import { Logger } from '../../logging'; -export const createLogLegacyImportRoute = () => ({ - path: '/api/saved_objects/_log_legacy_import', - method: 'POST', - options: { - handler(request: Hapi.Request) { - request.server.log( - ['warning'], - 'Importing saved objects from a .json file has been deprecated' - ); - return { success: true }; +export const registerLogLegacyImportRoute = (router: IRouter, logger: Logger) => { + router.post( + { + path: '/_log_legacy_import', + validate: false, }, - }, -}); + async (context, req, res) => { + logger.warn('Importing saved objects from a .json file has been deprecated'); + return res.ok({ body: { success: true } }); + } + ); +}; diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts new file mode 100644 index 0000000000000..efa7add7951b0 --- /dev/null +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { extname } from 'path'; +import { Readable } from 'stream'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { resolveImportErrors } from '../import'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { createSavedObjectsStreamFromNdJson } from './utils'; + +interface FileStream extends Readable { + hapi: { + filename: string; + }; +} + +export const registerResolveImportErrorsRoute = ( + router: IRouter, + config: SavedObjectConfig, + supportedTypes: string[] +) => { + const { maxImportExportSize, maxImportPayloadBytes } = config; + + router.post( + { + path: '/_resolve_import_errors', + options: { + body: { + maxBytes: maxImportPayloadBytes, + output: 'stream', + accepts: 'multipart/form-data', + }, + }, + validate: { + body: schema.object({ + file: schema.stream(), + retries: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + overwrite: schema.boolean({ defaultValue: false }), + replaceReferences: schema.arrayOf( + schema.object({ + type: schema.string(), + from: schema.string(), + to: schema.string(), + }), + { defaultValue: [] } + ), + }) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const file = req.body.file as FileStream; + const fileExtension = extname(file.hapi.filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); + } + const result = await resolveImportErrors({ + supportedTypes, + savedObjectsClient: context.core.savedObjects.client, + readStream: createSavedObjectsStreamFromNdJson(file), + retries: req.body.retries, + objectLimit: maxImportExportSize, + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/update.ts b/src/core/server/saved_objects/routes/update.ts new file mode 100644 index 0000000000000..c0d94d362e648 --- /dev/null +++ b/src/core/server/saved_objects/routes/update.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerUpdateRoute = (router: IRouter) => { + router.put( + { + path: '/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + body: schema.object({ + attributes: schema.recordOf(schema.string(), schema.any()), + version: schema.maybe(schema.string()), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const { attributes, version, references } = req.body; + const options = { version, references }; + + const result = await context.core.savedObjects.client.update(type, id, attributes, options); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.test.ts b/src/core/server/saved_objects/routes/utils.test.ts similarity index 96% rename from src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.test.ts rename to src/core/server/saved_objects/routes/utils.test.ts index 342063fefaec6..83dceda2e1398 100644 --- a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -17,9 +17,9 @@ * under the License. */ -import { createSavedObjectsStreamFromNdJson } from './create_saved_objects_stream_from_ndjson'; +import { createSavedObjectsStreamFromNdJson } from './utils'; import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from '../../../utils/streams'; +import { createPromiseFromStreams, createConcatStream } from '../../../../legacy/utils/streams'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); diff --git a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts b/src/core/server/saved_objects/routes/utils.ts similarity index 92% rename from src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts rename to src/core/server/saved_objects/routes/utils.ts index b96514054db56..5536391341da3 100644 --- a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -16,9 +16,14 @@ * specific language governing permissions and limitations * under the License. */ + import { Readable } from 'stream'; import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; -import { createSplitStream, createMapStream, createFilterStream } from '../../../utils/streams'; +import { + createSplitStream, + createMapStream, + createFilterStream, +} from '../../../../legacy/utils/streams'; export function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { return ndJsonStream diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index 7217cde55d061..cac04003f29b2 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -19,9 +19,9 @@ import { schema, TypeOf } from '@kbn/config-schema'; -export type SavedObjectsConfigType = TypeOf; +export type SavedObjectsMigrationConfigType = TypeOf; -export const config = { +export const savedObjectsMigrationConfig = { path: 'migrations', schema: schema.object({ batchSize: schema.number({ defaultValue: 100 }), @@ -30,3 +30,29 @@ export const config = { skip: schema.boolean({ defaultValue: false }), }), }; + +export type SavedObjectsConfigType = TypeOf; + +export const savedObjectsConfig = { + path: 'savedObjects', + schema: schema.object({ + maxImportPayloadBytes: schema.byteSize({ defaultValue: 10485760 }), + maxImportExportSize: schema.byteSize({ defaultValue: 10000 }), + }), +}; + +export class SavedObjectConfig { + public maxImportPayloadBytes: number; + public maxImportExportSize: number; + + public migration: SavedObjectsMigrationConfigType; + + constructor( + rawConfig: SavedObjectsConfigType, + rawMigrationConfig: SavedObjectsMigrationConfigType + ) { + this.maxImportPayloadBytes = rawConfig.maxImportPayloadBytes.getValueInBytes(); + this.maxImportExportSize = rawConfig.maxImportExportSize.getValueInBytes(); + this.migration = rawMigrationConfig; + } +} diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 07e7d8db8b25c..0c7bedecf39f5 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -24,6 +24,7 @@ import { typeRegistryInstanceMock, } from './saved_objects_service.test.mocks'; +import { ByteSizeValue } from '@kbn/config-schema'; import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; import * as legacyElasticsearch from 'elasticsearch'; @@ -31,14 +32,33 @@ import { Env } from '../config'; import { configServiceMock } from '../mocks'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; import { legacyServiceMock } from '../legacy/legacy_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; import { SavedObjectsClientFactoryProvider } from './service/lib'; import { BehaviorSubject } from 'rxjs'; import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure_es_version'; describe('SavedObjectsService', () => { + const createCoreContext = ({ + skipMigration = true, + env, + }: { skipMigration?: boolean; env?: Env } = {}) => { + const configService = configServiceMock.create({ atPath: { skip: true } }); + configService.atPath.mockImplementation(path => { + if (path === 'migrations') { + return new BehaviorSubject({ skip: skipMigration }); + } + return new BehaviorSubject({ + maxImportPayloadBytes: new ByteSizeValue(0), + maxImportExportSize: new ByteSizeValue(0), + }); + }); + return mockCoreContext.create({ configService, env }); + }; + const createSetupDeps = () => { const elasticsearchMock = elasticsearchServiceMock.createInternalSetup(); return { + http: httpServiceMock.createSetupContract(), elasticsearch: elasticsearchMock, legacyPlugins: legacyServiceMock.createDiscoverPlugins(), }; @@ -51,7 +71,7 @@ describe('SavedObjectsService', () => { describe('#setup()', () => { describe('#setClientFactoryProvider', () => { it('registers the factory to the clientProvider', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -65,7 +85,7 @@ describe('SavedObjectsService', () => { expect(clientProviderInstanceMock.setClientFactory).toHaveBeenCalledWith(factory); }); it('throws if a factory is already registered', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -84,7 +104,7 @@ describe('SavedObjectsService', () => { describe('#addClientWrapper', () => { it('registers the wrapper to the clientProvider', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -112,7 +132,7 @@ describe('SavedObjectsService', () => { describe('registerType', () => { it('registers the type to the internal typeRegistry', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -132,7 +152,7 @@ describe('SavedObjectsService', () => { describe('#start()', () => { it('creates a KibanaMigrator which retries NoConnections errors from callAsInternalUser', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const coreSetup = createSetupDeps(); @@ -153,7 +173,7 @@ describe('SavedObjectsService', () => { }); it('skips KibanaMigrator migrations when --optimize=true', async () => { - const coreContext = mockCoreContext.create({ + const coreContext = createCoreContext({ env: ({ cliArgs: { optimize: true }, packageInfo: { version: 'x.x.x' } } as unknown) as Env, }); const soService = new SavedObjectsService(coreContext); @@ -164,8 +184,7 @@ describe('SavedObjectsService', () => { }); it('skips KibanaMigrator migrations when migrations.skip=true', async () => { - const configService = configServiceMock.create({ atPath: { skip: true } }); - const coreContext = mockCoreContext.create({ configService }); + const coreContext = createCoreContext({ skipMigration: true }); const soService = new SavedObjectsService(coreContext); await soService.setup(createSetupDeps()); await soService.start({}); @@ -174,8 +193,7 @@ describe('SavedObjectsService', () => { it('waits for all es nodes to be compatible before running migrations', async done => { expect.assertions(2); - const configService = configServiceMock.create({ atPath: { skip: false } }); - const coreContext = mockCoreContext.create({ configService }); + const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); const setupDeps = createSetupDeps(); // Create an new subject so that we can control when isCompatible=true @@ -204,8 +222,7 @@ describe('SavedObjectsService', () => { }); it('resolves with KibanaMigrator after waiting for migrations to complete', async () => { - const configService = configServiceMock.create({ atPath: { skip: false } }); - const coreContext = mockCoreContext.create({ configService }); + const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); await soService.setup(createSetupDeps()); expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(0); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 2f07dbe51a09e..ece00539536e1 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -31,9 +31,13 @@ import { LegacyServiceDiscoverPlugins } from '../legacy'; import { InternalElasticsearchServiceSetup, APICaller } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; import { migrationsRetryCallCluster } from '../elasticsearch/retry_call_cluster'; -import { SavedObjectsConfigType } from './saved_objects_config'; -import { KibanaRequest } from '../http'; -import { SavedObjectsClientContract, SavedObjectsType } from './types'; +import { + SavedObjectsConfigType, + SavedObjectsMigrationConfigType, + SavedObjectConfig, +} from './saved_objects_config'; +import { InternalHttpServiceSetup, KibanaRequest } from '../http'; +import { SavedObjectsClientContract, SavedObjectsType, SavedObjectsLegacyUiExports } from './types'; import { ISavedObjectsRepository, SavedObjectsRepository } from './service/lib/repository'; import { SavedObjectsClientFactoryProvider, @@ -43,6 +47,7 @@ import { Logger } from '../logging'; import { convertLegacyTypes } from './utils'; import { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; import { PropertyValidators } from './validation'; +import { registerRoutes } from './routes'; import { SavedObjectsSerializer } from './serialization'; /** @@ -198,6 +203,7 @@ export interface SavedObjectsRepositoryFactory { export interface SavedObjectsSetupDeps { legacyPlugins: LegacyServiceDiscoverPlugins; elasticsearch: InternalElasticsearchServiceSetup; + http: InternalHttpServiceSetup; } interface WrappedClientFactoryWrapper { @@ -215,6 +221,7 @@ export class SavedObjectsService private logger: Logger; private setupDeps?: SavedObjectsSetupDeps; + private config?: SavedObjectConfig; private clientFactoryProvider?: SavedObjectsClientFactoryProvider; private clientFactoryWrappers: WrappedClientFactoryWrapper[] = []; @@ -237,6 +244,27 @@ export class SavedObjectsService legacyTypes.forEach(type => this.typeRegistry.registerType(type)); this.validations = setupDeps.legacyPlugins.uiExports.savedObjectValidations || {}; + const importableExportableTypes = getImportableAndExportableTypes( + setupDeps.legacyPlugins.uiExports + ); + + const savedObjectsConfig = await this.coreContext.configService + .atPath('savedObjects') + .pipe(first()) + .toPromise(); + const savedObjectsMigrationConfig = await this.coreContext.configService + .atPath('migrations') + .pipe(first()) + .toPromise(); + this.config = new SavedObjectConfig(savedObjectsConfig, savedObjectsMigrationConfig); + + registerRoutes({ + http: setupDeps.http, + logger: this.logger, + config: this.config, + importableExportableTypes, + }); + return { setClientFactoryProvider: provider => { if (this.clientFactoryProvider) { @@ -261,7 +289,7 @@ export class SavedObjectsService core: SavedObjectsStartDeps, migrationsRetryDelay?: number ): Promise { - if (!this.setupDeps) { + if (!this.setupDeps || !this.config) { throw new Error('#setup() needs to be run first'); } @@ -271,12 +299,8 @@ export class SavedObjectsService .atPath('kibana') .pipe(first()) .toPromise(); - const savedObjectsConfig = await this.coreContext.configService - .atPath('migrations') - .pipe(first()) - .toPromise(); const adminClient = this.setupDeps!.elasticsearch.adminClient; - const migrator = this.createMigrator(kibanaConfig, savedObjectsConfig, migrationsRetryDelay); + const migrator = this.createMigrator(kibanaConfig, this.config.migration, migrationsRetryDelay); /** * Note: We want to ensure that migrations have completed before @@ -289,7 +313,7 @@ export class SavedObjectsService * So, when the `migrations.skip` is true, we skip migrations altogether. */ const cliArgs = this.coreContext.env.cliArgs; - const skipMigrations = cliArgs.optimize || savedObjectsConfig.skip; + const skipMigrations = cliArgs.optimize || this.config.migration.skip; if (skipMigrations) { this.logger.warn( @@ -354,7 +378,7 @@ export class SavedObjectsService private createMigrator( kibanaConfig: KibanaConfigType, - savedObjectsConfig: SavedObjectsConfigType, + savedObjectsConfig: SavedObjectsMigrationConfigType, migrationsRetryDelay?: number ): KibanaMigrator { const adminClient = this.setupDeps!.elasticsearch.adminClient; @@ -374,3 +398,16 @@ export class SavedObjectsService }); } } + +function getImportableAndExportableTypes({ + savedObjectMappings = [], + savedObjectsManagement = {}, +}: SavedObjectsLegacyUiExports) { + const visibleTypes = savedObjectMappings.reduce( + (types, mapping) => [...types, ...Object.keys(mapping.properties)], + [] as string[] + ); + return visibleTypes.filter( + type => savedObjectsManagement[type]?.isImportableAndExportable === true ?? false + ); +} diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 980ba005e0eeb..a4fde1765b7d3 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -21,6 +21,7 @@ import { SavedObjectsClient } from './service/saved_objects_client'; import { SavedObjectsTypeMappingDefinition, SavedObjectsTypeMappingDefinitions } from './mappings'; import { SavedObjectMigrationMap } from './migrations'; import { PropertyValidators } from './validation'; +import { SavedObjectsManagementDefinition } from './management'; export { SavedObjectsImportResponse, @@ -256,6 +257,7 @@ export interface SavedObjectsLegacyUiExports { savedObjectMigrations: SavedObjectsLegacyMigrationDefinitions; savedObjectSchemas: SavedObjectsLegacySchemaDefinitions; savedObjectValidations: PropertyValidators; + savedObjectsManagement: SavedObjectsManagementDefinition; } /** diff --git a/src/core/server/saved_objects/utils.test.ts b/src/core/server/saved_objects/utils.test.ts index d1c15517e94a6..1e2b9f6a0f694 100644 --- a/src/core/server/saved_objects/utils.test.ts +++ b/src/core/server/saved_objects/utils.test.ts @@ -61,6 +61,7 @@ describe('convertLegacyTypes', () => { savedObjectMigrations: {}, savedObjectSchemas: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -100,6 +101,7 @@ describe('convertLegacyTypes', () => { }, }, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -134,6 +136,7 @@ describe('convertLegacyTypes', () => { }, }, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -182,6 +185,7 @@ describe('convertLegacyTypes', () => { }, savedObjectSchemas: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -244,6 +248,7 @@ describe('convertLegacyTypes', () => { }, }, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 96adb3bbcd210..db2493b38d6e0 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -42,7 +42,7 @@ import { config as loggingConfig } from './logging'; import { config as devConfig } from './dev'; import { config as pathConfig } from './path'; import { config as kibanaConfig } from './kibana_config'; -import { config as savedObjectsConfig } from './saved_objects'; +import { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects'; import { config as uiSettingsConfig } from './ui_settings'; import { mapToObject } from '../utils'; import { ContextService } from './context'; @@ -132,6 +132,7 @@ export class Server { }); const savedObjectsSetup = await this.savedObjects.setup({ + http: httpSetup, elasticsearch: elasticsearchServiceSetup, legacyPlugins, }); @@ -257,6 +258,7 @@ export class Server { [devConfig.path, devConfig.schema], [kibanaConfig.path, kibanaConfig.schema], [savedObjectsConfig.path, savedObjectsConfig.schema], + [savedObjectsMigrationConfig.path, savedObjectsMigrationConfig.schema], [uiSettingsConfig.path, uiSettingsConfig.schema], ]; diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index ea81193c1dd0a..8e6bae0b588bc 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -77,6 +77,7 @@ export default function(kibana) { order: -1003, url: `${kbnBaseUrl}#/discover`, euiIconType: 'discoverApp', + disableSubUrlTracking: true, category: DEFAULT_APP_CATEGORIES.analyze, }, { @@ -87,6 +88,7 @@ export default function(kibana) { order: -1002, url: `${kbnBaseUrl}#/visualize`, euiIconType: 'visualizeApp', + disableSubUrlTracking: true, category: DEFAULT_APP_CATEGORIES.analyze, }, { diff --git a/src/legacy/core_plugins/kibana/migrations/types.ts b/src/legacy/core_plugins/kibana/migrations/types.ts index 144151ed80d43..839f753670b20 100644 --- a/src/legacy/core_plugins/kibana/migrations/types.ts +++ b/src/legacy/core_plugins/kibana/migrations/types.ts @@ -17,7 +17,8 @@ * under the License. */ -import { SavedObjectReference } from '../../../../legacy/server/saved_objects/routes/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectReference } from '../../../../core/server'; export interface SavedObjectAttributes { kibanaSavedObjectMeta: { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts index fa5354a17b6d9..fe7beafcad18c 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts @@ -165,7 +165,7 @@ export class DashboardStateManager { // make sure url ('_a') matches initial state this.kbnUrlStateStorage.set(this.STATE_STORAGE_KEY, initialState, { replace: true }); - // setup state syncing utils. state container will be synched with url into `this.STATE_STORAGE_KEY` query param + // setup state syncing utils. state container will be synced with url into `this.STATE_STORAGE_KEY` query param this.stateSyncRef = syncState({ storageKey: this.STATE_STORAGE_KEY, stateContainer: { @@ -173,10 +173,20 @@ export class DashboardStateManager { set: (state: DashboardAppState | null) => { // sync state required state container to be able to handle null // overriding set() so it could handle null coming from url - this.stateContainer.set({ - ...this.stateDefaults, - ...state, - }); + if (state) { + this.stateContainer.set({ + ...this.stateDefaults, + ...state, + }); + } else { + // Do nothing in case when state from url is empty, + // this fixes: https://github.com/elastic/kibana/issues/57789 + // There are not much cases when state in url could become empty: + // 1. User manually removed `_a` from the url + // 2. Browser is navigating away from the page and most likely there is no `_a` in the url. + // In this case we don't want to do any state updates + // and just allow $scope.$on('destroy') fire later and clean up everything + } }, }, stateStorage: this.kbnUrlStateStorage, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js index a370c66ae330b..038f783a0daf1 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js @@ -19,13 +19,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { getAngularModule, getServices, subscribeWithScope } from '../../kibana_services'; - +import { getAngularModule, getServices } from '../../kibana_services'; import './context_app'; +import { getState } from './context_state'; import contextAppRouteTemplate from './context.html'; import { getRootBreadcrumbs } from '../helpers/breadcrumbs'; -import { FilterStateManager } from '../../../../../data/public'; -const { chrome } = getServices(); const k7Breadcrumbs = $route => { const { indexPattern } = $route.current.locals; @@ -68,53 +66,50 @@ getAngularModule().config($routeProvider => { }); }); -function ContextAppRouteController( - $routeParams, - $scope, - AppState, - config, - $route, - getAppState, - globalState -) { +function ContextAppRouteController($routeParams, $scope, config, $route) { const filterManager = getServices().filterManager; - const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); const indexPattern = $route.current.locals.indexPattern.ip; + const { + startSync: startStateSync, + stopSync: stopStateSync, + appState, + getFilters, + setFilters, + setAppState, + } = getState({ + defaultStepSize: config.get('context:defaultSize'), + timeFieldName: indexPattern.timeFieldName, + storeInSessionStorage: config.get('state:storeInSessionStorage'), + }); + this.state = { ...appState.getState() }; + this.anchorId = $routeParams.id; + this.indexPattern = indexPattern; + this.discoverUrl = getServices().chrome.navLinks.get('kibana:discover').url; + filterManager.setFilters(_.cloneDeep(getFilters())); + startStateSync(); - this.state = new AppState(createDefaultAppState(config, indexPattern)); - this.state.save(true); - + // take care of parameter changes in UI $scope.$watchGroup( [ 'contextAppRoute.state.columns', 'contextAppRoute.state.predecessorCount', 'contextAppRoute.state.successorCount', ], - () => this.state.save(true) + newValues => { + const [columns, predecessorCount, successorCount] = newValues; + if (Array.isArray(columns) && predecessorCount >= 0 && successorCount >= 0) { + setAppState({ columns, predecessorCount, successorCount }); + } + } ); - - const updateSubsciption = subscribeWithScope($scope, filterManager.getUpdates$(), { - next: () => { - this.filters = _.cloneDeep(filterManager.getFilters()); - }, + // take care of parameter filter changes + const filterObservable = filterManager.getUpdates$().subscribe(() => { + setFilters(filterManager); + $route.reload(); }); $scope.$on('$destroy', () => { - filterStateManager.destroy(); - updateSubsciption.unsubscribe(); + stopStateSync(); + filterObservable.unsubscribe(); }); - this.anchorId = $routeParams.id; - this.indexPattern = indexPattern; - this.discoverUrl = chrome.navLinks.get('kibana:discover').url; - this.filters = _.cloneDeep(filterManager.getFilters()); -} - -function createDefaultAppState(config, indexPattern) { - return { - columns: ['_source'], - filters: [], - predecessorCount: parseInt(config.get('context:defaultSize'), 10), - sort: [indexPattern.timeFieldName, 'desc'], - successorCount: parseInt(config.get('context:defaultSize'), 10), - }; } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts index a9c6918adbfde..b91ef5a6b79fb 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts @@ -67,7 +67,7 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract) { size: number, filters: Filter[] ) { - if (typeof anchor !== 'object' || anchor === null) { + if (typeof anchor !== 'object' || anchor === null || !size) { return []; } const indexPattern = await indexPatterns.get(indexPatternId); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js index 966ecffda7755..1cebb88cbda5a 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js @@ -88,9 +88,11 @@ export function QueryActionsProvider(Promise) { const fetchSurroundingRows = (type, state) => { const { - queryParameters: { indexPatternId, filters, sort, tieBreakerField }, + queryParameters: { indexPatternId, sort, tieBreakerField }, rows: { anchor }, } = state; + const filters = getServices().filterManager.getFilters(); + const count = type === 'successors' ? state.queryParameters.successorCount diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts new file mode 100644 index 0000000000000..1fa71ed11643a --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts @@ -0,0 +1,193 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getState } from './context_state'; +import { createBrowserHistory, History } from 'history'; +import { FilterManager, Filter } from '../../../../../../../plugins/data/public'; +import { coreMock } from '../../../../../../../core/public/mocks'; +const setupMock = coreMock.createSetup(); + +describe('Test Discover Context State', () => { + let history: History; + let state: any; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(async () => { + history = createBrowserHistory(); + history.push('/'); + state = await getState({ + defaultStepSize: '4', + timeFieldName: 'time', + history, + }); + state.startSync(); + }); + afterEach(() => { + state.stopSync(); + }); + test('getState function default return', () => { + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "filters": Array [], + "predecessorCount": 4, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 4, + } + `); + expect(state.globalState.getState()).toMatchInlineSnapshot(`null`); + expect(state.startSync).toBeDefined(); + expect(state.stopSync).toBeDefined(); + expect(state.getFilters()).toStrictEqual([]); + }); + test('getState -> setAppState syncing to url', async () => { + state.setAppState({ predecessorCount: 10 }); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(time,desc),successorCount:4)"` + ); + }); + test('getState -> url to appState syncing', async () => { + history.push( + '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' + ); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + }); + test('getState -> url to appState syncing with return to a url without state', async () => { + history.push( + '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' + ); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + history.push('/'); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + }); + + test('getState -> filters', async () => { + const filterManager = new FilterManager(setupMock.uiSettings); + const filterGlobal = { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + } as Filter; + filterManager.setGlobalFilters([filterGlobal]); + const filterApp = { + query: { match: { extension: { query: 'png', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: true, disabled: false, alias: null }, + } as Filter; + filterManager.setAppFilters([filterApp]); + state.setFilters(filterManager); + expect(state.getFilters()).toMatchInlineSnapshot(` + Array [ + Object { + "$state": Object { + "store": "globalState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": "logstash-*", + "key": "extension", + "negate": false, + "params": Object { + "query": "jpg", + }, + "type": "phrase", + "value": [Function], + }, + "query": Object { + "match": Object { + "extension": Object { + "query": "jpg", + "type": "phrase", + }, + }, + }, + }, + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": "logstash-*", + "key": "extension", + "negate": true, + "params": Object { + "query": "png", + }, + "type": "phrase", + "value": [Function], + }, + "query": Object { + "match": Object { + "extension": Object { + "query": "png", + "type": "phrase", + }, + }, + }, + }, + ] + `); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match:(extension:(query:jpg,type:phrase))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match:(extension:(query:png,type:phrase))))),predecessorCount:4,sort:!(time,desc),successorCount:4)"` + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts new file mode 100644 index 0000000000000..8fb6140d55e31 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts @@ -0,0 +1,275 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import _ from 'lodash'; +import { createBrowserHistory, History } from 'history'; +import { + createStateContainer, + createKbnUrlStateStorage, + syncStates, + BaseStateContainer, +} from '../../../../../../../plugins/kibana_utils/public'; +import { esFilters, FilterManager, Filter } from '../../../../../../../plugins/data/public'; + +interface AppState { + /** + * Columns displayed in the table, cannot be changed by UI, just in discover's main app + */ + columns: string[]; + /** + * Array of filters + */ + filters: Filter[]; + /** + * Number of records to be fetched before anchor records (newer records) + */ + predecessorCount: number; + /** + * Sorting of the records to be fetched, assumed to be a legacy parameter + */ + sort: string[]; + /** + * Number of records to be fetched after the anchor records (older records) + */ + successorCount: number; +} + +interface GlobalState { + /** + * Array of filters + */ + filters: Filter[]; +} + +interface GetStateParams { + /** + * Number of records to be fetched when 'Load' link/button is clicked + */ + defaultStepSize: string; + /** + * The timefield used for sorting + */ + timeFieldName: string; + /** + * Determins the use of long vs. short/hashed urls + */ + storeInSessionStorage?: boolean; + /** + * Browser history used for testing + */ + history?: History; +} + +interface GetStateReturn { + /** + * Global state, the _g part of the URL + */ + globalState: BaseStateContainer; + /** + * App state, the _a part of the URL + */ + appState: BaseStateContainer; + /** + * Start sync between state and URL + */ + startSync: () => void; + /** + * Stop sync between state and URL + */ + stopSync: () => void; + /** + * Set app state to with a partial new app state + */ + setAppState: (newState: Partial) => void; + /** + * Get all filters, global and app state + */ + getFilters: () => Filter[]; + /** + * Set global state and app state filters by the given FilterManager instance + * @param filterManager + */ + setFilters: (filterManager: FilterManager) => void; + /** + * sync state to URL, used for testing + */ + flushToUrl: () => void; +} +const GLOBAL_STATE_URL_KEY = '_g'; +const APP_STATE_URL_KEY = '_a'; + +/** + * Builds and returns appState and globalState containers + * provides helper functions to start/stop syncing with URL + */ +export function getState({ + defaultStepSize, + timeFieldName, + storeInSessionStorage = false, + history, +}: GetStateParams): GetStateReturn { + const stateStorage = createKbnUrlStateStorage({ + useHash: storeInSessionStorage, + history: history ? history : createBrowserHistory(), + }); + + const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState; + const globalStateContainer = createStateContainer(globalStateInitial); + + const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; + const appStateInitial = createInitialAppState(defaultStepSize, timeFieldName, appStateFromUrl); + const appStateContainer = createStateContainer(appStateInitial); + + const { start, stop } = syncStates([ + { + storageKey: GLOBAL_STATE_URL_KEY, + stateContainer: { + ...globalStateContainer, + ...{ + set: (value: GlobalState | null) => { + if (value) { + globalStateContainer.set(value); + } + }, + }, + }, + stateStorage, + }, + { + storageKey: APP_STATE_URL_KEY, + stateContainer: { + ...appStateContainer, + ...{ + set: (value: AppState | null) => { + if (value) { + appStateContainer.set(value); + } + }, + }, + }, + stateStorage, + }, + ]); + + return { + globalState: globalStateContainer, + appState: appStateContainer, + startSync: start, + stopSync: stop, + setAppState: (newState: Partial) => { + const oldState = appStateContainer.getState(); + const mergedState = { ...oldState, ...newState }; + + if (!isEqualState(oldState, mergedState)) { + appStateContainer.set(mergedState); + } + }, + getFilters: () => [ + ...getFilters(globalStateContainer.getState()), + ...getFilters(appStateContainer.getState()), + ], + setFilters: (filterManager: FilterManager) => { + // global state filters + const globalFilters = filterManager.getGlobalFilters(); + const globalFilterChanged = !isEqualFilters( + globalFilters, + getFilters(globalStateContainer.getState()) + ); + if (globalFilterChanged) { + globalStateContainer.set({ filters: globalFilters }); + } + // app state filters + const appFilters = filterManager.getAppFilters(); + const appFilterChanged = !isEqualFilters( + appFilters, + getFilters(appStateContainer.getState()) + ); + if (appFilterChanged) { + appStateContainer.set({ ...appStateContainer.getState(), ...{ filters: appFilters } }); + } + }, + // helper function just needed for testing + flushToUrl: () => stateStorage.flush(), + }; +} + +/** + * Helper function to compare 2 different filter states + */ +export function isEqualFilters(filtersA: Filter[], filtersB: Filter[]) { + if (!filtersA && !filtersB) { + return true; + } else if (!filtersA || !filtersB) { + return false; + } + return esFilters.compareFilters(filtersA, filtersB, esFilters.COMPARE_ALL_OPTIONS); +} + +/** + * Helper function to compare 2 different states, is needed since comparing filters + * works differently, doesn't work with _.isEqual + */ +function isEqualState(stateA: AppState | GlobalState, stateB: AppState | GlobalState) { + if (!stateA && !stateB) { + return true; + } else if (!stateA || !stateB) { + return false; + } + const { filters: stateAFilters = [], ...stateAPartial } = stateA; + const { filters: stateBFilters = [], ...stateBPartial } = stateB; + return ( + _.isEqual(stateAPartial, stateBPartial) && + esFilters.compareFilters(stateAFilters, stateBFilters, esFilters.COMPARE_ALL_OPTIONS) + ); +} + +/** + * Helper function to return array of filter object of a given state + */ +function getFilters(state: AppState | GlobalState): Filter[] { + if (!state || !Array.isArray(state.filters)) { + return []; + } + return state.filters; +} + +/** + * Helper function to return the initial app state, which is a merged object of url state and + * default state. The default size is the default number of successor/predecessor records to fetch + */ +function createInitialAppState( + defaultSize: string, + timeFieldName: string, + urlState: AppState +): AppState { + const defaultState = { + columns: ['_source'], + filters: [], + predecessorCount: parseInt(defaultSize, 10), + sort: [timeFieldName, 'desc'], + successorCount: parseInt(defaultSize, 10), + }; + if (typeof urlState !== 'object') { + return defaultState; + } + + return { + ...defaultState, + ...urlState, + }; +} diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts index 565382313e369..e8ded9d99f892 100644 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts @@ -16,11 +16,17 @@ * specific language governing permissions and limitations * under the License. */ + +import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import angular, { auto } from 'angular'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { + DataPublicPluginStart, + DataPublicPluginSetup, + getQueryStateContainer, +} from '../../../../../plugins/data/public'; import { registerFeature } from './np_ready/register_feature'; import './kibana_services'; import { IEmbeddableStart, IEmbeddableSetup } from '../../../../../plugins/embeddable/public'; @@ -30,7 +36,10 @@ import { NavigationPublicPluginStart as NavigationStart } from '../../../../../p import { ChartsPluginStart } from '../../../../../plugins/charts/public'; import { buildServices } from './build_services'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public'; +import { + KibanaLegacySetup, + AngularRenderedAppUpdater, +} from '../../../../../plugins/kibana_legacy/public'; import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; import { DocViewInput, DocViewInputFn } from './np_ready/doc_views/doc_views_types'; import { DocViewTable } from './np_ready/components/table/table'; @@ -40,6 +49,7 @@ import { VisualizationsStart, VisualizationsSetup, } from '../../../visualizations/public/np_ready/public'; +import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; /** * These are the interfaces with your public contracts. You should export these @@ -56,6 +66,7 @@ export interface DiscoverSetupPlugins { kibanaLegacy: KibanaLegacySetup; home: HomePublicPluginSetup; visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; } export interface DiscoverStartPlugins { uiActions: UiActionsStart; @@ -81,6 +92,9 @@ export class DiscoverPlugin implements Plugin { private docViewsRegistry: DocViewsRegistry | null = null; private embeddableInjector: auto.IInjectorService | null = null; private getEmbeddableInjector: (() => Promise) | null = null; + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking: (() => void) | undefined = undefined; + /** * why are those functions public? they are needed for some mocha tests * can be removed once all is Jest @@ -89,6 +103,27 @@ export class DiscoverPlugin implements Plugin { public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup { + const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer( + plugins.data.query + ); + const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/kibana'), + defaultSubUrl: '#/discover', + storageKey: 'lastUrl:discover', + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: querySyncStateContainer.state$, + }, + ], + }); + this.stopUrlTracking = () => { + stopQuerySyncStateContainer(); + stopUrlTracker(); + }; + this.getEmbeddableInjector = this.getInjector.bind(this); this.docViewsRegistry = new DocViewsRegistry(this.getEmbeddableInjector); this.docViewsRegistry.addDocView({ @@ -108,6 +143,8 @@ export class DiscoverPlugin implements Plugin { plugins.kibanaLegacy.registerLegacyApp({ id: 'discover', title: 'Discover', + updater$: this.appStateUpdater.asObservable(), + navLinkId: 'kibana:discover', order: -1004, euiIconType: 'discoverApp', mount: async (params: AppMountParameters) => { @@ -117,11 +154,16 @@ export class DiscoverPlugin implements Plugin { if (!this.initializeInnerAngular) { throw Error('Discover plugin method initializeInnerAngular is undefined'); } + appMounted(); await this.initializeServices(); await this.initializeInnerAngular(); const { renderApp } = await import('./np_ready/application'); - return renderApp(innerAngularName, params.element); + const unmount = await renderApp(innerAngularName, params.element); + return () => { + unmount(); + appUnMounted(); + }; }, }); registerFeature(plugins.home); @@ -160,6 +202,12 @@ export class DiscoverPlugin implements Plugin { this.registerEmbeddable(core, plugins); } + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } + /** * register embeddable with a slimmer embeddable version of inner angular */ diff --git a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts index 6cb1531be6b5b..4b09997fc6244 100644 --- a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts @@ -31,7 +31,7 @@ import { TelemetryPluginStart } from '../../../../../plugins/telemetry/public'; import { Environment, HomePublicPluginSetup, - FeatureCatalogueEntry, + HomePublicPluginStart, } from '../../../../../plugins/home/public'; import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public'; @@ -43,7 +43,7 @@ export interface HomeKibanaServices { uiSettings: IUiSettingsClient; config: KibanaLegacySetup['config']; homeConfig: HomePublicPluginSetup['config']; - directories: readonly FeatureCatalogueEntry[]; + featureCatalogue: HomePublicPluginStart['featureCatalogue']; http: HttpStart; savedObjectsClient: SavedObjectsClientContract; toastNotifications: NotificationsSetup['toasts']; diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx b/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx index 2149885f3ee11..578d1f0a766a5 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx @@ -26,7 +26,11 @@ import { getServices } from '../kibana_services'; export const renderApp = async (element: HTMLElement) => { const homeTitle = i18n.translate('kbn.home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); - const { directories, chrome } = getServices(); + const { featureCatalogue, chrome } = getServices(); + + // all the directories could be get in "start" phase of plugin after all of the legacy plugins will be moved to a NP + const directories = featureCatalogue.get(); + chrome.setBreadcrumbs([{ text: homeTitle }]); render(, element); diff --git a/src/legacy/core_plugins/kibana/public/home/plugin.ts b/src/legacy/core_plugins/kibana/public/home/plugin.ts index 75e7cc2e453be..1f4b5fe7cacef 100644 --- a/src/legacy/core_plugins/kibana/public/home/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/home/plugin.ts @@ -34,7 +34,6 @@ import { Environment, HomePublicPluginStart, HomePublicPluginSetup, - FeatureCatalogueEntry, } from '../../../../../plugins/home/public'; export interface HomePluginStartDependencies { @@ -53,7 +52,7 @@ export class HomePlugin implements Plugin { private dataStart: DataPublicPluginStart | null = null; private savedObjectsClient: any = null; private environment: Environment | null = null; - private directories: readonly FeatureCatalogueEntry[] | null = null; + private featureCatalogue: HomePublicPluginStart['featureCatalogue'] | null = null; private telemetry?: TelemetryPluginStart; constructor(private initializerContext: PluginInitializerContext) {} @@ -83,7 +82,7 @@ export class HomePlugin implements Plugin { environment: this.environment!, config: kibanaLegacy.config, homeConfig: home.config, - directories: this.directories!, + featureCatalogue: this.featureCatalogue!, }); const { renderApp } = await import('./np_ready/application'); return await renderApp(params.element); @@ -93,7 +92,7 @@ export class HomePlugin implements Plugin { start(core: CoreStart, { data, home, telemetry }: HomePluginStartDependencies) { this.environment = home.environment.get(); - this.directories = home.featureCatalogue.get(); + this.featureCatalogue = home.featureCatalogue; this.dataStart = data; this.telemetry = telemetry; this.savedObjectsClient = core.savedObjects.client; diff --git a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts index 72369290a52a0..5063f8e2e1cb3 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts @@ -35,7 +35,6 @@ import { DataPublicPluginStart, IndexPatternsContract } from '../../../../../plu import { VisualizationsStart } from '../../../visualizations/public'; import { SavedVisualizations } from './np_ready/types'; import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/public'; -import { Chrome } from './legacy_imports'; import { KibanaLegacyStart } from '../../../../../plugins/kibana_legacy/public'; import { DataStart } from '../../../data/public'; @@ -49,7 +48,6 @@ export interface VisualizeKibanaServices { embeddable: IEmbeddableStart; getBasePath: () => string; indexPatterns: IndexPatternsContract; - legacyChrome: Chrome; localStorage: Storage; navigation: NavigationStart; toastNotifications: ToastsStart; @@ -63,6 +61,7 @@ export interface VisualizeKibanaServices { visualizations: VisualizationsStart; usageCollection?: UsageCollectionSetup; I18nContext: I18nStart['Context']; + setActiveUrl: (newUrl: string) => void; } let services: VisualizeKibanaServices | null = null; diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy.ts index 75426e4ef89b1..b9525e2b2a0c6 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy.ts @@ -18,7 +18,7 @@ */ import { PluginInitializerContext } from 'kibana/public'; -import { legacyChrome, npSetup, npStart } from './legacy_imports'; +import { npSetup, npStart } from 'ui/new_platform'; import { start as visualizations } from '../../../visualizations/public/np_ready/public/legacy'; import { plugin } from './index'; import { start as dataShim } from '../../../data/public/legacy'; @@ -26,12 +26,7 @@ import { start as dataShim } from '../../../data/public/legacy'; const instance = plugin({ env: npSetup.plugins.kibanaLegacy.env, } as PluginInitializerContext); -instance.setup(npSetup.core, { - ...npSetup.plugins, - __LEGACY: { - legacyChrome, - }, -}); +instance.setup(npSetup.core, npSetup.plugins); instance.start(npStart.core, { ...npStart.plugins, dataShim, diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts index ac9fc227406ff..92433799ba420 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts @@ -24,11 +24,6 @@ * directly where they are needed. */ -import chrome from 'ui/chrome'; - -export const legacyChrome = chrome; -export { Chrome } from 'ui/chrome'; - // @ts-ignore export { AppState, AppStateProvider } from 'ui/state_management/app_state'; export { State } from 'ui/state_management/state'; @@ -39,8 +34,6 @@ export { StateManagementConfigProvider } from 'ui/state_management/config_provid export { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; export { PersistedState } from 'ui/persisted_state'; -export { npSetup, npStart } from 'ui/new_platform'; - export { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; // @ts-ignore export { EventsProvider } from 'ui/events'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts index 3d5fd6605f56b..bd7b478f827a6 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts @@ -45,7 +45,7 @@ import { VisualizeKibanaServices } from '../kibana_services'; let angularModuleInstance: IModule | null = null; -export const renderApp = async ( +export const renderApp = ( element: HTMLElement, appBasePath: string, deps: VisualizeKibanaServices @@ -58,7 +58,6 @@ export const renderApp = async ( { core: deps.core, env: deps.pluginInitializerContext.env }, true ); - // custom routing stuff initVisualizeApp(angularModuleInstance, deps); } const $injector = mountVisualizeApp(appBasePath, element); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 27fb9b63843c4..409d4b41fbe69 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -90,13 +90,13 @@ function VisualizeAppController( }, }, toastNotifications, - legacyChrome, chrome, getBasePath, core: { docLinks }, savedQueryService, uiSettings, I18nContext, + setActiveUrl, } = getServices(); const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); @@ -441,14 +441,6 @@ function VisualizeAppController( }) ); - subscriptions.add( - subscribeWithScope($scope, timefilter.getAutoRefreshFetch$(), { - next: () => { - $scope.vis.forceReload(); - }, - }) - ); - $scope.$on('$destroy', () => { if ($scope._handler) { $scope._handler.destroy(); @@ -588,10 +580,7 @@ function VisualizeAppController( }); // Manually insert a new url so the back button will open the saved visualization. $window.history.pushState({}, '', savedVisualizationParsedUrl.getRootRelativePath()); - // Since we aren't reloading the page, only inserting a new browser history item, we need to manually update - // the last url for this app, so directly clicking on the Visualize tab will also bring the user to the saved - // url, not the unsaved one. - legacyChrome.trackSubUrlForApp('kibana:visualize', savedVisualizationParsedUrl); + setActiveUrl(savedVisualizationParsedUrl.appPath); const lastDashboardAbsoluteUrl = chrome.navLinks.get('kibana:dashboard').url; const dashboardParsedUrl = absoluteToParsedUrl( diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js index cae1e40cd445a..c0cc499b598f0 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js @@ -36,7 +36,6 @@ export function VisualizeListingController($injector, $scope, createNewVis) { const { addBasePath, chrome, - legacyChrome, savedObjectsClient, savedVisualizations, data: { @@ -100,17 +99,13 @@ export function VisualizeListingController($injector, $scope, createNewVis) { selectedItems.map(item => { return savedObjectsClient.delete(item.savedObjectType, item.id); }) - ) - .then(() => { - legacyChrome.untrackNavLinksForDeletedSavedObjects(selectedItems.map(item => item.id)); - }) - .catch(error => { - toastNotifications.addError(error, { - title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); + ).catch(error => { + toastNotifications.addError(error, { + title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), }); + }); }; chrome.setBreadcrumbs([ diff --git a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts index 612c9ae17ea34..7c99ed0bbe680 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts @@ -17,6 +17,7 @@ * under the License. */ +import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { @@ -28,12 +29,19 @@ import { SavedObjectsClientContract, } from 'kibana/public'; -import { Storage } from '../../../../../plugins/kibana_utils/public'; -import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { Storage, createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; +import { + DataPublicPluginStart, + DataPublicPluginSetup, + getQueryStateContainer, +} from '../../../../../plugins/data/public'; import { IEmbeddableStart } from '../../../../../plugins/embeddable/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public'; +import { + KibanaLegacySetup, + AngularRenderedAppUpdater, +} from '../../../../../plugins/kibana_legacy/public'; import { VisualizationsStart } from '../../../visualizations/public'; import { VisualizeConstants } from './np_ready/visualize_constants'; import { setServices, VisualizeKibanaServices } from './kibana_services'; @@ -42,7 +50,6 @@ import { HomePublicPluginSetup, } from '../../../../../plugins/home/public'; import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/public'; -import { Chrome } from './legacy_imports'; import { DataStart } from '../../../data/public'; export interface VisualizePluginStartDependencies { @@ -55,12 +62,10 @@ export interface VisualizePluginStartDependencies { } export interface VisualizePluginSetupDependencies { - __LEGACY: { - legacyChrome: Chrome; - }; home: HomePublicPluginSetup; kibanaLegacy: KibanaLegacySetup; usageCollection?: UsageCollectionSetup; + data: DataPublicPluginSetup; } export class VisualizePlugin implements Plugin { @@ -73,48 +78,74 @@ export class VisualizePlugin implements Plugin { share: SharePluginStart; visualizations: VisualizationsStart; } | null = null; + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking: (() => void) | undefined = undefined; constructor(private initializerContext: PluginInitializerContext) {} public async setup( core: CoreSetup, - { home, kibanaLegacy, __LEGACY, usageCollection }: VisualizePluginSetupDependencies + { home, kibanaLegacy, usageCollection, data }: VisualizePluginSetupDependencies ) { + const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer( + data.query + ); + const { appMounted, appUnMounted, stop: stopUrlTracker, setActiveUrl } = createKbnUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/kibana'), + defaultSubUrl: '#/visualize', + storageKey: 'lastUrl:visualize', + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: querySyncStateContainer.state$, + }, + ], + }); + this.stopUrlTracking = () => { + stopQuerySyncStateContainer(); + stopUrlTracker(); + }; + kibanaLegacy.registerLegacyApp({ id: 'visualize', title: 'Visualize', + updater$: this.appStateUpdater.asObservable(), + navLinkId: 'kibana:visualize', mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); + if (this.startDependencies === null) { throw new Error('not started yet'); } + appMounted(); const { savedObjectsClient, embeddable, navigation, visualizations, - data, dataShim, + data: dataStart, share, } = this.startDependencies; const deps: VisualizeKibanaServices = { - ...__LEGACY, pluginInitializerContext: this.initializerContext, addBasePath: coreStart.http.basePath.prepend, core: coreStart, chrome: coreStart.chrome, - data, dataShim, + data: dataStart, embeddable, getBasePath: core.http.basePath.get, - indexPatterns: data.indexPatterns, + indexPatterns: dataStart.indexPatterns, localStorage: new Storage(localStorage), navigation, savedObjectsClient, savedVisualizations: visualizations.getSavedVisualizationsLoader(), - savedQueryService: data.query.savedQueries, + savedQueryService: dataStart.query.savedQueries, share, toastNotifications: coreStart.notifications.toasts, uiSettings: coreStart.uiSettings, @@ -123,11 +154,16 @@ export class VisualizePlugin implements Plugin { visualizations, usageCollection, I18nContext: coreStart.i18n.Context, + setActiveUrl, }; setServices(deps); const { renderApp } = await import('./np_ready/application'); - return renderApp(params.element, params.appBasePath, deps); + const unmount = renderApp(params.element, params.appBasePath, deps); + return () => { + unmount(); + appUnMounted(); + }; }, }); @@ -166,4 +202,10 @@ export class VisualizePlugin implements Plugin { visualizations, }; } + + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } } diff --git a/src/legacy/core_plugins/telemetry/server/collection_manager.ts b/src/legacy/core_plugins/telemetry/server/collection_manager.ts index 933c249cd7279..0394dea343adf 100644 --- a/src/legacy/core_plugins/telemetry/server/collection_manager.ts +++ b/src/legacy/core_plugins/telemetry/server/collection_manager.ts @@ -41,8 +41,8 @@ export interface StatsCollectionConfig { usageCollection: UsageCollectionSetup; callCluster: CallCluster; server: any; - start: string; - end: string; + start: string | number; + end: string | number; } export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig; @@ -193,7 +193,7 @@ export class TelemetryCollectionManager { } } catch (err) { statsCollectionConfig.server.log( - ['debu', 'telemetry', 'collection'], + ['debug', 'telemetry', 'collection'], `Failed to collect any usage with registered collections.` ); // swallow error to try next collection; diff --git a/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts index 5e593398333c9..fddcf70c30605 100644 --- a/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -34,6 +34,7 @@ import { esFilters, Filter, ISearchSource, + TimefilterContract, } from '../../../../../plugins/data/public'; import { EmbeddableInput, @@ -106,8 +107,10 @@ export class VisualizeEmbeddable extends Embeddable { this.handleChanges(); @@ -345,6 +352,7 @@ export class VisualizeEmbeddable extends Embeddable { diff --git a/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 03471174753fa..2f00467a85cda 100644 --- a/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/legacy/core_plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -38,6 +38,7 @@ import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import { getCapabilities, getHttp, getTypes, getUISettings } from '../np_ready/public/services'; import { showNewVisModal } from '../np_ready/public/wizard'; +import { TimefilterContract } from '../../../../../plugins/data/public'; interface VisualizationAttributes extends SavedObjectAttributes { visState: string; @@ -51,7 +52,10 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< > { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; - constructor(private getSavedVisualizationsLoader: () => SavedVisualizations) { + constructor( + private timefilter: TimefilterContract, + private getSavedVisualizationsLoader: () => SavedVisualizations + ) { super({ savedObjectMetaData: { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), @@ -114,6 +118,7 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const indexPattern = await getIndexPattern(savedObject); const indexPatterns = indexPattern ? [indexPattern] : []; return new VisualizeEmbeddable( + this.timefilter, { savedVisualization: savedObject, indexPatterns, diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index a948757d7bd83..9fb87cadb2983 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -56,6 +56,7 @@ const createInstance = async () => { const plugin = new VisualizationsPlugin({} as PluginInitializerContext); const setup = plugin.setup(coreMock.createSetup(), { + data: dataPluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createStartContract(), usageCollection: usageCollectionPluginMock.createSetupContract(), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index 36c04923e3fd0..20bed59faad88 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -36,7 +36,10 @@ import { ExpressionsSetup } from '../../../../../../plugins/expressions/public'; import { IEmbeddableSetup } from '../../../../../../plugins/embeddable/public'; import { visualization as visualizationFunction } from './expressions/visualization_function'; import { visualization as visualizationRenderer } from './expressions/visualization_renderer'; -import { DataPublicPluginStart } from '../../../../../../plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../../../plugins/data/public'; import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/public'; import { createSavedVisLoader, @@ -65,6 +68,7 @@ export interface VisualizationsSetupDeps { expressions: ExpressionsSetup; embeddable: IEmbeddableSetup; usageCollection: UsageCollectionSetup; + data: DataPublicPluginSetup; } export interface VisualizationsStartDeps { @@ -95,7 +99,7 @@ export class VisualizationsPlugin public setup( core: CoreSetup, - { expressions, embeddable, usageCollection }: VisualizationsSetupDeps + { expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps ): VisualizationsSetup { setUISettings(core.uiSettings); setUsageCollector(usageCollection); @@ -103,7 +107,10 @@ export class VisualizationsPlugin expressions.registerFunction(visualizationFunction); expressions.registerRenderer(visualizationRenderer); - const embeddableFactory = new VisualizeEmbeddableFactory(this.getSavedVisualizationsLoader); + const embeddableFactory = new VisualizeEmbeddableFactory( + data.query.timefilter.timefilter, + this.getSavedVisualizationsLoader + ); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); return { diff --git a/src/legacy/server/saved_objects/routes/_mock_server.ts b/src/legacy/server/saved_objects/routes/_mock_server.ts deleted file mode 100644 index 10b8c1aa07959..0000000000000 --- a/src/legacy/server/saved_objects/routes/_mock_server.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { defaultValidationErrorHandler } from '../../../../core/server/http/http_tools'; - -const defaultConfig = { - 'kibana.index': '.kibana', - 'savedObjects.maxImportExportSize': 10000, - 'savedObjects.maxImportPayloadBytes': 52428800, -}; - -export function createMockServer(config: { [key: string]: any } = defaultConfig) { - const server = new Hapi.Server({ - port: 0, - routes: { - validate: { - failAction: defaultValidationErrorHandler, - }, - }, - }); - server.config = () => { - return { - get(key: string) { - return config[key]; - }, - has(key: string) { - return config.hasOwnProperty(key); - }, - }; - }; - - return server; -} diff --git a/src/legacy/server/saved_objects/routes/bulk_create.ts b/src/legacy/server/saved_objects/routes/bulk_create.ts deleted file mode 100644 index b185650494f94..0000000000000 --- a/src/legacy/server/saved_objects/routes/bulk_create.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectAttributes, SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites, SavedObjectReference, WithoutQueryAndParams } from './types'; - -interface SavedObject { - type: string; - id?: string; - attributes: SavedObjectAttributes; - version?: string; - migrationVersion?: { [key: string]: string }; - references: SavedObjectReference[]; -} - -interface BulkCreateRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - query: { - overwrite: boolean; - }; - payload: SavedObject[]; -} - -export const createBulkCreateRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/_bulk_create', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - query: Joi.object() - .keys({ - overwrite: Joi.boolean().default(false), - }) - .default(), - payload: Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string(), - attributes: Joi.object().required(), - version: Joi.string(), - migrationVersion: Joi.object().optional(), - references: Joi.array() - .items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ) - .default([]), - }).required() - ), - }, - handler(request: BulkCreateRequest) { - const { overwrite } = request.query; - const { savedObjectsClient } = request.pre; - - return savedObjectsClient.bulkCreate(request.payload, { overwrite }); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/bulk_get.ts b/src/legacy/server/saved_objects/routes/bulk_get.ts deleted file mode 100644 index e9eca8e557982..0000000000000 --- a/src/legacy/server/saved_objects/routes/bulk_get.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface BulkGetRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - payload: Array<{ - type: string; - id: string; - fields?: string[]; - }>; -} - -export const createBulkGetRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/_bulk_get', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - payload: Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - fields: Joi.array().items(Joi.string()), - }).required() - ), - }, - handler(request: BulkGetRequest) { - const { savedObjectsClient } = request.pre; - - return savedObjectsClient.bulkGet(request.payload); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/bulk_update.ts b/src/legacy/server/saved_objects/routes/bulk_update.ts deleted file mode 100644 index a77b0c059447f..0000000000000 --- a/src/legacy/server/saved_objects/routes/bulk_update.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClient, SavedObjectsBulkUpdateObject } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface BulkUpdateRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClient; - }; - payload: SavedObjectsBulkUpdateObject[]; -} - -export const createBulkUpdateRoute = (prereqs: Prerequisites) => { - return { - path: '/api/saved_objects/_bulk_update', - method: 'PUT', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - payload: Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - attributes: Joi.object().required(), - version: Joi.string(), - references: Joi.array().items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ), - }) - ), - }, - handler(request: BulkUpdateRequest) { - const { savedObjectsClient } = request.pre; - return savedObjectsClient.bulkUpdate(request.payload); - }, - }, - }; -}; diff --git a/src/legacy/server/saved_objects/routes/create.test.ts b/src/legacy/server/saved_objects/routes/create.test.ts deleted file mode 100644 index 4f096a9ee5c93..0000000000000 --- a/src/legacy/server/saved_objects/routes/create.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createCreateRoute } from './create'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('POST /api/saved_objects/{type}', () => { - let server: Hapi.Server; - const clientResponse = { - id: 'logstash-*', - type: 'index-pattern', - title: 'logstash-*', - version: 'foo', - references: [], - attributes: {}, - }; - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse)); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createCreateRoute(prereqs)); - }); - - afterEach(() => { - savedObjectsClient.create.mockReset(); - }); - - it('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern', - payload: { - attributes: { - title: 'Testing', - }, - }, - }; - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); - }); - - it('requires attributes', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern', - payload: {}, - }; - - const { statusCode, payload } = await server.inject(request); - const response = JSON.parse(payload); - - expect(response.validation.keys).toContain('attributes'); - expect(response.message).toMatch(/is required/); - expect(response.statusCode).toBe(400); - expect(statusCode).toBe(400); - }); - - it('calls upon savedObjectClient.create', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern', - payload: { - attributes: { - title: 'Testing', - }, - }, - }; - - await server.inject(request); - expect(savedObjectsClient.create).toHaveBeenCalled(); - - expect(savedObjectsClient.create).toHaveBeenCalledWith( - 'index-pattern', - { title: 'Testing' }, - { overwrite: false, id: undefined, migrationVersion: undefined, references: [] } - ); - }); - - it('can specify an id', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern/logstash-*', - payload: { - attributes: { - title: 'Testing', - }, - }, - }; - - await server.inject(request); - expect(savedObjectsClient.create).toHaveBeenCalled(); - - const args = savedObjectsClient.create.mock.calls[0]; - const options = { overwrite: false, id: 'logstash-*', references: [] }; - const attributes = { title: 'Testing' }; - - expect(args).toEqual(['index-pattern', attributes, options]); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/create.ts b/src/legacy/server/saved_objects/routes/create.ts deleted file mode 100644 index a3f4a926972ca..0000000000000 --- a/src/legacy/server/saved_objects/routes/create.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectAttributes, SavedObjectsClient } from 'src/core/server'; -import { Prerequisites, SavedObjectReference, WithoutQueryAndParams } from './types'; - -interface CreateRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClient; - }; - query: { - overwrite: boolean; - }; - params: { - type: string; - id?: string; - }; - payload: { - attributes: SavedObjectAttributes; - migrationVersion?: { [key: string]: string }; - references: SavedObjectReference[]; - }; -} - -export const createCreateRoute = (prereqs: Prerequisites) => { - return { - path: '/api/saved_objects/{type}/{id?}', - method: 'POST', - options: { - pre: [prereqs.getSavedObjectsClient], - validate: { - query: Joi.object() - .keys({ - overwrite: Joi.boolean().default(false), - }) - .default(), - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string(), - }) - .required(), - payload: Joi.object({ - attributes: Joi.object().required(), - migrationVersion: Joi.object().optional(), - references: Joi.array() - .items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ) - .default([]), - }).required(), - }, - handler(request: CreateRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - const { overwrite } = request.query; - const { migrationVersion, references } = request.payload; - const options = { id, overwrite, migrationVersion, references }; - - return savedObjectsClient.create(type, request.payload.attributes, options); - }, - }, - }; -}; diff --git a/src/legacy/server/saved_objects/routes/delete.test.ts b/src/legacy/server/saved_objects/routes/delete.test.ts deleted file mode 100644 index f3e5e83771471..0000000000000 --- a/src/legacy/server/saved_objects/routes/delete.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createDeleteRoute } from './delete'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('DELETE /api/saved_objects/{type}/{id}', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - savedObjectsClient.delete.mockImplementation(() => Promise.resolve('{}')); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createDeleteRoute(prereqs)); - }); - - afterEach(() => { - savedObjectsClient.delete.mockReset(); - }); - - it('formats successful response', async () => { - const request = { - method: 'DELETE', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - - expect(statusCode).toBe(200); - expect(response).toEqual({}); - }); - - it('calls upon savedObjectClient.delete', async () => { - const request = { - method: 'DELETE', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - - await server.inject(request); - expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*'); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/delete.ts b/src/legacy/server/saved_objects/routes/delete.ts deleted file mode 100644 index a718f26bc2014..0000000000000 --- a/src/legacy/server/saved_objects/routes/delete.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface DeleteRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - params: { - type: string; - id: string; - }; -} - -export const createDeleteRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/{type}/{id}', - method: 'DELETE', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .required(), - }, - handler(request: DeleteRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - - return savedObjectsClient.delete(type, id); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/export.test.ts b/src/legacy/server/saved_objects/routes/export.test.ts deleted file mode 100644 index 93ca3a419e6df..0000000000000 --- a/src/legacy/server/saved_objects/routes/export.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('../../../../core/server/saved_objects/export', () => ({ - getSortedObjectsForExport: jest.fn(), -})); - -import Hapi from 'hapi'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import * as exportMock from '../../../../core/server/saved_objects/export'; -import { createMockServer } from './_mock_server'; -import { createExportRoute } from './export'; -import { createListStream } from '../../../utils/streams'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -const getSortedObjectsForExport = exportMock.getSortedObjectsForExport as jest.Mock; - -describe('POST /api/saved_objects/_export', () => { - let server: Hapi.Server; - const savedObjectsClient = { - ...savedObjectsClientMock.create(), - errors: {} as any, - }; - - beforeEach(() => { - server = createMockServer(); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createExportRoute(prereqs, server, ['index-pattern', 'search'])); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test('does not allow both "search" and "objects" to be specified', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_export', - payload: { - search: 'search', - objects: [{ type: 'search', id: 'bar' }], - includeReferencesDeep: true, - }, - }; - - const { payload, statusCode } = await server.inject(request); - - expect(statusCode).toEqual(400); - expect(JSON.parse(payload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "\\"search\\" must not exist simultaneously with [objects]", - "statusCode": 400, - "validation": Object { - "keys": Array [ - "value", - ], - "source": "payload", - }, - } - `); - }); - - test('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_export', - payload: { - type: 'search', - search: 'my search string', - includeReferencesDeep: true, - }, - }; - getSortedObjectsForExport.mockResolvedValueOnce( - createListStream([ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]) - ); - - const { payload, statusCode, headers } = await server.inject(request); - const objects = payload.split('\n').map(row => JSON.parse(row)); - - expect(statusCode).toBe(200); - expect(headers).toHaveProperty('content-disposition', 'attachment; filename="export.ndjson"'); - expect(headers).toHaveProperty('content-type', 'application/ndjson'); - expect(objects).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - ] - `); - expect(getSortedObjectsForExport).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "excludeExportDetails": false, - "exportSizeLimit": 10000, - "includeReferencesDeep": true, - "objects": undefined, - "savedObjectsClient": Object { - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "errors": Object {}, - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "search": "my search string", - "types": Array [ - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/export.ts b/src/legacy/server/saved_objects/routes/export.ts deleted file mode 100644 index ce4aed4b78c2a..0000000000000 --- a/src/legacy/server/saved_objects/routes/export.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import stringify from 'json-stable-stringify'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { - createPromiseFromStreams, - createMapStream, - createConcatStream, -} from '../../../utils/streams'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { getSortedObjectsForExport } from '../../../../core/server/saved_objects'; -import { Prerequisites } from './types'; - -interface ExportRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - payload: { - type?: string[]; - objects?: Array<{ - type: string; - id: string; - }>; - search?: string; - includeReferencesDeep: boolean; - excludeExportDetails: boolean; - }; -} - -export const createExportRoute = ( - prereqs: Prerequisites, - server: Hapi.Server, - supportedTypes: string[] -) => ({ - path: '/api/saved_objects/_export', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - payload: Joi.object() - .keys({ - type: Joi.array() - .items(Joi.string().valid(supportedTypes.sort())) - .single() - .optional(), - objects: Joi.array() - .items({ - type: Joi.string() - .valid(supportedTypes.sort()) - .required(), - id: Joi.string().required(), - }) - .max(server.config().get('savedObjects.maxImportExportSize')) - .optional(), - search: Joi.string().optional(), - includeReferencesDeep: Joi.boolean().default(false), - excludeExportDetails: Joi.boolean().default(false), - }) - .xor('type', 'objects') - .nand('search', 'objects') - .default(), - }, - async handler(request: ExportRequest, h: Hapi.ResponseToolkit) { - const { savedObjectsClient } = request.pre; - const exportStream = await getSortedObjectsForExport({ - savedObjectsClient, - types: request.payload.type, - search: request.payload.search, - objects: request.payload.objects, - exportSizeLimit: server.config().get('savedObjects.maxImportExportSize'), - includeReferencesDeep: request.payload.includeReferencesDeep, - excludeExportDetails: request.payload.excludeExportDetails, - }); - - const docsToExport: string[] = await createPromiseFromStreams([ - exportStream, - createMapStream((obj: unknown) => { - return stringify(obj); - }), - createConcatStream([]), - ]); - - return h - .response(docsToExport.join('\n')) - .header('Content-Disposition', `attachment; filename="export.ndjson"`) - .header('Content-Type', 'application/ndjson'); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/find.ts b/src/legacy/server/saved_objects/routes/find.ts deleted file mode 100644 index f8cb8c50d9684..0000000000000 --- a/src/legacy/server/saved_objects/routes/find.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites, WithoutQueryAndParams } from './types'; - -interface FindRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - query: { - per_page: number; - page: number; - type: string[]; - search?: string; - default_search_operator: 'AND' | 'OR'; - search_fields?: string[]; - sort_field?: string; - has_reference?: { - type: string; - id: string; - }; - fields?: string[]; - filter?: string; - }; -} - -export const createFindRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/_find', - method: 'GET', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - query: Joi.object() - .keys({ - per_page: Joi.number() - .min(0) - .default(20), - page: Joi.number() - .min(0) - .default(1), - type: Joi.array() - .items(Joi.string()) - .single() - .required(), - search: Joi.string() - .allow('') - .optional(), - default_search_operator: Joi.string() - .valid('OR', 'AND') - .default('OR'), - search_fields: Joi.array() - .items(Joi.string()) - .single(), - sort_field: Joi.string(), - has_reference: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .optional(), - fields: Joi.array() - .items(Joi.string()) - .single(), - filter: Joi.string() - .allow('') - .optional(), - }) - .default(), - }, - handler(request: FindRequest) { - const query = request.query; - return request.pre.savedObjectsClient.find({ - perPage: query.per_page, - page: query.page, - type: query.type, - search: query.search, - defaultSearchOperator: query.default_search_operator, - searchFields: query.search_fields, - sortField: query.sort_field, - hasReference: query.has_reference, - fields: query.fields, - filter: query.filter, - }); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/get.test.ts b/src/legacy/server/saved_objects/routes/get.test.ts deleted file mode 100644 index 7ede2a8b4d7b4..0000000000000 --- a/src/legacy/server/saved_objects/routes/get.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createGetRoute } from './get'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('GET /api/saved_objects/{type}/{id}', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - savedObjectsClient.get.mockImplementation(() => - Promise.resolve({ - id: 'logstash-*', - title: 'logstash-*', - type: 'logstash-type', - attributes: {}, - timeFieldName: '@timestamp', - notExpandable: true, - references: [], - }) - ); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createGetRoute(prereqs)); - }); - - afterEach(() => { - savedObjectsClient.get.mockReset(); - }); - - it('formats successful response', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - const clientResponse = { - id: 'logstash-*', - title: 'logstash-*', - type: 'logstash-type', - attributes: {}, - timeFieldName: '@timestamp', - notExpandable: true, - references: [], - }; - - savedObjectsClient.get.mockImplementation(() => Promise.resolve(clientResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); - }); - - it('calls upon savedObjectClient.get', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - - await server.inject(request); - expect(savedObjectsClient.get).toHaveBeenCalled(); - - const args = savedObjectsClient.get.mock.calls[0]; - expect(args).toEqual(['index-pattern', 'logstash-*']); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/get.ts b/src/legacy/server/saved_objects/routes/get.ts deleted file mode 100644 index 4dbb06d53425a..0000000000000 --- a/src/legacy/server/saved_objects/routes/get.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface GetRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - params: { - type: string; - id: string; - }; -} - -export const createGetRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/{type}/{id}', - method: 'GET', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .required(), - }, - handler(request: GetRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - - return savedObjectsClient.get(type, id); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/import.test.ts b/src/legacy/server/saved_objects/routes/import.test.ts deleted file mode 100644 index 2b8d9d7523507..0000000000000 --- a/src/legacy/server/saved_objects/routes/import.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createImportRoute } from './import'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('POST /api/saved_objects/_import', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); - const emptyResponse = { - saved_objects: [], - total: 0, - per_page: 0, - page: 0, - }; - - beforeEach(() => { - server = createMockServer(); - jest.resetAllMocks(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route( - createImportRoute(prereqs, server, ['index-pattern', 'visualization', 'dashboard']) - ); - }); - - test('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--BOUNDARY', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '', - '--BOUNDARY--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=BOUNDARY', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: true, - successCount: 0, - }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); - }); - - test('defaults migrationVersion to empty object', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: true, - successCount: 1, - }); - expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1); - const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; - expect(firstBulkCreateCallArray).toHaveLength(1); - expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); - }); - - test('imports an index pattern and dashboard, ignoring empty lines in the file', async () => { - // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '', - '', - '', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: true, - successCount: 2, - }); - }); - - test('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { - // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: {}, - references: [], - error: { - statusCode: 409, - message: 'version conflict, document already exists', - }, - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: false, - successCount: 1, - errors: [ - { - id: 'my-pattern', - type: 'index-pattern', - title: 'my-pattern-*', - error: { - type: 'conflict', - }, - }, - ], - }); - }); - - test('imports a visualization with missing references', async () => { - // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]}', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: 'my-pattern-*', - type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, - references: [], - attributes: {}, - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: false, - successCount: 0, - errors: [ - { - id: 'my-vis', - type: 'visualization', - title: 'my-vis', - error: { - type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: 'my-pattern-*', - }, - ], - blocking: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], - }, - }, - ], - }); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "my-pattern-*", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/import.ts b/src/legacy/server/saved_objects/routes/import.ts deleted file mode 100644 index 53fa3ea7d5d00..0000000000000 --- a/src/legacy/server/saved_objects/routes/import.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Boom from 'boom'; -import Hapi from 'hapi'; -import Joi from 'joi'; -import { extname } from 'path'; -import { Readable } from 'stream'; -import { SavedObjectsClientContract } from 'src/core/server'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { importSavedObjects } from '../../../../core/server/saved_objects/import'; -import { Prerequisites, WithoutQueryAndParams } from './types'; -import { createSavedObjectsStreamFromNdJson } from '../lib'; - -interface HapiReadableStream extends Readable { - hapi: { - filename: string; - }; -} - -interface ImportRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - query: { - overwrite: boolean; - }; - payload: { - file: HapiReadableStream; - }; -} - -export const createImportRoute = ( - prereqs: Prerequisites, - server: Hapi.Server, - supportedTypes: string[] -) => ({ - path: '/api/saved_objects/_import', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - payload: { - maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), - output: 'stream', - allow: 'multipart/form-data', - }, - validate: { - query: Joi.object() - .keys({ - overwrite: Joi.boolean().default(false), - }) - .default(), - payload: Joi.object({ - file: Joi.object().required(), - }).default(), - }, - }, - async handler(request: ImportRequest, h: Hapi.ResponseToolkit) { - const { savedObjectsClient } = request.pre; - const { filename } = request.payload.file.hapi; - const fileExtension = extname(filename).toLowerCase(); - if (fileExtension !== '.ndjson') { - return Boom.badRequest(`Invalid file extension ${fileExtension}`); - } - - return await importSavedObjects({ - supportedTypes, - savedObjectsClient, - readStream: createSavedObjectsStreamFromNdJson(request.payload.file), - objectLimit: request.server.config().get('savedObjects.maxImportExportSize'), - overwrite: request.query.overwrite, - }); - }, -}); diff --git a/src/legacy/server/saved_objects/routes/index.ts b/src/legacy/server/saved_objects/routes/index.ts deleted file mode 100644 index 0afcfba308546..0000000000000 --- a/src/legacy/server/saved_objects/routes/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { createBulkCreateRoute } from './bulk_create'; -export { createBulkGetRoute } from './bulk_get'; -export { createCreateRoute } from './create'; -export { createDeleteRoute } from './delete'; -export { createFindRoute } from './find'; -export { createGetRoute } from './get'; -export { createImportRoute } from './import'; -export { createLogLegacyImportRoute } from './log_legacy_import'; -export { createResolveImportErrorsRoute } from './resolve_import_errors'; -export { createUpdateRoute } from './update'; -export { createBulkUpdateRoute } from './bulk_update'; -export { createExportRoute } from './export'; diff --git a/src/legacy/server/saved_objects/routes/resolve_import_errors.ts b/src/legacy/server/saved_objects/routes/resolve_import_errors.ts deleted file mode 100644 index 15d02f525c2cf..0000000000000 --- a/src/legacy/server/saved_objects/routes/resolve_import_errors.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Boom from 'boom'; -import Hapi from 'hapi'; -import Joi from 'joi'; -import { extname } from 'path'; -import { Readable } from 'stream'; -import { SavedObjectsClientContract } from 'src/core/server'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { resolveImportErrors } from '../../../../core/server/saved_objects/import'; -import { Prerequisites } from './types'; -import { createSavedObjectsStreamFromNdJson } from '../lib'; - -interface HapiReadableStream extends Readable { - hapi: { - filename: string; - }; -} - -interface ImportRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - payload: { - file: HapiReadableStream; - retries: Array<{ - type: string; - id: string; - overwrite: boolean; - replaceReferences: Array<{ - type: string; - from: string; - to: string; - }>; - }>; - }; -} - -export const createResolveImportErrorsRoute = ( - prereqs: Prerequisites, - server: Hapi.Server, - supportedTypes: string[] -) => ({ - path: '/api/saved_objects/_resolve_import_errors', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - payload: { - maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), - output: 'stream', - allow: 'multipart/form-data', - }, - validate: { - payload: Joi.object({ - file: Joi.object().required(), - retries: Joi.array() - .items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - overwrite: Joi.boolean().default(false), - replaceReferences: Joi.array() - .items( - Joi.object({ - type: Joi.string().required(), - from: Joi.string().required(), - to: Joi.string().required(), - }) - ) - .default([]), - }) - ) - .required(), - }).default(), - }, - }, - async handler(request: ImportRequest) { - const { savedObjectsClient } = request.pre; - const { filename } = request.payload.file.hapi; - const fileExtension = extname(filename).toLowerCase(); - - if (fileExtension !== '.ndjson') { - return Boom.badRequest(`Invalid file extension ${fileExtension}`); - } - - return await resolveImportErrors({ - supportedTypes, - savedObjectsClient, - readStream: createSavedObjectsStreamFromNdJson(request.payload.file), - retries: request.payload.retries, - objectLimit: request.server.config().get('savedObjects.maxImportExportSize'), - }); - }, -}); diff --git a/src/legacy/server/saved_objects/routes/update.ts b/src/legacy/server/saved_objects/routes/update.ts deleted file mode 100644 index dadd1e412c8cd..0000000000000 --- a/src/legacy/server/saved_objects/routes/update.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectAttributes, SavedObjectsClient } from 'src/core/server'; -import { Prerequisites, SavedObjectReference } from './types'; - -interface UpdateRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClient; - }; - params: { - type: string; - id: string; - }; - payload: { - attributes: SavedObjectAttributes; - version?: string; - references: SavedObjectReference[]; - }; -} - -export const createUpdateRoute = (prereqs: Prerequisites) => { - return { - path: '/api/saved_objects/{type}/{id}', - method: 'PUT', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .required(), - payload: Joi.object({ - attributes: Joi.object().required(), - version: Joi.string(), - references: Joi.array().items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ), - }).required(), - }, - handler(request: UpdateRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - const { attributes, version, references } = request.payload; - const options = { version, references }; - - return savedObjectsClient.update(type, id, attributes, options); - }, - }, - }; -}; diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index 1d2f2d0f23e17..c7286f77ceccd 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -31,30 +31,6 @@ import { getRootPropertiesObjects } from '../../../core/server/saved_objects/map import { convertTypesToLegacySchema } from '../../../core/server/saved_objects/utils'; import { SavedObjectsManagement } from '../../../core/server/saved_objects/management'; -import { - createBulkCreateRoute, - createBulkGetRoute, - createCreateRoute, - createDeleteRoute, - createFindRoute, - createGetRoute, - createUpdateRoute, - createBulkUpdateRoute, - createExportRoute, - createImportRoute, - createResolveImportErrorsRoute, - createLogLegacyImportRoute, -} from './routes'; - -function getImportableAndExportableTypes({ kbnServer, visibleTypes }) { - const { savedObjectsManagement = {} } = kbnServer.uiExports; - return visibleTypes.filter( - type => - savedObjectsManagement[type] && - savedObjectsManagement[type].isImportableAndExportable === true - ); -} - export function savedObjectsMixin(kbnServer, server) { const migrator = kbnServer.newPlatform.__internals.kibanaMigrator; const typeRegistry = kbnServer.newPlatform.__internals.typeRegistry; @@ -62,7 +38,6 @@ export function savedObjectsMixin(kbnServer, server) { const allTypes = Object.keys(getRootPropertiesObjects(mappings)); const schema = new SavedObjectsSchema(convertTypesToLegacySchema(typeRegistry.getAllTypes())); const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type)); - const importableAndExportableTypes = getImportableAndExportableTypes({ kbnServer, visibleTypes }); server.decorate('server', 'kibanaMigrator', migrator); server.decorate( @@ -79,27 +54,6 @@ export function savedObjectsMixin(kbnServer, server) { return; } - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method(req) { - return req.getSavedObjectsClient(); - }, - }, - }; - server.route(createBulkCreateRoute(prereqs)); - server.route(createBulkGetRoute(prereqs)); - server.route(createBulkUpdateRoute(prereqs)); - server.route(createCreateRoute(prereqs)); - server.route(createDeleteRoute(prereqs)); - server.route(createFindRoute(prereqs)); - server.route(createGetRoute(prereqs)); - server.route(createUpdateRoute(prereqs)); - server.route(createExportRoute(prereqs, server, importableAndExportableTypes)); - server.route(createImportRoute(prereqs, server, importableAndExportableTypes)); - server.route(createResolveImportErrorsRoute(prereqs, server, importableAndExportableTypes)); - server.route(createLogLegacyImportRoute()); - const serializer = kbnServer.newPlatform.start.core.savedObjects.createSerializer(); const createRepository = (callCluster, extraTypes = []) => { diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index 9ca0374b959f6..99d2041448426 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -185,82 +185,6 @@ describe('Saved Objects Mixin', () => { }); }); - describe('Routes', () => { - it('should create 12 routes', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledTimes(12); - }); - it('should add POST /api/saved_objects/_bulk_create', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_bulk_create', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/_bulk_get', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_bulk_get', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/{type}/{id?}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id?}', method: 'POST' }) - ); - }); - it('should add DELETE /api/saved_objects/{type}/{id}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'DELETE' }) - ); - }); - it('should add GET /api/saved_objects/_find', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_find', method: 'GET' }) - ); - }); - it('should add GET /api/saved_objects/{type}/{id}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'GET' }) - ); - }); - it('should add PUT /api/saved_objects/{type}/{id}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'PUT' }) - ); - }); - it('should add GET /api/saved_objects/_export', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_export', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/_import', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_import', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/_resolve_import_errors', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/api/saved_objects/_resolve_import_errors', - method: 'POST', - }) - ); - }); - it('should add POST /api/saved_objects/_log_legacy_import', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_log_legacy_import', method: 'POST' }) - ); - }); - }); - describe('Saved object service', () => { let service; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index 4e52f6f6bafec..38b3434ef9c48 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -61,6 +61,10 @@ const mockCore = { }, }; +let refreshInterval = undefined; +let isTimeRangeSelectorEnabled = true; +let isAutoRefreshSelectorEnabled = true; + export const npSetup = { core: mockCore, plugins: { @@ -101,7 +105,14 @@ export const npSetup = { }, query: { filterManager: { + getFetches$: sinon.fake(), + getFilters: sinon.fake(), + getAppFilters: sinon.fake(), getGlobalFilters: sinon.fake(), + removeFilter: sinon.fake(), + addFilters: sinon.fake(), + setFilters: sinon.fake(), + removeAll: sinon.fake(), getUpdates$: mockObservable, }, timefilter: { @@ -110,6 +121,41 @@ export const npSetup = { getRefreshInterval: sinon.fake(), getTimeUpdate$: mockObservable, getRefreshIntervalUpdate$: mockObservable, + getFetch$: mockObservable, + getAutoRefreshFetch$: mockObservable, + getEnabledUpdated$: mockObservable, + getTimeUpdate$: mockObservable, + getRefreshIntervalUpdate$: mockObservable, + isTimeRangeSelectorEnabled: () => { + return isTimeRangeSelectorEnabled; + }, + isAutoRefreshSelectorEnabled: () => { + return isAutoRefreshSelectorEnabled; + }, + disableAutoRefreshSelector: () => { + isAutoRefreshSelectorEnabled = false; + }, + enableAutoRefreshSelector: () => { + isAutoRefreshSelectorEnabled = true; + }, + getRefreshInterval: () => { + return refreshInterval; + }, + setRefreshInterval: interval => { + refreshInterval = interval; + }, + enableTimeRangeSelector: () => { + isTimeRangeSelectorEnabled = true; + }, + disableTimeRangeSelector: () => { + isTimeRangeSelectorEnabled = false; + }, + getTime: sinon.fake(), + setTime: sinon.fake(), + getActiveBounds: sinon.fake(), + getBounds: sinon.fake(), + calculateBounds: sinon.fake(), + createFilter: sinon.fake(), }, history: sinon.fake(), }, @@ -183,10 +229,6 @@ export const npSetup = { }, }; -let refreshInterval = undefined; -let isTimeRangeSelectorEnabled = true; -let isAutoRefreshSelectorEnabled = true; - export const npStart = { core: { chrome: { diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx index e232e41902cbb..ca468f85275a8 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx @@ -26,7 +26,7 @@ import { useRequestReadContext, } from '../../../../contexts'; -import * as utils from '../../../../../lib/utils/utils'; +import { expandLiteralStrings } from '../../../../../../../es_ui_shared/console_lang/lib'; import { subscribeResizeChecker } from '../subscribe_console_resize_checker'; import { applyCurrentSettings } from './apply_editor_settings'; @@ -67,7 +67,7 @@ function EditorOutputUI() { editor.update( data .map(d => d.response.value as string) - .map(readOnlySettings.tripleQuotes ? utils.expandLiteralStrings : a => a) + .map(readOnlySettings.tripleQuotes ? expandLiteralStrings : a => a) .join('\n') ); } else if (error) { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts index 35d9865ee4ccb..102f90a9feb6f 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts @@ -17,10 +17,11 @@ * under the License. */ -import * as utils from '../../../lib/utils/utils'; +import { extractDeprecationMessages } from '../../../lib/utils'; +import { collapseLiteralStrings } from '../../../../../es_ui_shared/console_lang/lib'; // @ts-ignore import * as es from '../../../lib/es/es'; -import { BaseResponseType } from '../../../types/common'; +import { BaseResponseType } from '../../../types'; export interface EsRequestArgs { requests: any; @@ -73,7 +74,7 @@ export function sendRequestToES(args: EsRequestArgs): Promise const req = requests.shift(); const esPath = req.url; const esMethod = req.method; - let esData = utils.collapseLiteralStrings(req.data.join('\n')); + let esData = collapseLiteralStrings(req.data.join('\n')); if (esData) { esData += '\n'; } // append a new line for bulk requests. @@ -97,7 +98,7 @@ export function sendRequestToES(args: EsRequestArgs): Promise const warnings = xhr.getResponseHeader('warning'); if (warnings) { - const deprecationMessages = utils.extractDeprecationMessages(warnings); + const deprecationMessages = extractDeprecationMessages(warnings); value = deprecationMessages.join('\n') + '\n' + value; } diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js index da101b61f8035..d763db7ae5d79 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/input.js @@ -18,6 +18,7 @@ */ import ace from 'brace'; +import { workerModule } from './worker'; const oop = ace.acequire('ace/lib/oop'); const TextMode = ace.acequire('ace/mode/text').Mode; @@ -29,7 +30,6 @@ const WorkerClient = ace.acequire('ace/worker/worker_client').WorkerClient; const AceTokenizer = ace.acequire('ace/tokenizer').Tokenizer; const HighlightRules = require('./input_highlight_rules').InputHighlightRules; -import { workerModule } from './worker'; export function Mode() { this.$tokenizer = new AceTokenizer(new HighlightRules().getRules()); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js index 842736428e8bb..2c1b30f806f95 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js @@ -18,7 +18,7 @@ */ const ace = require('brace'); -import { addToRules } from './x_json_highlight_rules'; +import { addXJsonToRules } from '../../../../../../es_ui_shared/console_lang'; export function addEOL(tokens, reg, nextIfEOL, normalNext) { if (typeof reg === 'object') { @@ -101,7 +101,7 @@ export function InputHighlightRules() { ), }; - addToRules(this); + addXJsonToRules(this); if (this.constructor === InputHighlightRules) { this.normalizeRules(); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js index 920bdff1798db..e27222ebd65e9 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js @@ -19,7 +19,7 @@ const ace = require('brace'); import 'brace/mode/json'; -import { addToRules } from './x_json_highlight_rules'; +import { addXJsonToRules } from '../../../../../../es_ui_shared/console_lang'; const oop = ace.acequire('ace/lib/oop'); const JsonHighlightRules = ace.acequire('ace/mode/json_highlight_rules').JsonHighlightRules; @@ -27,7 +27,7 @@ const JsonHighlightRules = ace.acequire('ace/mode/json_highlight_rules').JsonHig export function OutputJsonHighlightRules() { this.$rules = {}; - addToRules(this, 'start'); + addXJsonToRules(this, 'start'); this.$rules.start.unshift( { diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js index b6b74c6377233..13ae329380221 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js @@ -18,17 +18,15 @@ */ import ace from 'brace'; +import { ScriptHighlightRules } from '../../../../../../es_ui_shared/console_lang'; const oop = ace.acequire('ace/lib/oop'); const TextMode = ace.acequire('ace/mode/text').Mode; const MatchingBraceOutdent = ace.acequire('ace/mode/matching_brace_outdent').MatchingBraceOutdent; const CstyleBehaviour = ace.acequire('ace/mode/behaviour/cstyle').CstyleBehaviour; const CStyleFoldMode = ace.acequire('ace/mode/folding/cstyle').FoldMode; -//const WorkerClient = ace.acequire('ace/worker/worker_client').WorkerClient; ace.acequire('ace/tokenizer'); -const ScriptHighlightRules = require('./script_highlight_rules').ScriptHighlightRules; - export function ScriptMode() { this.$outdent = new MatchingBraceOutdent(); this.$behaviour = new CstyleBehaviour(); @@ -57,19 +55,4 @@ oop.inherits(ScriptMode, TextMode); this.autoOutdent = function(state, doc, row) { this.$outdent.autoOutdent(doc, row); }; - - // this.createWorker = function (session) { - // const worker = new WorkerClient(['ace', 'sense_editor'], 'sense_editor/mode/worker', 'SenseWorker', 'sense_editor/mode/worker'); - // worker.attachToDocument(session.getDocument()); - - // worker.on('error', function (e) { - // session.setAnnotations([e.data]); - // }); - - // worker.on('ok', function (anno) { - // session.setAnnotations(anno.data); - // }); - - // return worker; - // }; }.call(ScriptMode.prototype)); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.d.ts b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.d.ts new file mode 100644 index 0000000000000..c7ceb6a95b896 --- /dev/null +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/index.d.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export declare const workerModule: { id: string; src: string }; diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js index a66bc20685df7..63f97345bc9ff 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js @@ -22,8 +22,8 @@ import $ from 'jquery'; import _ from 'lodash'; import { create } from '../create'; +import { collapseLiteralStrings } from '../../../../../../es_ui_shared/console_lang/lib'; const editorInput1 = require('./editor_input1.txt'); -const utils = require('../../../../lib/utils/utils'); describe('Editor', () => { let input; @@ -331,7 +331,7 @@ describe('Editor', () => { const expected = { method: 'POST', url: '_search', - data: [utils.collapseLiteralStrings(simpleRequest.data)], + data: [collapseLiteralStrings(simpleRequest.data)], }; compareRequest(request, expected); diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index 1271f167c6cc1..f559f5dfcd707 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -19,7 +19,9 @@ import _ from 'lodash'; import RowParser from '../../../lib/row_parser'; -import * as utils from '../../../lib/utils/utils'; +import { collapseLiteralStrings } from '../../../../../es_ui_shared/console_lang/lib'; +import * as utils from '../../../lib/utils'; + // @ts-ignore import * as es from '../../../lib/es/es'; @@ -480,7 +482,7 @@ export class SenseEditor { let ret = 'curl -X' + esMethod + ' "' + url + '"'; if (esData && esData.length) { ret += " -H 'Content-Type: application/json' -d'\n"; - const dataAsString = utils.collapseLiteralStrings(esData.join('\n')); + const dataAsString = collapseLiteralStrings(esData.join('\n')); // since Sense doesn't allow single quote json string any single qoute is within a string. ret += dataAsString.replace(/'/g, '\\"'); if (esData.length > 1) { diff --git a/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts b/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts index 00bfe32c85906..d5465ebe96514 100644 --- a/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts +++ b/src/plugins/console/public/lib/ace_token_provider/token_provider.test.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - import '../../application/models/sense_editor/sense_editor.test.mocks'; import $ from 'jquery'; diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index ac8fa1ea48caa..e09024ccfc859 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -29,7 +29,7 @@ import { // @ts-ignore } from '../kb/kb'; -import * as utils from '../utils/utils'; +import * as utils from '../utils'; // @ts-ignore import { populateContext } from './engine'; diff --git a/src/plugins/console/public/lib/utils/__tests__/utils.test.js b/src/plugins/console/public/lib/utils/__tests__/utils.test.js index f6828f354a1bc..6115be3c84ed9 100644 --- a/src/plugins/console/public/lib/utils/__tests__/utils.test.js +++ b/src/plugins/console/public/lib/utils/__tests__/utils.test.js @@ -17,125 +17,80 @@ * under the License. */ -const _ = require('lodash'); -const utils = require('../utils'); -const collapsingTests = require('./utils_string_collapsing.txt'); -const expandingTests = require('./utils_string_expanding.txt'); +const utils = require('../'); describe('Utils class', () => { - describe('collapseLiteralStrings', () => { - it('will collapse multiline strings', () => { - const multiline = '{ "foo": """bar\nbaz""" }'; - expect(utils.collapseLiteralStrings(multiline)).toEqual('{ "foo": "bar\\nbaz" }'); - }); + test('extract deprecation messages', function() { + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning" "Mon, 27 Feb 2017 14:52:14 GMT"' + ) + ).toEqual(['#! Deprecation: this is a warning']); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning"' + ) + ).toEqual(['#! Deprecation: this is a warning']); - it('will collapse multiline strings with CRLF endings', () => { - const multiline = '{ "foo": """bar\r\nbaz""" }'; - expect(utils.collapseLiteralStrings(multiline)).toEqual('{ "foo": "bar\\r\\nbaz" }'); - }); - }); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning" "Mon, 27 Feb 2017 14:52:14 GMT", 299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a second warning" "Mon, 27 Feb 2017 14:52:14 GMT"' + ) + ).toEqual(['#! Deprecation: this is a warning', '#! Deprecation: this is a second warning']); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning", 299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a second warning"' + ) + ).toEqual(['#! Deprecation: this is a warning', '#! Deprecation: this is a second warning']); - _.each(collapsingTests.split(/^=+$/m), function(fixture) { - if (fixture.trim() === '') { - return; - } - fixture = fixture.split(/^-+$/m); - const name = fixture[0].trim(); - const expanded = fixture[1].trim(); - const collapsed = fixture[2].trim(); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes a comma" "Mon, 27 Feb 2017 14:52:14 GMT"' + ) + ).toEqual(['#! Deprecation: this is a warning, and it includes a comma']); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes a comma"' + ) + ).toEqual(['#! Deprecation: this is a warning, and it includes a comma']); - test('Literal collapse - ' + name, function() { - expect(utils.collapseLiteralStrings(expanded)).toEqual(collapsed); - }); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes an escaped backslash \\\\ and a pair of \\"escaped quotes\\"" "Mon, 27 Feb 2017 14:52:14 GMT"' + ) + ).toEqual([ + '#! Deprecation: this is a warning, and it includes an escaped backslash \\ and a pair of "escaped quotes"', + ]); + expect( + utils.extractDeprecationMessages( + '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes an escaped backslash \\\\ and a pair of \\"escaped quotes\\""' + ) + ).toEqual([ + '#! Deprecation: this is a warning, and it includes an escaped backslash \\ and a pair of "escaped quotes"', + ]); }); - _.each(expandingTests.split(/^=+$/m), function(fixture) { - if (fixture.trim() === '') { - return; - } - fixture = fixture.split(/^-+$/m); - const name = fixture[0].trim(); - const collapsed = fixture[1].trim(); - const expanded = fixture[2].trim(); - - test('Literal expand - ' + name, function() { - expect(utils.expandLiteralStrings(collapsed)).toEqual(expanded); - }); - - test('extract deprecation messages', function() { - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning" "Mon, 27 Feb 2017 14:52:14 GMT"' - ) - ).toEqual(['#! Deprecation: this is a warning']); - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning"' - ) - ).toEqual(['#! Deprecation: this is a warning']); - - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning" "Mon, 27 Feb 2017 14:52:14 GMT", 299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a second warning" "Mon, 27 Feb 2017 14:52:14 GMT"' - ) - ).toEqual(['#! Deprecation: this is a warning', '#! Deprecation: this is a second warning']); - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning", 299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a second warning"' - ) - ).toEqual(['#! Deprecation: this is a warning', '#! Deprecation: this is a second warning']); - - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes a comma" "Mon, 27 Feb 2017 14:52:14 GMT"' - ) - ).toEqual(['#! Deprecation: this is a warning, and it includes a comma']); - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes a comma"' - ) - ).toEqual(['#! Deprecation: this is a warning, and it includes a comma']); - - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes an escaped backslash \\\\ and a pair of \\"escaped quotes\\"" "Mon, 27 Feb 2017 14:52:14 GMT"' - ) - ).toEqual([ - '#! Deprecation: this is a warning, and it includes an escaped backslash \\ and a pair of "escaped quotes"', - ]); - expect( - utils.extractDeprecationMessages( - '299 Elasticsearch-6.0.0-alpha1-SNAPSHOT-abcdef1 "this is a warning, and it includes an escaped backslash \\\\ and a pair of \\"escaped quotes\\""' - ) - ).toEqual([ - '#! Deprecation: this is a warning, and it includes an escaped backslash \\ and a pair of "escaped quotes"', - ]); - }); - - test('unescape', function() { - expect(utils.unescape('escaped backslash \\\\')).toEqual('escaped backslash \\'); - expect(utils.unescape('a pair of \\"escaped quotes\\"')).toEqual( - 'a pair of "escaped quotes"' - ); - expect(utils.unescape('escaped quotes do not have to come in pairs: \\"')).toEqual( - 'escaped quotes do not have to come in pairs: "' - ); - }); + test('unescape', function() { + expect(utils.unescape('escaped backslash \\\\')).toEqual('escaped backslash \\'); + expect(utils.unescape('a pair of \\"escaped quotes\\"')).toEqual('a pair of "escaped quotes"'); + expect(utils.unescape('escaped quotes do not have to come in pairs: \\"')).toEqual( + 'escaped quotes do not have to come in pairs: "' + ); + }); - test('split on unquoted comma followed by space', function() { - expect(utils.splitOnUnquotedCommaSpace('a, b')).toEqual(['a', 'b']); - expect(utils.splitOnUnquotedCommaSpace('a,b, c')).toEqual(['a,b', 'c']); - expect(utils.splitOnUnquotedCommaSpace('"a, b"')).toEqual(['"a, b"']); - expect(utils.splitOnUnquotedCommaSpace('"a, b", c')).toEqual(['"a, b"', 'c']); - expect(utils.splitOnUnquotedCommaSpace('"a, b\\", c"')).toEqual(['"a, b\\", c"']); - expect(utils.splitOnUnquotedCommaSpace(', a, b')).toEqual(['', 'a', 'b']); - expect(utils.splitOnUnquotedCommaSpace('a, b, ')).toEqual(['a', 'b', '']); - expect(utils.splitOnUnquotedCommaSpace('\\"a, b", "c, d\\", e", f"')).toEqual([ - '\\"a', - 'b", "c', - 'd\\"', - 'e", f"', - ]); - }); + test('split on unquoted comma followed by space', function() { + expect(utils.splitOnUnquotedCommaSpace('a, b')).toEqual(['a', 'b']); + expect(utils.splitOnUnquotedCommaSpace('a,b, c')).toEqual(['a,b', 'c']); + expect(utils.splitOnUnquotedCommaSpace('"a, b"')).toEqual(['"a, b"']); + expect(utils.splitOnUnquotedCommaSpace('"a, b", c')).toEqual(['"a, b"', 'c']); + expect(utils.splitOnUnquotedCommaSpace('"a, b\\", c"')).toEqual(['"a, b\\", c"']); + expect(utils.splitOnUnquotedCommaSpace(', a, b')).toEqual(['', 'a', 'b']); + expect(utils.splitOnUnquotedCommaSpace('a, b, ')).toEqual(['a', 'b', '']); + expect(utils.splitOnUnquotedCommaSpace('\\"a, b", "c, d\\", e", f"')).toEqual([ + '\\"a', + 'b", "c', + 'd\\"', + 'e", f"', + ]); }); }); diff --git a/src/plugins/console/public/lib/utils/utils.ts b/src/plugins/console/public/lib/utils/index.ts similarity index 59% rename from src/plugins/console/public/lib/utils/utils.ts rename to src/plugins/console/public/lib/utils/index.ts index 0b10938abe704..f66c952dd3af7 100644 --- a/src/plugins/console/public/lib/utils/utils.ts +++ b/src/plugins/console/public/lib/utils/index.ts @@ -18,6 +18,10 @@ */ import _ from 'lodash'; +import { + expandLiteralStrings, + collapseLiteralStrings, +} from '../../../../es_ui_shared/console_lang/lib'; export function textFromRequest(request: any) { let data = request.data; @@ -56,59 +60,6 @@ export function formatRequestBodyDoc(data: string[], indent: boolean) { }; } -export function collapseLiteralStrings(data: any) { - const splitData = data.split(`"""`); - for (let idx = 1; idx < splitData.length - 1; idx += 2) { - splitData[idx] = JSON.stringify(splitData[idx]); - } - return splitData.join(''); -} - -/* - The following regex describes global match on: - 1. one colon followed by any number of space characters - 2. one double quote (not escaped, special case for JSON in JSON). - 3. greedily match any non double quote and non newline char OR any escaped double quote char (non-capturing). - 4. handle a special case where an escaped slash may be the last character - 5. one double quote - - For instance: `: "some characters \" here"` - Will match and be expanded to: `"""some characters " here"""` - - */ - -const LITERAL_STRING_CANDIDATES = /((:[\s\r\n]*)([^\\])"(\\"|[^"\n])*\\?")/g; - -export function expandLiteralStrings(data: string) { - return data.replace(LITERAL_STRING_CANDIDATES, (match, string) => { - // Expand to triple quotes if there are _any_ slashes - if (string.match(/\\./)) { - const firstDoubleQuoteIdx = string.indexOf('"'); - const lastDoubleQuoteIdx = string.lastIndexOf('"'); - - // Handle a special case where we may have a value like "\"test\"". We don't - // want to expand this to """"test"""" - so we terminate before processing the string - // further if we detect this either at the start or end of the double quote section. - - if (string[firstDoubleQuoteIdx + 1] === '\\' && string[firstDoubleQuoteIdx + 2] === '"') { - return string; - } - - if (string[lastDoubleQuoteIdx - 1] === '"' && string[lastDoubleQuoteIdx - 2] === '\\') { - return string; - } - - const colonAndAnySpacing = string.slice(0, firstDoubleQuoteIdx); - const rawStringifiedValue = string.slice(firstDoubleQuoteIdx, string.length); - // Remove one level of JSON stringification - const jsonValue = JSON.parse(rawStringifiedValue); - return `${colonAndAnySpacing}"""${jsonValue}"""`; - } else { - return string; - } - }); -} - export function extractDeprecationMessages(warnings: string) { // pattern for valid warning header const re = /\d{3} [0-9a-zA-Z!#$%&'*+-.^_`|~]+ \"((?:\t| |!|[\x23-\x5b]|[\x5d-\x7e]|[\x80-\xff]|\\\\|\\")*)\"(?: \"[^"]*\")?/; diff --git a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts index 2923cee60f898..80c13464ad98a 100644 --- a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts +++ b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts @@ -18,6 +18,7 @@ */ import { TimefilterService, TimeHistoryContract, TimefilterContract } from '.'; +import { Observable } from 'rxjs'; export type TimefilterServiceClientContract = PublicMethodsOf; @@ -28,7 +29,7 @@ const createSetupContractMock = () => { getEnabledUpdated$: jest.fn(), getTimeUpdate$: jest.fn(), getRefreshIntervalUpdate$: jest.fn(), - getAutoRefreshFetch$: jest.fn(), + getAutoRefreshFetch$: jest.fn(() => new Observable()), getFetch$: jest.fn(), getTime: jest.fn(), setTime: jest.fn(), diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 3ca84549c559d..9a364e84092ca 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -17,7 +17,7 @@ * under the License. */ -import { UiActionsSetup } from 'src/plugins/ui_actions/public'; +import { UiActionsSetup, Trigger } from 'src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, APPLY_FILTER_TRIGGER, @@ -34,35 +34,30 @@ import { * @param api */ export const bootstrap = (uiActions: UiActionsSetup) => { - const triggerContext = { + const triggerContext: Trigger = { id: CONTEXT_MENU_TRIGGER, title: 'Context menu', description: 'Triggered on top-right corner context-menu select.', - actionIds: [], }; - const triggerFilter = { + const triggerFilter: Trigger = { id: APPLY_FILTER_TRIGGER, title: 'Filter click', description: 'Triggered when user applies filter to an embeddable.', - actionIds: [], }; - const triggerBadge = { + const triggerBadge: Trigger = { id: PANEL_BADGE_TRIGGER, title: 'Panel badges', description: 'Actions appear in title bar when an embeddable loads in a panel', - actionIds: [], }; - const selectRangeTrigger = { + const selectRangeTrigger: Trigger = { id: SELECT_RANGE_TRIGGER, title: 'Select range', description: 'Applies a range filter', - actionIds: [], }; - const valueClickTrigger = { + const valueClickTrigger: Trigger = { id: VALUE_CLICK_TRIGGER, title: 'Value clicked', description: 'Value was clicked', - actionIds: [], }; const actionApplyFilter = createFilterAction(); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 9982c632f36fb..79d59317767d9 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -25,7 +25,7 @@ import { nextTick } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { I18nProvider } from '@kbn/i18n/react'; import { CONTEXT_MENU_TRIGGER } from '../triggers'; -import { Action, UiActionsApi } from 'src/plugins/ui_actions/public'; +import { Action, UiActionsStart } from 'src/plugins/ui_actions/public'; import { Trigger, GetEmbeddableFactory, ViewMode } from '../types'; import { EmbeddableFactory, isErrorEmbeddable } from '../embeddables'; import { EmbeddablePanel } from './embeddable_panel'; @@ -52,7 +52,6 @@ const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => embeddableFac const editModeAction = createEditModeAction(); const trigger: Trigger = { id: CONTEXT_MENU_TRIGGER, - actionIds: [editModeAction.id], }; const embeddableFactory = new ContactCardEmbeddableFactory( {} as any, @@ -177,7 +176,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => { const renderInEditModeAndOpenContextMenu = async ( embeddableInputs: any, - getActions: UiActionsApi['getTriggerCompatibleActions'] = () => Promise.resolve([]) + getActions: UiActionsStart['getTriggerCompatibleActions'] = () => Promise.resolve([]) ) => { const inspector = inspectorPluginMock.createStartContract(); diff --git a/src/plugins/embeddable/public/lib/types.ts b/src/plugins/embeddable/public/lib/types.ts index 1bd71163db44c..68ea5bc17f7c9 100644 --- a/src/plugins/embeddable/public/lib/types.ts +++ b/src/plugins/embeddable/public/lib/types.ts @@ -24,7 +24,6 @@ export interface Trigger { id: string; title?: string; description?: string; - actionIds: string[]; } export interface PropertySpec { diff --git a/src/plugins/embeddable/public/tests/test_plugin.ts b/src/plugins/embeddable/public/tests/test_plugin.ts index d9e1a75d92bf3..1edc332780336 100644 --- a/src/plugins/embeddable/public/tests/test_plugin.ts +++ b/src/plugins/embeddable/public/tests/test_plugin.ts @@ -19,8 +19,8 @@ import { CoreSetup, CoreStart } from 'src/core/public'; // eslint-disable-next-line -import { uiActionsTestPlugin } from 'src/plugins/ui_actions/public/tests'; -import { UiActionsApi } from 'src/plugins/ui_actions/public'; +import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { coreMock } from '../../../../core/public/mocks'; import { EmbeddablePublicPlugin, IEmbeddableSetup, IEmbeddableStart } from '../plugin'; @@ -30,14 +30,14 @@ export interface TestPluginReturn { coreStart: CoreStart; setup: IEmbeddableSetup; doStart: (anotherCoreStart?: CoreStart) => IEmbeddableStart; - uiActions: UiActionsApi; + uiActions: UiActionsStart; } export const testPlugin = ( coreSetup: CoreSetup = coreMock.createSetup(), coreStart: CoreStart = coreMock.createStart() ): TestPluginReturn => { - const uiActions = uiActionsTestPlugin(coreSetup, coreStart); + const uiActions = uiActionsPluginMock.createPlugin(coreSetup, coreStart); const initializerContext = {} as any; const plugin = new EmbeddablePublicPlugin(initializerContext); const setup = plugin.setup(coreSetup, { uiActions: uiActions.setup }); diff --git a/src/legacy/server/saved_objects/routes/types.ts b/src/plugins/es_ui_shared/console_lang/ace/modes/index.d.ts similarity index 60% rename from src/legacy/server/saved_objects/routes/types.ts rename to src/plugins/es_ui_shared/console_lang/ace/modes/index.d.ts index b3f294b66499b..06c9f9a51ea68 100644 --- a/src/legacy/server/saved_objects/routes/types.ts +++ b/src/plugins/es_ui_shared/console_lang/ace/modes/index.d.ts @@ -17,20 +17,16 @@ * under the License. */ -import Hapi from 'hapi'; -import { SavedObjectsClientContract } from 'src/core/server'; +import { Editor } from 'brace'; -export interface SavedObjectReference { - name: string; - type: string; - id: string; -} +export declare const ElasticsearchSqlHighlightRules: FunctionConstructor; +export declare const ScriptHighlightRules: FunctionConstructor; +export declare const XJsonHighlightRules: FunctionConstructor; -export interface Prerequisites { - getSavedObjectsClient: { - assign: string; - method: (req: Hapi.Request) => SavedObjectsClientContract; - }; -} +export declare const XJsonMode: FunctionConstructor; -export type WithoutQueryAndParams = Pick>; +/** + * @param otherRules Another Ace ruleset + * @param embedUnder The state name under which the rules will be embedded. Defaults to "json". + */ +export declare const addXJsonToRules: (otherRules: any, embedUnder?: string) => void; diff --git a/src/plugins/ui_actions/public/tests/helpers.ts b/src/plugins/es_ui_shared/console_lang/ace/modes/index.js similarity index 77% rename from src/plugins/ui_actions/public/tests/helpers.ts rename to src/plugins/es_ui_shared/console_lang/ace/modes/index.js index d1a4a71705a81..955f23116f659 100644 --- a/src/plugins/ui_actions/public/tests/helpers.ts +++ b/src/plugins/es_ui_shared/console_lang/ace/modes/index.js @@ -17,12 +17,11 @@ * under the License. */ -import { UiActionsDependencies } from '../types'; +export { + ElasticsearchSqlHighlightRules, + ScriptHighlightRules, + XJsonHighlightRules, + addXJsonToRules, +} from './lexer_rules'; -export const createDeps = (): UiActionsDependencies => { - const deps: UiActionsDependencies = { - actions: new Map(), - triggers: new Map(), - }; - return deps; -}; +export { XJsonMode, installXJsonMode } from './x_json'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/elasticsearch_sql_highlight_rules.ts b/src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/elasticsearch_sql_highlight_rules.ts similarity index 100% rename from src/plugins/console/public/application/models/legacy_core_editor/mode/elasticsearch_sql_highlight_rules.ts rename to src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/elasticsearch_sql_highlight_rules.ts diff --git a/src/plugins/ui_actions/public/triggers/get_trigger.ts b/src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/index.js similarity index 74% rename from src/plugins/ui_actions/public/triggers/get_trigger.ts rename to src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/index.js index 5c96200261a90..be11fd726b7f2 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger.ts +++ b/src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/index.js @@ -17,14 +17,6 @@ * under the License. */ -import { UiActionsApiPure } from '../types'; - -export const getTrigger: UiActionsApiPure['getTrigger'] = ({ triggers }) => id => { - const trigger = triggers.get(id); - - if (!trigger) { - throw new Error(`Trigger [triggerId = ${id}] does not exist.`); - } - - return trigger; -}; +export { ElasticsearchSqlHighlightRules } from './elasticsearch_sql_highlight_rules'; +export { ScriptHighlightRules } from './script_highlight_rules'; +export { XJsonHighlightRules, addToRules as addXJsonToRules } from './x_json_highlight_rules'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/script_highlight_rules.js b/src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/script_highlight_rules.js similarity index 100% rename from src/plugins/console/public/application/models/legacy_core_editor/mode/script_highlight_rules.js rename to src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/script_highlight_rules.js diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/x_json_highlight_rules.js b/src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/x_json_highlight_rules.js similarity index 85% rename from src/plugins/console/public/application/models/legacy_core_editor/mode/x_json_highlight_rules.js rename to src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/x_json_highlight_rules.js index d0a79e84e809d..14323b9320330 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/x_json_highlight_rules.js +++ b/src/plugins/es_ui_shared/console_lang/ace/modes/lexer_rules/x_json_highlight_rules.js @@ -17,11 +17,16 @@ * under the License. */ -const _ = require('lodash'); +import * as _ from 'lodash'; +import ace from 'brace'; +import 'brace/mode/json'; import { ElasticsearchSqlHighlightRules } from './elasticsearch_sql_highlight_rules'; const { ScriptHighlightRules } = require('./script_highlight_rules'); +const { JsonHighlightRules } = ace.acequire('ace/mode/json_highlight_rules'); +const oop = ace.acequire('ace/lib/oop'); + const jsonRules = function(root) { root = root ? root : 'json'; const rules = {}; @@ -146,6 +151,30 @@ const jsonRules = function(root) { return rules; }; +export function XJsonHighlightRules() { + this.$rules = { + ...jsonRules('start'), + }; + + this.embedRules(ScriptHighlightRules, 'script-', [ + { + token: 'punctuation.end_triple_quote', + regex: '"""', + next: 'pop', + }, + ]); + + this.embedRules(ElasticsearchSqlHighlightRules, 'sql-', [ + { + token: 'punctuation.end_triple_quote', + regex: '"""', + next: 'pop', + }, + ]); +} + +oop.inherits(XJsonHighlightRules, JsonHighlightRules); + export function addToRules(otherRules, embedUnder) { otherRules.$rules = _.defaultsDeep(otherRules.$rules, jsonRules(embedUnder)); otherRules.embedRules(ScriptHighlightRules, 'script-', [ diff --git a/src/legacy/server/saved_objects/lib/index.ts b/src/plugins/es_ui_shared/console_lang/ace/modes/x_json/index.ts similarity index 89% rename from src/legacy/server/saved_objects/lib/index.ts rename to src/plugins/es_ui_shared/console_lang/ace/modes/x_json/index.ts index 1255ef67a03c2..caa7b518b8b66 100644 --- a/src/legacy/server/saved_objects/lib/index.ts +++ b/src/plugins/es_ui_shared/console_lang/ace/modes/x_json/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { createSavedObjectsStreamFromNdJson } from './create_saved_objects_stream_from_ndjson'; +export { XJsonMode } from './x_json_mode'; diff --git a/src/plugins/es_ui_shared/console_lang/ace/modes/x_json/x_json_mode.ts b/src/plugins/es_ui_shared/console_lang/ace/modes/x_json/x_json_mode.ts new file mode 100644 index 0000000000000..9f804c29a5d27 --- /dev/null +++ b/src/plugins/es_ui_shared/console_lang/ace/modes/x_json/x_json_mode.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import ace from 'brace'; + +import { XJsonHighlightRules } from '../index'; + +const oop = ace.acequire('ace/lib/oop'); +const { Mode: JSONMode } = ace.acequire('ace/mode/json'); +const { Tokenizer: AceTokenizer } = ace.acequire('ace/tokenizer'); +const { MatchingBraceOutdent } = ace.acequire('ace/mode/matching_brace_outdent'); +const { CstyleBehaviour } = ace.acequire('ace/mode/behaviour/cstyle'); +const { FoldMode: CStyleFoldMode } = ace.acequire('ace/mode/folding/cstyle'); + +export function XJsonMode(this: any) { + const ruleset: any = new XJsonHighlightRules(); + ruleset.normalizeRules(); + this.$tokenizer = new AceTokenizer(ruleset.getRules()); + this.$outdent = new MatchingBraceOutdent(); + this.$behaviour = new CstyleBehaviour(); + this.foldingRules = new CStyleFoldMode(); +} + +oop.inherits(XJsonMode, JSONMode); diff --git a/src/plugins/ui_actions/public/actions/register_action.ts b/src/plugins/es_ui_shared/console_lang/index.ts similarity index 73% rename from src/plugins/ui_actions/public/actions/register_action.ts rename to src/plugins/es_ui_shared/console_lang/index.ts index 5738be63c9592..a4958911af412 100644 --- a/src/plugins/ui_actions/public/actions/register_action.ts +++ b/src/plugins/es_ui_shared/console_lang/index.ts @@ -17,12 +17,13 @@ * under the License. */ -import { UiActionsApiPure } from '../types'; +// Lib is intentionally not included in this barrel export file to separate worker logic +// from being imported with pure functions -export const registerAction: UiActionsApiPure['registerAction'] = ({ actions }) => action => { - if (actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); - } - - actions.set(action.id, action); -}; +export { + ElasticsearchSqlHighlightRules, + ScriptHighlightRules, + XJsonHighlightRules, + addXJsonToRules, + XJsonMode, +} from './ace/modes'; diff --git a/src/plugins/es_ui_shared/console_lang/lib/index.ts b/src/plugins/es_ui_shared/console_lang/lib/index.ts new file mode 100644 index 0000000000000..bf7f0290d4158 --- /dev/null +++ b/src/plugins/es_ui_shared/console_lang/lib/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { collapseLiteralStrings, expandLiteralStrings } from './json_xjson_translation_tools'; diff --git a/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/json_xjson_translation_tools.test.ts b/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/json_xjson_translation_tools.test.ts new file mode 100644 index 0000000000000..92c14ade791cd --- /dev/null +++ b/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/json_xjson_translation_tools.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import _ from 'lodash'; +// @ts-ignore +import collapsingTests from './utils_string_collapsing.txt'; +// @ts-ignore +import expandingTests from './utils_string_expanding.txt'; + +import * as utils from '../index'; + +describe('JSON to XJSON conversion tools', () => { + it('will collapse multiline strings', () => { + const multiline = '{ "foo": """bar\nbaz""" }'; + expect(utils.collapseLiteralStrings(multiline)).toEqual('{ "foo": "bar\\nbaz" }'); + }); + + it('will collapse multiline strings with CRLF endings', () => { + const multiline = '{ "foo": """bar\r\nbaz""" }'; + expect(utils.collapseLiteralStrings(multiline)).toEqual('{ "foo": "bar\\r\\nbaz" }'); + }); +}); + +_.each(collapsingTests.split(/^=+$/m), function(fixture) { + if (fixture.trim() === '') { + return; + } + fixture = fixture.split(/^-+$/m); + const name = fixture[0].trim(); + const expanded = fixture[1].trim(); + const collapsed = fixture[2].trim(); + + test('Literal collapse - ' + name, function() { + expect(utils.collapseLiteralStrings(expanded)).toEqual(collapsed); + }); +}); + +_.each(expandingTests.split(/^=+$/m), function(fixture) { + if (fixture.trim() === '') { + return; + } + fixture = fixture.split(/^-+$/m); + const name = fixture[0].trim(); + const collapsed = fixture[1].trim(); + const expanded = fixture[2].trim(); + + test('Literal expand - ' + name, function() { + expect(utils.expandLiteralStrings(collapsed)).toEqual(expanded); + }); +}); diff --git a/src/plugins/console/public/lib/utils/__tests__/utils_string_collapsing.txt b/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/utils_string_collapsing.txt similarity index 100% rename from src/plugins/console/public/lib/utils/__tests__/utils_string_collapsing.txt rename to src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/utils_string_collapsing.txt diff --git a/src/plugins/console/public/lib/utils/__tests__/utils_string_expanding.txt b/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/utils_string_expanding.txt similarity index 100% rename from src/plugins/console/public/lib/utils/__tests__/utils_string_expanding.txt rename to src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/__tests__/utils_string_expanding.txt diff --git a/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/index.ts b/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/index.ts new file mode 100644 index 0000000000000..28f1aca95efab --- /dev/null +++ b/src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools/index.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function collapseLiteralStrings(data: string) { + const splitData = data.split(`"""`); + for (let idx = 1; idx < splitData.length - 1; idx += 2) { + splitData[idx] = JSON.stringify(splitData[idx]); + } + return splitData.join(''); +} + +/* + The following regex describes global match on: + 1. one colon followed by any number of space characters + 2. one double quote (not escaped, special case for JSON in JSON). + 3. greedily match any non double quote and non newline char OR any escaped double quote char (non-capturing). + 4. handle a special case where an escaped slash may be the last character + 5. one double quote + + For instance: `: "some characters \" here"` + Will match and be expanded to: `"""some characters " here"""` + + */ + +const LITERAL_STRING_CANDIDATES = /((:[\s\r\n]*)([^\\])"(\\"|[^"\n])*\\?")/g; + +export function expandLiteralStrings(data: string) { + return data.replace(LITERAL_STRING_CANDIDATES, (match, string) => { + // Expand to triple quotes if there are _any_ slashes + if (string.match(/\\./)) { + const firstDoubleQuoteIdx = string.indexOf('"'); + const lastDoubleQuoteIdx = string.lastIndexOf('"'); + + // Handle a special case where we may have a value like "\"test\"". We don't + // want to expand this to """"test"""" - so we terminate before processing the string + // further if we detect this either at the start or end of the double quote section. + + if (string[firstDoubleQuoteIdx + 1] === '\\' && string[firstDoubleQuoteIdx + 2] === '"') { + return string; + } + + if (string[lastDoubleQuoteIdx - 1] === '"' && string[lastDoubleQuoteIdx - 2] === '\\') { + return string; + } + + const colonAndAnySpacing = string.slice(0, firstDoubleQuoteIdx); + const rawStringifiedValue = string.slice(firstDoubleQuoteIdx, string.length); + // Remove one level of JSON stringification + const jsonValue = JSON.parse(rawStringifiedValue); + return `${colonAndAnySpacing}"""${jsonValue}"""`; + } else { + return string; + } + }); +} diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index a12c951ad13a8..67e3617a85115 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './components/json_editor'; +export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor'; diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 3937bd309327d..eeb1ed80e8d0d 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -21,6 +21,7 @@ import { Execution } from './execution'; import { parseExpression } from '../ast'; import { createUnitTestExecutor } from '../test_helpers'; import { ExpressionFunctionDefinition } from '../../public'; +import { ExecutionContract } from './execution_contract'; const createExecution = ( expression: string = 'foo bar=123', @@ -48,7 +49,7 @@ const run = async ( describe('Execution', () => { test('can instantiate', () => { const execution = createExecution('foo bar=123'); - expect(execution.params.ast.chain[0].arguments.bar).toEqual([123]); + expect(execution.state.get().ast.chain[0].arguments.bar).toEqual([123]); }); test('initial input is null at creation', () => { @@ -127,6 +128,40 @@ describe('Execution', () => { }); }); + describe('.expression', () => { + test('uses expression passed in to constructor', () => { + const expression = 'add val="1"'; + const executor = createUnitTestExecutor(); + const execution = new Execution({ + executor, + expression, + }); + expect(execution.expression).toBe(expression); + }); + + test('generates expression from AST if not passed to constructor', () => { + const expression = 'add val="1"'; + const executor = createUnitTestExecutor(); + const execution = new Execution({ + ast: parseExpression(expression), + executor, + }); + expect(execution.expression).toBe(expression); + }); + }); + + describe('.contract', () => { + test('is instance of ExecutionContract', () => { + const execution = createExecution('add val=1'); + expect(execution.contract).toBeInstanceOf(ExecutionContract); + }); + + test('execution returns the same expression string', () => { + const execution = createExecution('add val=1'); + expect(execution.expression).toBe(execution.contract.getExpression()); + }); + }); + describe('execution context', () => { test('context.variables is an object', async () => { const { result } = (await run('introspectContext key="variables"')) as any; diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 7f4efafc13de8..2a272e187cffc 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -24,17 +24,25 @@ import { createError } from '../util'; import { Defer } from '../../../kibana_utils/common'; import { RequestAdapter, DataAdapter } from '../../../inspector/common'; import { isExpressionValueError } from '../expression_types/specs/error'; -import { ExpressionAstExpression, ExpressionAstFunction, parse } from '../ast'; +import { + ExpressionAstExpression, + ExpressionAstFunction, + parse, + formatExpression, + parseExpression, +} from '../ast'; import { ExecutionContext, DefaultInspectorAdapters } from './types'; import { getType } from '../expression_types'; import { ArgumentType, ExpressionFunction } from '../expression_functions'; import { getByAlias } from '../util/get_by_alias'; +import { ExecutionContract } from './execution_contract'; export interface ExecutionParams< ExtraContext extends Record = Record > { executor: Executor; - ast: ExpressionAstExpression; + ast?: ExpressionAstExpression; + expression?: string; context?: ExtraContext; } @@ -85,6 +93,19 @@ export class Execution< */ private readonly firstResultFuture = new Defer(); + /** + * Contract is a public representation of `Execution` instances. Contract we + * can return to other plugins for their consumption. + */ + public readonly contract: ExecutionContract< + ExtraContext, + Input, + Output, + InspectorAdapters + > = new ExecutionContract(this); + + public readonly expression: string; + public get result(): Promise { return this.firstResultFuture.promise; } @@ -94,7 +115,17 @@ export class Execution< } constructor(public readonly params: ExecutionParams) { - const { executor, ast } = params; + const { executor } = params; + + if (!params.ast && !params.expression) { + throw new TypeError('Execution params should contain at least .ast or .expression key.'); + } else if (params.ast && params.expression) { + throw new TypeError('Execution params cannot contain both .ast and .expression key.'); + } + + this.expression = params.expression || formatExpression(params.ast!); + const ast = params.ast || parseExpression(this.expression); + this.state = createExecutionContainer({ ...executor.state.get(), state: 'not-started', diff --git a/src/plugins/expressions/common/execution/execution_contract.test.ts b/src/plugins/expressions/common/execution/execution_contract.test.ts new file mode 100644 index 0000000000000..c33f8a1a0f36e --- /dev/null +++ b/src/plugins/expressions/common/execution/execution_contract.test.ts @@ -0,0 +1,140 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Execution } from './execution'; +import { parseExpression } from '../ast'; +import { createUnitTestExecutor } from '../test_helpers'; +import { ExecutionContract } from './execution_contract'; + +const createExecution = ( + expression: string = 'foo bar=123', + context: Record = {} +) => { + const executor = createUnitTestExecutor(); + const execution = new Execution({ + executor, + ast: parseExpression(expression), + context, + }); + return execution; +}; + +describe('ExecutionContract', () => { + test('can instantiate', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract).toBeInstanceOf(ExecutionContract); + }); + + test('can get the AST of expression', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract.getAst()).toMatchObject({ + type: 'expression', + chain: expect.any(Array), + }); + }); + + test('can get expression string', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract.getExpression()).toBe('foo bar=123'); + }); + + test('can cancel execution', () => { + const execution = createExecution('foo bar=123'); + const spy = jest.spyOn(execution, 'cancel'); + const contract = new ExecutionContract(execution); + + expect(spy).toHaveBeenCalledTimes(0); + contract.cancel(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('can get inspector adapters', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract.inspect()).toMatchObject({ + data: expect.any(Object), + requests: expect.any(Object), + }); + }); + + test('can get error result of the expression execution', async () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + execution.start(); + + const result = await contract.getData(); + + expect(result).toMatchObject({ + type: 'error', + }); + }); + + test('can get result of the expression execution', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + execution.start(); + + const result = await contract.getData(); + + expect(result).toBe('bar'); + }); + + describe('isPending', () => { + test('is true if execution has not been started', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + expect(contract.isPending).toBe(true); + }); + + test('is true when execution just started', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + + execution.start(); + + expect(contract.isPending).toBe(true); + }); + + test('is false when execution finished successfully', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + + execution.start(); + await execution.result; + + expect(contract.isPending).toBe(false); + expect(execution.state.get().state).toBe('result'); + }); + + test('is false when execution finished with error', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + + execution.start(); + await execution.result; + execution.state.get().state = 'error'; + + expect(contract.isPending).toBe(false); + expect(execution.state.get().state).toBe('error'); + }); + }); +}); diff --git a/src/plugins/expressions/common/execution/execution_contract.ts b/src/plugins/expressions/common/execution/execution_contract.ts new file mode 100644 index 0000000000000..8c784352b9fdf --- /dev/null +++ b/src/plugins/expressions/common/execution/execution_contract.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Execution } from './execution'; + +/** + * `ExecutionContract` is a wrapper around `Execution` class. It provides the + * same functionality but does not expose Expressions plugin internals. + */ +export class ExecutionContract< + ExtraContext extends Record = Record, + Input = unknown, + Output = unknown, + InspectorAdapters = unknown +> { + public get isPending(): boolean { + const state = this.execution.state.get().state; + const finished = state === 'error' || state === 'result'; + return !finished; + } + + constructor( + protected readonly execution: Execution + ) {} + + /** + * Cancel the execution of the expression. This will set abort signal + * (available in execution context) to aborted state, letting expression + * functions to stop their execution. + */ + cancel = () => { + this.execution.cancel(); + }; + + /** + * Returns the final output of expression, if any error happens still + * wraps that error into `ExpressionValueError` type and returns that. + * This function never throws. + */ + getData = async () => { + try { + return await this.execution.result; + } catch (e) { + return { + type: 'error', + error: { + type: e.type, + message: e.message, + stack: e.stack, + }, + }; + } + }; + + /** + * Get string representation of the expression. Returns the original string + * if execution was started from a string. If execution was started from an + * AST this method returns a string generated from AST. + */ + getExpression = () => { + return this.execution.expression; + }; + + /** + * Get AST used to execute the expression. + */ + getAst = () => this.execution.state.get().ast; + + /** + * Get Inspector adapters provided to all functions of expression through + * execution context. + */ + inspect = () => this.execution.inspectorAdapters; +} diff --git a/src/plugins/expressions/common/execution/index.ts b/src/plugins/expressions/common/execution/index.ts index 2452b0999d23e..fd5c0244438d7 100644 --- a/src/plugins/expressions/common/execution/index.ts +++ b/src/plugins/expressions/common/execution/index.ts @@ -20,3 +20,4 @@ export * from './types'; export * from './container'; export * from './execution'; +export * from './execution_contract'; diff --git a/src/plugins/expressions/common/executor/executor.execution.test.ts b/src/plugins/expressions/common/executor/executor.execution.test.ts new file mode 100644 index 0000000000000..eec7b5c907e29 --- /dev/null +++ b/src/plugins/expressions/common/executor/executor.execution.test.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Executor } from './executor'; +import { parseExpression } from '../ast'; + +// eslint-disable-next-line +const { __getArgs } = require('../execution/execution'); + +jest.mock('../execution/execution', () => { + const mockedModule = { + args: undefined, + __getArgs: () => mockedModule.args, + Execution: function ExecutionMock(...args: any) { + mockedModule.args = args; + }, + }; + + return mockedModule; +}); + +describe('Executor mocked execution tests', () => { + describe('createExecution()', () => { + describe('when execution is created from string', () => { + test('passes expression string to Execution', () => { + const executor = new Executor(); + executor.createExecution('foo bar="baz"'); + + expect(__getArgs()[0].expression).toBe('foo bar="baz"'); + }); + }); + + describe('when execution is created from AST', () => { + test('does not pass in expression string', () => { + const executor = new Executor(); + const ast = parseExpression('foo bar="baz"'); + executor.createExecution(ast); + + expect(__getArgs()[0].expression).toBe(undefined); + }); + }); + }); +}); diff --git a/src/plugins/expressions/common/executor/executor.test.ts b/src/plugins/expressions/common/executor/executor.test.ts index 502728bb66403..4e43cedd18157 100644 --- a/src/plugins/expressions/common/executor/executor.test.ts +++ b/src/plugins/expressions/common/executor/executor.test.ts @@ -130,7 +130,7 @@ describe('Executor', () => { const execution = executor.createExecution('foo bar="baz"'); expect(execution).toBeInstanceOf(Execution); - expect(execution.params.ast.chain[0].function).toBe('foo'); + expect(execution.state.get().ast.chain[0].function).toBe('foo'); }); test('returns Execution object from AST', () => { @@ -139,7 +139,7 @@ describe('Executor', () => { const execution = executor.createExecution(ast); expect(execution).toBeInstanceOf(Execution); - expect(execution.params.ast.chain[0].function).toBe('foo'); + expect(execution.state.get().ast.chain[0].function).toBe('foo'); }); test('Execution inherits context from Executor', () => { diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 5c27201b43fc0..af3662d13de4e 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -22,12 +22,12 @@ import { ExecutorState, ExecutorContainer } from './container'; import { createExecutorContainer } from './container'; import { AnyExpressionFunctionDefinition, ExpressionFunction } from '../expression_functions'; -import { Execution } from '../execution/execution'; +import { Execution, ExecutionParams } from '../execution/execution'; import { IRegistry } from '../types'; import { ExpressionType } from '../expression_types/expression_type'; import { AnyExpressionTypeDefinition } from '../expression_types/types'; import { getType } from '../expression_types'; -import { ExpressionAstExpression, ExpressionAstNode, parseExpression } from '../ast'; +import { ExpressionAstExpression, ExpressionAstNode } from '../ast'; import { typeSpecs } from '../expression_types/specs'; import { functionSpecs } from '../expression_functions/specs'; @@ -186,19 +186,57 @@ export class Executor = Record = Record>( + public createExecution< + ExtraContext extends Record = Record, + Input = unknown, + Output = unknown + >( ast: string | ExpressionAstExpression, context: ExtraContext = {} as ExtraContext - ): Execution { - if (typeof ast === 'string') ast = parseExpression(ast); - const execution = new Execution({ - ast, + ): Execution { + const params: ExecutionParams = { executor: this, context: { ...this.context, ...context, } as Context & ExtraContext, - }); + }; + + if (typeof ast === 'string') params.expression = ast; + else params.ast = ast; + + const execution = new Execution(params); + return execution; } + + public fork(): Executor { + const initialState = this.state.get(); + const fork = new Executor(initialState); + + /** + * Synchronize registry state - make any new types, functions and context + * also available in the forked instance of `Executor`. + */ + this.state.state$.subscribe(({ types, functions, context }) => { + const state = fork.state.get(); + fork.state.set({ + ...state, + types: { + ...types, + ...state.types, + }, + functions: { + ...functions, + ...state.functions, + }, + context: { + ...context, + ...state.context, + }, + }); + }); + + return fork; + } } diff --git a/src/plugins/expressions/common/service/expressions_services.test.ts b/src/plugins/expressions/common/service/expressions_services.test.ts index c9687192481c6..6028e4a4cd1df 100644 --- a/src/plugins/expressions/common/service/expressions_services.test.ts +++ b/src/plugins/expressions/common/service/expressions_services.test.ts @@ -51,4 +51,83 @@ describe('ExpressionsService', () => { expect(typeof expressions.setup().getFunctions().var_set).toBe('object'); }); + + describe('.fork()', () => { + test('returns a new ExpressionsService instance', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork).not.toBe(service); + expect(fork).toBeInstanceOf(ExpressionsService); + }); + + test('fork keeps all types of the origin service', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork.executor.state.get().types).toEqual(service.executor.state.get().types); + }); + + test('fork keeps all functions of the origin service', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork.executor.state.get().functions).toEqual(service.executor.state.get().functions); + }); + + test('fork keeps context of the origin service', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork.executor.state.get().context).toEqual(service.executor.state.get().context); + }); + + test('newly registered functions in origin are also available in fork', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + service.registerFunction({ + name: '__test__', + args: {}, + help: '', + fn: () => {}, + }); + + expect(fork.executor.state.get().functions).toEqual(service.executor.state.get().functions); + }); + + test('newly registered functions in fork are NOT available in origin', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + fork.registerFunction({ + name: '__test__', + args: {}, + help: '', + fn: () => {}, + }); + + expect(Object.values(fork.executor.state.get().functions)).toHaveLength( + Object.values(service.executor.state.get().functions).length + 1 + ); + }); + + test('fork can execute an expression with newly registered function', async () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + service.registerFunction({ + name: '__test__', + args: {}, + help: '', + fn: () => { + return '123'; + }, + }); + + const result = await fork.run('__test__', null); + + expect(result).toBe('123'); + }); + }); }); diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index 8543fbe0fced2..94019aa62841e 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -20,10 +20,16 @@ import { Executor } from '../executor'; import { ExpressionRendererRegistry } from '../expression_renderers'; import { ExpressionAstExpression } from '../ast'; +import { ExecutionContract } from '../execution/execution_contract'; export type ExpressionsServiceSetup = ReturnType; export type ExpressionsServiceStart = ReturnType; +export interface ExpressionServiceParams { + executor?: Executor; + renderers?: ExpressionRendererRegistry; +} + /** * `ExpressionsService` class is used for multiple purposes: * @@ -44,8 +50,16 @@ export type ExpressionsServiceStart = ReturnType; * so that JSDoc appears in developers IDE when they use those `plugins.expressions.registerFunction(`. */ export class ExpressionsService { - public readonly executor = Executor.createWithDefaults(); - public readonly renderers = new ExpressionRendererRegistry(); + public readonly executor: Executor; + public readonly renderers: ExpressionRendererRegistry; + + constructor({ + executor = Executor.createWithDefaults(), + renderers = new ExpressionRendererRegistry(), + }: ExpressionServiceParams = {}) { + this.executor = executor; + this.renderers = renderers; + } /** * Register an expression function, which will be possible to execute as @@ -117,8 +131,45 @@ export class ExpressionsService { context?: ExtraContext ): Promise => this.executor.run(ast, input, context); + /** + * Create a new instance of `ExpressionsService`. The new instance inherits + * all state of the original `ExpressionsService`, including all expression + * types, expression functions and context. Also, all new types and functions + * registered in the original services AFTER the forking event will be + * available in the forked instance. However, all new types and functions + * registered in the forked instances will NOT be available to the original + * service. + */ + public readonly fork = (): ExpressionsService => { + const executor = this.executor.fork(); + const renderers = this.renderers; + const fork = new ExpressionsService({ executor, renderers }); + + return fork; + }; + + /** + * Starts expression execution and immediately returns `ExecutionContract` + * instance that tracks the progress of the execution and can be used to + * interact with the execution. + */ + public readonly execute = < + Input = unknown, + Output = unknown, + ExtraContext extends Record = Record + >( + ast: string | ExpressionAstExpression, + // This any is for legacy reasons. + input: Input = { type: 'null' } as any, + context?: ExtraContext + ): ExecutionContract => { + const execution = this.executor.createExecution(ast, context); + execution.start(input); + return execution.contract; + }; + public setup() { - const { executor, renderers, registerFunction, run } = this; + const { executor, renderers, registerFunction, run, fork } = this; const getFunction = executor.getFunction.bind(executor); const getFunctions = executor.getFunctions.bind(executor); @@ -130,6 +181,7 @@ export class ExpressionsService { const registerType = executor.registerType.bind(executor); return { + fork, getFunction, getFunctions, getRenderer, @@ -144,7 +196,7 @@ export class ExpressionsService { } public start() { - const { executor, renderers, run } = this; + const { execute, executor, renderers, run } = this; const getFunction = executor.getFunction.bind(executor); const getFunctions = executor.getFunctions.bind(executor); @@ -154,6 +206,7 @@ export class ExpressionsService { const getTypes = executor.getTypes.bind(executor); return { + execute, getFunction, getFunctions, getRenderer, diff --git a/src/plugins/expressions/public/execute.test.ts b/src/plugins/expressions/public/execute.test.ts deleted file mode 100644 index 2f2a303bad4c4..0000000000000 --- a/src/plugins/expressions/public/execute.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { execute, ExpressionDataHandler } from './execute'; -import { ExpressionAstExpression, parseExpression } from '../common'; - -jest.mock('./services', () => ({ - getInterpreter: () => { - return { - interpretAst: async (expression: ExpressionAstExpression) => { - return {}; - }, - }; - }, - getNotifications: jest.fn(() => { - return { - toasts: { - addError: jest.fn(() => {}), - }, - }; - }), -})); - -describe('execute helper function', () => { - it('returns ExpressionDataHandler instance', () => { - const response = execute(''); - expect(response).toBeInstanceOf(ExpressionDataHandler); - }); -}); - -describe('ExpressionDataHandler', () => { - const expressionString = ''; - - describe('constructor', () => { - it('accepts expression string', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - }); - - it('accepts expression AST', () => { - const expressionAST = parseExpression(expressionString) as ExpressionAstExpression; - const expressionDataHandler = new ExpressionDataHandler(expressionAST, {}); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - expect(expressionDataHandler.getAst()).toEqual(expressionAST); - }); - - it('allows passing in context', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, { - context: { test: 'hello' }, - }); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - }); - - it('allows passing in search context', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, { - searchContext: { filters: [] }, - }); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - }); - }); - - describe('getData()', () => { - it('returns a promise', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(expressionDataHandler.getData()).toBeInstanceOf(Promise); - }); - - it('promise resolves with data', async () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(await expressionDataHandler.getData()).toEqual({}); - }); - }); - - it('cancel() aborts request', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expressionDataHandler.cancel(); - }); - - it('inspect() returns correct inspector adapters', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(expressionDataHandler.inspect()).toHaveProperty('requests'); - expect(expressionDataHandler.inspect()).toHaveProperty('data'); - }); -}); diff --git a/src/plugins/expressions/public/execute.ts b/src/plugins/expressions/public/execute.ts deleted file mode 100644 index c07fb9ad0549c..0000000000000 --- a/src/plugins/expressions/public/execute.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { DataAdapter, RequestAdapter, Adapters } from '../../inspector/public'; -import { getInterpreter } from './services'; -import { IExpressionLoaderParams } from './types'; -import { - ExpressionAstExpression, - parseExpression, - formatExpression, - ExpressionValue, -} from '../common'; - -/** - * The search context describes a specific context (filters, time range and query) - * that will be applied to the expression for execution. Not every expression will - * be effected by that. You have to use special functions - * that will pick up this search context and forward it to following functions that - * understand it. - */ - -export class ExpressionDataHandler { - private abortController: AbortController; - private expression: string; - private ast: ExpressionAstExpression; - - private inspectorAdapters: Adapters; - private promise: Promise; - - public isPending: boolean = true; - constructor(expression: string | ExpressionAstExpression, params: IExpressionLoaderParams) { - if (typeof expression === 'string') { - this.expression = expression; - this.ast = parseExpression(expression); - } else { - this.ast = expression; - this.expression = formatExpression(this.ast); - } - - this.abortController = new AbortController(); - this.inspectorAdapters = params.inspectorAdapters || this.getActiveInspectorAdapters(); - - const defaultInput = { type: 'null' }; - const interpreter = getInterpreter(); - this.promise = interpreter - .interpretAst(this.ast, params.context || defaultInput, { - search: params.searchContext, - inspectorAdapters: this.inspectorAdapters, - abortSignal: this.abortController.signal, - variables: params.variables, - }) - .then( - (v: ExpressionValue) => { - this.isPending = false; - return v; - }, - () => { - this.isPending = false; - } - ) as Promise; - } - - cancel = () => { - this.abortController.abort(); - }; - - getData = async () => { - try { - return await this.promise; - } catch (e) { - return { - type: 'error', - error: { - type: e.type, - message: e.message, - stack: e.stack, - }, - }; - } - }; - - getExpression = () => { - return this.expression; - }; - - getAst = () => { - return this.ast; - }; - - inspect = () => { - return this.inspectorAdapters; - }; - - /** - * Returns an object of all inspectors for this vis object. - * This must only be called after this.type has properly be initialized, - * since we need to read out data from the the vis type to check which - * inspectors are available. - */ - private getActiveInspectorAdapters = (): Adapters => { - const adapters: Adapters = {}; - - // Add the requests inspector adapters if the vis type explicitly requested it via - // inspectorAdapters.requests: true in its definition or if it's using the courier - // request handler, since that will automatically log its requests. - adapters.requests = new RequestAdapter(); - - // Add the data inspector adapter if the vis type requested it or if the - // vis is using courier, since we know that courier supports logging - // its data. - adapters.data = new DataAdapter(); - - return adapters; - }; -} - -export function execute( - expression: string | ExpressionAstExpression, - params: IExpressionLoaderParams = {} -): ExpressionDataHandler { - return new ExpressionDataHandler(expression, params); -} diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 5f64c11f4efe6..06dd951cd5410 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -37,7 +37,6 @@ export { ReactExpressionRendererProps, ReactExpressionRendererType, } from './react_expression_renderer'; -export { ExpressionDataHandler } from './execute'; export { ExpressionRenderHandler } from './render'; export { AnyExpressionFunctionDefinition, @@ -48,6 +47,7 @@ export { DatatableColumnType, DatatableRow, Execution, + ExecutionContract, ExecutionContainer, ExecutionContext, ExecutionParams, diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index 480434244d6f5..e07a22a5e1d60 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -19,10 +19,12 @@ import { first, skip, toArray } from 'rxjs/operators'; import { loader, ExpressionLoader } from './loader'; -import { ExpressionDataHandler } from './execute'; import { Observable } from 'rxjs'; import { ExpressionAstExpression, parseExpression, IInterpreterRenderHandlers } from '../common'; +// eslint-disable-next-line +const { __getLastExecution } = require('./services'); + const element: HTMLElement = null as any; jest.mock('./services', () => { @@ -33,7 +35,13 @@ jest.mock('./services', () => { }, }, }; - return { + + // eslint-disable-next-line + const service = new (require('../common/service/expressions_services').ExpressionsService as any)(); + + const moduleMock = { + __execution: undefined, + __getLastExecution: () => moduleMock.__execution, getInterpreter: () => { return { interpretAst: async (expression: ExpressionAstExpression) => { @@ -51,17 +59,19 @@ jest.mock('./services', () => { }, }; }), + getExpressionsService: () => service, }; -}); -jest.mock('./execute', () => { - const actual = jest.requireActual('./execute'); - return { - ExpressionDataHandler: jest - .fn() - .mockImplementation((...args) => new actual.ExpressionDataHandler(...args)), - execute: jest.fn().mockReturnValue(actual.execute), + const execute = service.execute; + service.execute = (...args: any) => { + const execution = execute(...args); + jest.spyOn(execution, 'getData'); + jest.spyOn(execution, 'cancel'); + moduleMock.__execution = execution; + return execution; }; + + return moduleMock; }); describe('execute helper function', () => { @@ -97,9 +107,9 @@ describe('ExpressionLoader', () => { }); it('emits on $data when data is available', async () => { - const expressionLoader = new ExpressionLoader(element, expressionString, {}); + const expressionLoader = new ExpressionLoader(element, 'var foo', { variables: { foo: 123 } }); const response = await expressionLoader.data$.pipe(first()).toPromise(); - expect(response).toEqual({ type: 'render', as: 'test' }); + expect(response).toBe(123); }); it('emits on loading$ on initial load and on updates', async () => { @@ -128,94 +138,13 @@ describe('ExpressionLoader', () => { }); it('cancels the previous request when the expression is updated', () => { - const cancelMock = jest.fn(); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData: () => true, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - const expressionLoader = new ExpressionLoader(element, expressionString, {}); - expressionLoader.update('new', {}); - - expect(cancelMock).toHaveBeenCalledTimes(1); - }); - - it('does not send an observable message if a request was aborted', () => { - const cancelMock = jest.fn(); - - const getData = jest - .fn() - .mockResolvedValueOnce({ - type: 'error', - error: { - name: 'AbortError', - }, - }) - .mockResolvedValueOnce({ - type: 'real', - }); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - const expressionLoader = new ExpressionLoader(element, expressionString, {}); - - expect.assertions(2); - expressionLoader.data$.subscribe({ - next(data) { - expect(data).toEqual({ - type: 'real', - }); - }, - error() { - expect(false).toEqual('Should not be called'); - }, - }); - - expressionLoader.update('new expression', {}); - - expect(getData).toHaveBeenCalledTimes(2); - }); - - it('sends an observable error if the data fetching failed', () => { - const cancelMock = jest.fn(); - - const getData = jest.fn().mockResolvedValue('rejected'); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - const expressionLoader = new ExpressionLoader(element, expressionString, {}); - - expect.assertions(2); - expressionLoader.data$.subscribe({ - next(data) { - expect(data).toEqual('Should not be called'); - }, - error(error) { - expect(error.message).toEqual('Could not fetch data'); - }, - }); + const expressionLoader = new ExpressionLoader(element, 'var foo', {}); + const execution = __getLastExecution(); + jest.spyOn(execution, 'cancel'); - expect(getData).toHaveBeenCalledTimes(1); + expect(execution.cancel).toHaveBeenCalledTimes(0); + expressionLoader.update('var bar', {}); + expect(execution.cancel).toHaveBeenCalledTimes(1); }); it('inspect() returns correct inspector adapters', () => { diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 320a8469fe9e3..4600922e076fa 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -20,11 +20,11 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { Adapters, InspectorSession } from '../../inspector/public'; -import { ExpressionDataHandler } from './execute'; import { ExpressionRenderHandler } from './render'; import { IExpressionLoaderParams } from './types'; import { ExpressionAstExpression } from '../common'; -import { getInspector } from './services'; +import { getInspector, getExpressionsService } from './services'; +import { ExecutionContract } from '../common/execution/execution_contract'; type Data = any; @@ -35,7 +35,7 @@ export class ExpressionLoader { events$: ExpressionRenderHandler['events$']; loading$: Observable; - private dataHandler: ExpressionDataHandler | undefined; + private execution: ExecutionContract | undefined; private renderHandler: ExpressionRenderHandler; private dataSubject: Subject; private loadingSubject: Subject; @@ -93,26 +93,26 @@ export class ExpressionLoader { this.dataSubject.complete(); this.loadingSubject.complete(); this.renderHandler.destroy(); - if (this.dataHandler) { - this.dataHandler.cancel(); + if (this.execution) { + this.execution.cancel(); } } cancel() { - if (this.dataHandler) { - this.dataHandler.cancel(); + if (this.execution) { + this.execution.cancel(); } } getExpression(): string | undefined { - if (this.dataHandler) { - return this.dataHandler.getExpression(); + if (this.execution) { + return this.execution.getExpression(); } } getAst(): ExpressionAstExpression | undefined { - if (this.dataHandler) { - return this.dataHandler.getAst(); + if (this.execution) { + return this.execution.getAst(); } } @@ -130,9 +130,7 @@ export class ExpressionLoader { } inspect(): Adapters | undefined { - if (this.dataHandler) { - return this.dataHandler.inspect(); - } + return this.execution ? (this.execution.inspect() as Adapters) : undefined; } update(expression?: string | ExpressionAstExpression, params?: IExpressionLoaderParams): void { @@ -150,15 +148,19 @@ export class ExpressionLoader { expression: string | ExpressionAstExpression, params: IExpressionLoaderParams ): Promise => { - if (this.dataHandler && this.dataHandler.isPending) { - this.dataHandler.cancel(); + if (this.execution && this.execution.isPending) { + this.execution.cancel(); } this.setParams(params); - this.dataHandler = new ExpressionDataHandler(expression, params); - if (!params.inspectorAdapters) params.inspectorAdapters = this.dataHandler.inspect(); - const prevDataHandler = this.dataHandler; + this.execution = getExpressionsService().execute(expression, params.context, { + search: params.searchContext, + variables: params.variables || {}, + inspectorAdapters: params.inspectorAdapters, + }); + if (!params.inspectorAdapters) params.inspectorAdapters = this.execution.inspect() as Adapters; + const prevDataHandler = this.execution; const data = await prevDataHandler.getData(); - if (this.dataHandler !== prevDataHandler) { + if (this.execution !== prevDataHandler) { return; } this.dataSubject.next(data); diff --git a/src/plugins/expressions/public/mocks.tsx b/src/plugins/expressions/public/mocks.tsx index 70760ada83955..eabc4034e7430 100644 --- a/src/plugins/expressions/public/mocks.tsx +++ b/src/plugins/expressions/public/mocks.tsx @@ -31,6 +31,7 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { + fork: jest.fn(), getFunction: jest.fn(), getFunctions: jest.fn(), getRenderer: jest.fn(), @@ -65,7 +66,6 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { return { execute: jest.fn(), - ExpressionDataHandler: jest.fn(), ExpressionLoader: jest.fn(), ExpressionRenderHandler: jest.fn(), getFunction: jest.fn(), diff --git a/src/plugins/expressions/public/plugin.test.ts b/src/plugins/expressions/public/plugin.test.ts index 5437a7d21f338..ac9a2f508e2db 100644 --- a/src/plugins/expressions/public/plugin.test.ts +++ b/src/plugins/expressions/public/plugin.test.ts @@ -19,6 +19,7 @@ import { expressionsPluginMock } from './mocks'; import { add } from '../common/test_helpers/expression_functions/add'; +import { ExpressionsService } from '../common'; describe('ExpressionsPublicPlugin', () => { test('can instantiate from mocks', async () => { @@ -27,6 +28,13 @@ describe('ExpressionsPublicPlugin', () => { }); describe('setup contract', () => { + test('.fork() method returns ExpressionsService', async () => { + const { setup } = await expressionsPluginMock.createPlugin(); + const fork = setup.fork(); + + expect(fork).toBeInstanceOf(ExpressionsService); + }); + describe('.registerFunction()', () => { test('can register a function', async () => { const { setup } = await expressionsPluginMock.createPlugin(); @@ -65,6 +73,15 @@ describe('ExpressionsPublicPlugin', () => { } `); }); + + test('"kibana" function return value of type "kibana_context"', async () => { + const { doStart } = await expressionsPluginMock.createPlugin(); + const start = await doStart(); + const execution = start.execute('kibana'); + const result = await execution.getData(); + + expect((result as any).type).toBe('kibana_context'); + }); }); }); }); diff --git a/src/plugins/expressions/public/plugin.ts b/src/plugins/expressions/public/plugin.ts index 6799b1590f252..aac429b365c48 100644 --- a/src/plugins/expressions/public/plugin.ts +++ b/src/plugins/expressions/public/plugin.ts @@ -36,11 +36,11 @@ import { setInterpreter, setRenderersRegistry, setNotifications, + setExpressionsService, } from './services'; import { kibanaContext as kibanaContextFunction } from './expression_functions/kibana_context'; import { ReactExpressionRenderer } from './react_expression_renderer'; import { ExpressionLoader, loader } from './loader'; -import { ExpressionDataHandler, execute } from './execute'; import { render, ExpressionRenderHandler } from './render'; export interface ExpressionsSetupDeps { @@ -92,8 +92,6 @@ export interface ExpressionsSetup extends ExpressionsServiceSetup { } export interface ExpressionsStart extends ExpressionsServiceStart { - execute: typeof execute; - ExpressionDataHandler: typeof ExpressionDataHandler; ExpressionLoader: typeof ExpressionLoader; ExpressionRenderHandler: typeof ExpressionRenderHandler; loader: typeof loader; @@ -118,6 +116,7 @@ export class ExpressionsPublicPlugin executor.registerFunction(kibanaContextFunction()); setRenderersRegistry(renderers); + setExpressionsService(this.expressions); const expressionsSetup = expressions.setup(); @@ -180,8 +179,6 @@ export class ExpressionsPublicPlugin return { ...expressionsStart, - execute, - ExpressionDataHandler, ExpressionLoader, ExpressionRenderHandler, loader, diff --git a/src/plugins/expressions/public/services.ts b/src/plugins/expressions/public/services.ts index 75ec4826ea45a..4fdff9b151ac2 100644 --- a/src/plugins/expressions/public/services.ts +++ b/src/plugins/expressions/public/services.ts @@ -22,6 +22,7 @@ import { createKibanaUtilsCore, createGetterSetter } from '../../kibana_utils/pu import { ExpressionInterpreter } from './types'; import { Start as IInspector } from '../../inspector/public'; import { ExpressionsSetup } from './plugin'; +import { ExpressionsService } from '../common'; export const { getCoreStart, setCoreStart, savedObjects } = createKibanaUtilsCore(); @@ -37,3 +38,7 @@ export const [getNotifications, setNotifications] = createGetterSetter('Renderers registry'); + +export const [getExpressionsService, setExpressionsService] = createGetterSetter< + ExpressionsService +>('ExpressionsService'); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts index 4b17d8517328b..4cf74d991ceb9 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts @@ -181,4 +181,10 @@ describe('kbnUrlTracker', () => { `"/app/test#/start?state1=(key1:abc)&state2=(key2:def)"` ); }); + + test('set url to storage when setActiveUrl was called', () => { + createTracker(); + urlTracker.setActiveUrl('/deep/path/4'); + expect(storage.getItem('storageKey')).toEqual('#/deep/path/4'); + }); }); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts index 6f3f64ea7b941..2edd135c184ec 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts @@ -36,6 +36,7 @@ export interface KbnUrlTracker { * Unregistering the url tracker. This won't reset the current state of the nav link */ stop: () => void; + setActiveUrl: (newUrl: string) => void; } /** @@ -130,20 +131,24 @@ export function createKbnUrlTracker({ } } + function setActiveUrl(newUrl: string) { + const urlWithHashes = baseUrl + '#' + newUrl; + let urlWithStates = ''; + try { + urlWithStates = unhashUrl(urlWithHashes); + } catch (e) { + toastNotifications.addDanger(e.message); + } + + activeUrl = getActiveSubUrl(urlWithStates || urlWithHashes); + storageInstance.setItem(storageKey, activeUrl); + } + function onMountApp() { unsubscribe(); // track current hash when within app unsubscribeURLHistory = historyInstance.listen(location => { - const urlWithHashes = baseUrl + '#' + location.pathname + location.search; - let urlWithStates = ''; - try { - urlWithStates = unhashUrl(urlWithHashes); - } catch (e) { - toastNotifications.addDanger(e.message); - } - - activeUrl = getActiveSubUrl(urlWithStates || urlWithHashes); - storageInstance.setItem(storageKey, activeUrl); + setActiveUrl(location.pathname + location.search); }); } @@ -188,5 +193,6 @@ export function createKbnUrlTracker({ stop() { unsubscribe(); }, + setActiveUrl, }; } diff --git a/src/plugins/ui_actions/README.md b/src/plugins/ui_actions/README.md index 02942b7d5b406..c4e02b551c884 100644 --- a/src/plugins/ui_actions/README.md +++ b/src/plugins/ui_actions/README.md @@ -1,10 +1,10 @@ # UI Actions -An API for: - - creating custom functionality (`actions`) - - creating custom user interaction events (`triggers`) - - attaching and detaching `actions` to `triggers`. - - emitting `trigger` events - - executing `actions` attached to a given `trigger`. - - exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +An API for: +- creating custom functionality (`actions`) +- creating custom user interaction events (`triggers`) +- attaching and detaching `actions` to `triggers`. +- emitting `trigger` events +- executing `actions` attached to a given `trigger`. +- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. diff --git a/src/plugins/ui_actions/public/triggers/incompatible_action_error.ts b/src/plugins/ui_actions/public/actions/incompatible_action_error.ts similarity index 100% rename from src/plugins/ui_actions/public/triggers/incompatible_action_error.ts rename to src/plugins/ui_actions/public/actions/incompatible_action_error.ts diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index feb9a8de62eb3..64bfd368e3dfa 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -17,5 +17,6 @@ * under the License. */ -export { Action } from './action'; -export { createAction } from './create_action'; +export * from './action'; +export * from './create_action'; +export * from './incompatible_action_error'; diff --git a/src/plugins/ui_actions/public/api.ts b/src/plugins/ui_actions/public/api.ts deleted file mode 100644 index 9a6fd04b14e10..0000000000000 --- a/src/plugins/ui_actions/public/api.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - UiActionsApi, - UiActionsDependenciesInternal, - UiActionsDependencies, - UiActionsApiPure, -} from './types'; -import { attachAction } from './triggers/attach_action'; -import { detachAction } from './triggers/detach_action'; -import { executeTriggerActions } from './triggers/execute_trigger_actions'; -import { getTrigger } from './triggers/get_trigger'; -import { getTriggerActions } from './triggers/get_trigger_actions'; -import { getTriggerCompatibleActions } from './triggers/get_trigger_compatible_actions'; -import { registerAction } from './actions/register_action'; -import { registerTrigger } from './triggers/register_trigger'; - -export const pureApi: UiActionsApiPure = { - attachAction, - detachAction, - executeTriggerActions, - getTrigger, - getTriggerActions, - getTriggerCompatibleActions, - registerAction, - registerTrigger, -}; - -export const createApi = (deps: UiActionsDependencies) => { - const partialApi: Partial = {}; - const depsInternal: UiActionsDependenciesInternal = { ...deps, api: partialApi }; - for (const [key, fn] of Object.entries(pureApi)) { - (partialApi as any)[key] = fn(depsInternal); - } - Object.freeze(partialApi); - const api = partialApi as UiActionsApi; - return { api, depsInternal }; -}; diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 7b80a8ea830c0..3dce2c1f4c257 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -101,10 +101,9 @@ function convertPanelActionToContextMenuItem({ }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { name: action.MenuItem - ? // Cast to `any` because `name` typed to string. - (React.createElement(uiToReactComponent(action.MenuItem), { + ? React.createElement(uiToReactComponent(action.MenuItem), { context: actionContext, - }) as any) + }) : action.getDisplayName(actionContext), icon: action.getIconType(actionContext), panel: _.get(action, 'childContextMenuPanel.id'), diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 427dbecb7aee4..83a08b11fa4c2 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -19,19 +19,30 @@ import { PluginInitializerContext } from '../../../core/public'; import { UiActionsPlugin } from './plugin'; +import { UiActionsService } from './service'; export function plugin(initializerContext: PluginInitializerContext) { return new UiActionsPlugin(initializerContext); } export { UiActionsSetup, UiActionsStart } from './plugin'; -export { - Action, - Trigger, - UiActionsApi, - GetActionsCompatibleWithTrigger, - ExecuteTriggerActions, -} from './types'; -export { createAction } from './actions'; +export { UiActionsServiceParams, UiActionsService } from './service'; +export { Action, createAction, IncompatibleActionError } from './actions'; export { buildContextMenuForActions } from './context_menu'; -export { IncompatibleActionError } from './triggers'; +export { Trigger } from './triggers'; + +/** + * @deprecated + * + * Use `UiActionsStart['getTriggerCompatibleActions']` or + * `UiActionsService['getTriggerCompatibleActions']` instead. + */ +export type GetActionsCompatibleWithTrigger = UiActionsService['getTriggerCompatibleActions']; + +/** + * @deprecated + * + * Use `UiActionsStart['executeTriggerActions']` or + * `UiActionsService['executeTriggerActions']` instead. + */ +export type ExecuteTriggerActions = UiActionsService['executeTriggerActions']; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index 273c5dcf83e81..d2ba901f1040d 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -17,9 +17,9 @@ * under the License. */ +import { CoreSetup, CoreStart } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '.'; import { plugin as pluginInitializer } from '.'; -// eslint-disable-next-line import { coreMock } from '../../../core/public/mocks'; export type Setup = jest.Mocked; @@ -45,17 +45,20 @@ const createStartContract = (): Start => { getTrigger: jest.fn(), getTriggerActions: jest.fn((id: string) => []), getTriggerCompatibleActions: jest.fn(), + clear: jest.fn(), + fork: jest.fn(), }; return startContract; }; -const createPlugin = async () => { +const createPlugin = ( + coreSetup: CoreSetup = coreMock.createSetup(), + coreStart: CoreStart = coreMock.createStart() +) => { const pluginInitializerContext = coreMock.createPluginInitializerContext(); - const coreSetup = coreMock.createSetup(); - const coreStart = coreMock.createStart(); const plugin = pluginInitializer(pluginInitializerContext); - const setup = await plugin.setup(coreSetup); + const setup = plugin.setup(coreSetup); return { pluginInitializerContext, @@ -63,7 +66,7 @@ const createPlugin = async () => { coreStart, plugin, setup, - doStart: async () => await plugin.start(coreStart), + doStart: (anotherCoreStart: CoreStart = coreStart) => plugin.start(anotherCoreStart), }; }; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 12a9b7cbc6526..0874803db7d37 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -17,43 +17,30 @@ * under the License. */ -import { CoreStart, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/public'; -import { UiActionsApi, ActionRegistry, TriggerRegistry } from './types'; -import { createApi } from './api'; - -export interface UiActionsSetup { - attachAction: UiActionsApi['attachAction']; - detachAction: UiActionsApi['detachAction']; - registerAction: UiActionsApi['registerAction']; - registerTrigger: UiActionsApi['registerTrigger']; -} +import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { UiActionsService } from './service'; + +export type UiActionsSetup = Pick< + UiActionsService, + 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' +>; -export type UiActionsStart = UiActionsApi; +export type UiActionsStart = PublicMethodsOf; export class UiActionsPlugin implements Plugin { - private readonly triggers: TriggerRegistry = new Map(); - private readonly actions: ActionRegistry = new Map(); - private api!: UiActionsApi; + private readonly service = new UiActionsService(); - constructor(initializerContext: PluginInitializerContext) { - this.api = createApi({ triggers: this.triggers, actions: this.actions }).api; - } + constructor(initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup): UiActionsSetup { - return { - registerTrigger: this.api.registerTrigger, - registerAction: this.api.registerAction, - attachAction: this.api.attachAction, - detachAction: this.api.detachAction, - }; + return this.service; } public start(core: CoreStart): UiActionsStart { - return this.api; + return this.service; } public stop() { - this.actions.clear(); - this.triggers.clear(); + this.service.clear(); } } diff --git a/src/plugins/ui_actions/public/service/index.ts b/src/plugins/ui_actions/public/service/index.ts new file mode 100644 index 0000000000000..3998a2ea255cb --- /dev/null +++ b/src/plugins/ui_actions/public/service/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './ui_actions_service'; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts new file mode 100644 index 0000000000000..2bbe106c49a25 --- /dev/null +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -0,0 +1,465 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiActionsService } from './ui_actions_service'; +import { Action } from '../actions'; +import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples'; +import { ActionRegistry, TriggerRegistry } from '../types'; +import { Trigger } from '../triggers'; + +const testAction1: Action = { + id: 'action1', + order: 1, + type: 'type1', + execute: async () => {}, + getDisplayName: () => 'test1', + getIconType: () => '', + isCompatible: async () => true, +}; + +const testAction2: Action = { + id: 'action2', + order: 2, + type: 'type2', + execute: async () => {}, + getDisplayName: () => 'test2', + getIconType: () => '', + isCompatible: async () => true, +}; + +describe('UiActionsService', () => { + test('can instantiate', () => { + new UiActionsService(); + }); + + describe('.registerTrigger()', () => { + test('can register a trigger', () => { + const service = new UiActionsService(); + service.registerTrigger({ + id: 'test', + }); + }); + }); + + describe('.getTrigger()', () => { + test('can get Trigger from registry', () => { + const service = new UiActionsService(); + service.registerTrigger({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + + const trigger = service.getTrigger('bar'); + + expect(trigger).toEqual({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + }); + + test('throws if trigger does not exist', () => { + const service = new UiActionsService(); + + expect(() => service.getTrigger('foo')).toThrowError( + 'Trigger [triggerId = foo] does not exist.' + ); + }); + }); + + describe('.registerAction()', () => { + test('can register an action', () => { + const service = new UiActionsService(); + service.registerAction({ + id: 'test', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + type: 'test', + }); + }); + }); + + describe('.getTriggerActions()', () => { + const action1: Action = { + id: 'action1', + order: 1, + type: 'type1', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + }; + const action2: Action = { + id: 'action2', + order: 2, + type: 'type2', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + }; + + test('returns actions set on trigger', () => { + const service = new UiActionsService(); + + service.registerAction(action1); + service.registerAction(action2); + service.registerTrigger({ + description: 'foo', + id: 'trigger', + title: 'baz', + }); + + const list0 = service.getTriggerActions('trigger'); + + expect(list0).toHaveLength(0); + + service.attachAction('trigger', 'action1'); + const list1 = service.getTriggerActions('trigger'); + + expect(list1).toHaveLength(1); + expect(list1).toEqual([action1]); + + service.attachAction('trigger', 'action2'); + const list2 = service.getTriggerActions('trigger'); + + expect(list2).toHaveLength(2); + expect(!!list2.find(({ id }: any) => id === 'action1')).toBe(true); + expect(!!list2.find(({ id }: any) => id === 'action2')).toBe(true); + }); + }); + + describe('.getTriggerCompatibleActions()', () => { + test('can register and get actions', async () => { + const actions: ActionRegistry = new Map(); + const service = new UiActionsService({ actions }); + const helloWorldAction = createHelloWorldAction({} as any); + const length = actions.size; + + service.registerAction(helloWorldAction); + + expect(actions.size - length).toBe(1); + expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); + }); + + test('getTriggerCompatibleActions returns attached actions', async () => { + const service = new UiActionsService(); + const helloWorldAction = createHelloWorldAction({} as any); + + service.registerAction(helloWorldAction); + + const testTrigger: Trigger = { + id: 'MY-TRIGGER', + title: 'My trigger', + }; + service.registerTrigger(testTrigger); + service.attachAction('MY-TRIGGER', helloWorldAction.id); + + const compatibleActions = await service.getTriggerCompatibleActions('MY-TRIGGER', {}); + + expect(compatibleActions.length).toBe(1); + expect(compatibleActions[0].id).toBe(helloWorldAction.id); + }); + + test('filters out actions not applicable based on the context', async () => { + const service = new UiActionsService(); + const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => { + return context.accept; + }); + + service.registerAction(restrictedAction); + + const testTrigger: Trigger = { + id: 'MY-TRIGGER', + title: 'My trigger', + }; + + service.registerTrigger(testTrigger); + service.attachAction(testTrigger.id, restrictedAction.id); + + const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { + accept: true, + }); + + expect(compatibleActions1.length).toBe(1); + + const compatibleActions2 = await service.getTriggerCompatibleActions(testTrigger.id, { + accept: false, + }); + + expect(compatibleActions2.length).toBe(0); + }); + + test(`throws an error with an invalid trigger ID`, async () => { + const service = new UiActionsService(); + + await expect(service.getTriggerCompatibleActions('I do not exist', {})).rejects.toMatchObject( + new Error('Trigger [triggerId = I do not exist] does not exist.') + ); + }); + + test('returns empty list if trigger not attached to any action', async () => { + const service = new UiActionsService(); + const testTrigger: Trigger = { + id: '123', + title: '123', + }; + service.registerTrigger(testTrigger); + + const actions = await service.getTriggerCompatibleActions(testTrigger.id, {}); + + expect(actions).toEqual([]); + }); + }); + + describe('.fork()', () => { + test('returns a new instance of the service', () => { + const service1 = new UiActionsService(); + const service2 = service1.fork(); + + expect(service1).not.toBe(service2); + expect(service2).toBeInstanceOf(UiActionsService); + }); + + test('triggers registered in original service are available in original an forked services', () => { + const service1 = new UiActionsService(); + service1.registerTrigger({ + id: 'foo', + }); + const service2 = service1.fork(); + + const trigger1 = service1.getTrigger('foo'); + const trigger2 = service2.getTrigger('foo'); + + expect(trigger1.id).toBe('foo'); + expect(trigger2.id).toBe('foo'); + }); + + test('triggers registered in forked service are not available in original service', () => { + const service1 = new UiActionsService(); + const service2 = service1.fork(); + + service2.registerTrigger({ + id: 'foo', + }); + + expect(() => service1.getTrigger('foo')).toThrowErrorMatchingInlineSnapshot( + `"Trigger [triggerId = foo] does not exist."` + ); + + const trigger2 = service2.getTrigger('foo'); + expect(trigger2.id).toBe('foo'); + }); + + test('forked service preserves trigger-to-actions mapping', () => { + const service1 = new UiActionsService(); + + service1.registerTrigger({ + id: 'foo', + }); + service1.registerAction(testAction1); + service1.attachAction('foo', testAction1.id); + + const service2 = service1.fork(); + + const actions1 = service1.getTriggerActions('foo'); + const actions2 = service2.getTriggerActions('foo'); + + expect(actions1).toHaveLength(1); + expect(actions2).toHaveLength(1); + expect(actions1[0].id).toBe(testAction1.id); + expect(actions2[0].id).toBe(testAction1.id); + }); + + test('new attachments in fork do not appear in original service', () => { + const service1 = new UiActionsService(); + + service1.registerTrigger({ + id: 'foo', + }); + service1.registerAction(testAction1); + service1.registerAction(testAction2); + service1.attachAction('foo', testAction1.id); + + const service2 = service1.fork(); + + expect(service1.getTriggerActions('foo')).toHaveLength(1); + expect(service2.getTriggerActions('foo')).toHaveLength(1); + + service2.attachAction('foo', testAction2.id); + + expect(service1.getTriggerActions('foo')).toHaveLength(1); + expect(service2.getTriggerActions('foo')).toHaveLength(2); + }); + + test('new attachments in original service do not appear in fork', () => { + const service1 = new UiActionsService(); + + service1.registerTrigger({ + id: 'foo', + }); + service1.registerAction(testAction1); + service1.registerAction(testAction2); + service1.attachAction('foo', testAction1.id); + + const service2 = service1.fork(); + + expect(service1.getTriggerActions('foo')).toHaveLength(1); + expect(service2.getTriggerActions('foo')).toHaveLength(1); + + service1.attachAction('foo', testAction2.id); + + expect(service1.getTriggerActions('foo')).toHaveLength(2); + expect(service2.getTriggerActions('foo')).toHaveLength(1); + }); + }); + + describe('registries', () => { + const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; + + test('can register trigger', () => { + const triggers: TriggerRegistry = new Map(); + const service = new UiActionsService({ triggers }); + + service.registerTrigger({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + + expect(triggers.get('bar')).toEqual({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + }); + + test('can register action', () => { + const actions: ActionRegistry = new Map(); + const service = new UiActionsService({ actions }); + + service.registerAction({ + id: HELLO_WORLD_ACTION_ID, + order: 13, + } as any); + + expect(actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({ + id: HELLO_WORLD_ACTION_ID, + order: 13, + }); + }); + + test('can attach an action to a trigger', () => { + const service = new UiActionsService(); + + const trigger: Trigger = { + id: 'MY-TRIGGER', + }; + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerTrigger(trigger); + service.registerAction(action); + service.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); + + const actions = service.getTriggerActions(trigger.id); + + expect(actions.length).toBe(1); + expect(actions[0].id).toBe(HELLO_WORLD_ACTION_ID); + }); + + test('can detach an action to a trigger', () => { + const service = new UiActionsService(); + + const trigger: Trigger = { + id: 'MY-TRIGGER', + }; + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerTrigger(trigger); + service.registerAction(action); + service.attachAction(trigger.id, HELLO_WORLD_ACTION_ID); + service.detachAction(trigger.id, HELLO_WORLD_ACTION_ID); + + const actions2 = service.getTriggerActions(trigger.id); + expect(actions2).toEqual([]); + }); + + test('detaching an invalid action from a trigger throws an error', async () => { + const service = new UiActionsService(); + + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerAction(action); + expect(() => service.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( + 'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].' + ); + }); + + test('attaching an invalid action to a trigger throws an error', async () => { + const service = new UiActionsService(); + + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerAction(action); + expect(() => service.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( + 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].' + ); + }); + + test('cannot register another action with the same ID', async () => { + const service = new UiActionsService(); + + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerAction(action); + expect(() => service.registerAction(action)).toThrowError( + 'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.' + ); + }); + + test('cannot register another trigger with the same ID', async () => { + const service = new UiActionsService(); + + const trigger = { id: 'MY-TRIGGER' } as any; + + service.registerTrigger(trigger); + expect(() => service.registerTrigger(trigger)).toThrowError( + 'Trigger [trigger.id = MY-TRIGGER] already registered.' + ); + }); + }); +}); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts new file mode 100644 index 0000000000000..a62d2aa356435 --- /dev/null +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -0,0 +1,194 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TriggerRegistry, ActionRegistry, TriggerToActionsRegistry } from '../types'; +import { Action } from '../actions'; +import { Trigger } from '../triggers/trigger'; +import { buildContextMenuForActions, openContextMenu } from '../context_menu'; + +export interface UiActionsServiceParams { + readonly triggers?: TriggerRegistry; + readonly actions?: ActionRegistry; + + /** + * A 1-to-N mapping from `Trigger` to zero or more `Action`. + */ + readonly triggerToActions?: TriggerToActionsRegistry; +} + +export class UiActionsService { + protected readonly triggers: TriggerRegistry; + protected readonly actions: ActionRegistry; + protected readonly triggerToActions: TriggerToActionsRegistry; + + constructor({ + triggers = new Map(), + actions = new Map(), + triggerToActions = new Map(), + }: UiActionsServiceParams = {}) { + this.triggers = triggers; + this.actions = actions; + this.triggerToActions = triggerToActions; + } + + public readonly registerTrigger = (trigger: Trigger) => { + if (this.triggers.has(trigger.id)) { + throw new Error(`Trigger [trigger.id = ${trigger.id}] already registered.`); + } + + this.triggers.set(trigger.id, trigger); + this.triggerToActions.set(trigger.id, []); + }; + + public readonly getTrigger = (id: string) => { + const trigger = this.triggers.get(id); + + if (!trigger) { + throw new Error(`Trigger [triggerId = ${id}] does not exist.`); + } + + return trigger; + }; + + public readonly registerAction = (action: Action) => { + if (this.actions.has(action.id)) { + throw new Error(`Action [action.id = ${action.id}] already registered.`); + } + + this.actions.set(action.id, action); + }; + + public readonly attachAction = (triggerId: string, actionId: string): void => { + const trigger = this.triggers.get(triggerId); + + if (!trigger) { + throw new Error( + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` + ); + } + + const actionIds = this.triggerToActions.get(triggerId); + + if (!actionIds!.find(id => id === actionId)) { + this.triggerToActions.set(triggerId, [...actionIds!, actionId]); + } + }; + + public readonly detachAction = (triggerId: string, actionId: string) => { + const trigger = this.triggers.get(triggerId); + + if (!trigger) { + throw new Error( + `No trigger [triggerId = ${triggerId}] exists, for detaching action [actionId = ${actionId}].` + ); + } + + const actionIds = this.triggerToActions.get(triggerId); + + this.triggerToActions.set( + triggerId, + actionIds!.filter(id => id !== actionId) + ); + }; + + public readonly getTriggerActions = (triggerId: string) => { + // This line checks if trigger exists, otherwise throws. + this.getTrigger!(triggerId); + + const actionIds = this.triggerToActions.get(triggerId); + const actions = actionIds! + .map(actionId => this.actions.get(actionId)) + .filter(Boolean) as Action[]; + + return actions; + }; + + public readonly getTriggerCompatibleActions = async (triggerId: string, context: C) => { + const actions = this.getTriggerActions!(triggerId); + const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context))); + return actions.reduce( + (acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc), + [] + ); + }; + + private async executeSingleAction(action: Action, actionContext: A) { + const href = action.getHref && action.getHref(actionContext); + + if (href) { + window.location.href = href; + return; + } + + await action.execute(actionContext); + } + + private async executeMultipleActions(actions: Action[], actionContext: C) { + const panel = await buildContextMenuForActions({ + actions, + actionContext, + closeMenu: () => session.close(), + }); + const session = openContextMenu([panel]); + } + + public readonly executeTriggerActions = async (triggerId: string, actionContext: C) => { + const actions = await this.getTriggerCompatibleActions!(triggerId, actionContext); + + if (!actions.length) { + throw new Error( + `No compatible actions found to execute for trigger [triggerId = ${triggerId}].` + ); + } + + if (actions.length === 1) { + await this.executeSingleAction(actions[0], actionContext); + return; + } + + await this.executeMultipleActions(actions, actionContext); + }; + + /** + * Removes all registered triggers and actions. + */ + public readonly clear = () => { + this.actions.clear(); + this.triggers.clear(); + this.triggerToActions.clear(); + }; + + /** + * "Fork" a separate instance of `UiActionsService` that inherits all existing + * triggers and actions, but going forward all new triggers and actions added + * to this instance of `UiActionsService` are only available within this instance. + */ + public readonly fork = (): UiActionsService => { + const triggers: TriggerRegistry = new Map(); + const actions: ActionRegistry = new Map(); + const triggerToActions: TriggerToActionsRegistry = new Map(); + + for (const [key, value] of this.triggers.entries()) triggers.set(key, value); + for (const [key, value] of this.actions.entries()) actions.set(key, value); + for (const [key, value] of this.triggerToActions.entries()) + triggerToActions.set(key, [...value]); + + return new UiActionsService({ triggers, actions, triggerToActions }); + }; +} diff --git a/src/plugins/ui_actions/public/tests/README.md b/src/plugins/ui_actions/public/tests/README.md new file mode 100644 index 0000000000000..8ea3a89e7c120 --- /dev/null +++ b/src/plugins/ui_actions/public/tests/README.md @@ -0,0 +1,2 @@ +This folder contains integration tests for the `ui_actions` plugin and +`test_samples` that other plugins can use in their tests. diff --git a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts similarity index 86% rename from src/plugins/ui_actions/public/triggers/execute_trigger_actions.test.ts rename to src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 7f2506daee268..f8c196a623499 100644 --- a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -19,7 +19,8 @@ import { Action, createAction } from '../actions'; import { openContextMenu } from '../context_menu'; -import { UiActionsTestPluginReturn, uiActionsTestPlugin } from '../tests/test_plugin'; +import { uiActionsPluginMock } from '../mocks'; +import { Trigger } from '../triggers'; jest.mock('../context_menu'); @@ -37,14 +38,14 @@ function createTestAction(id: string, checkCompatibility: (context: A) => boo }); } -let uiActions: UiActionsTestPluginReturn; +let uiActions: ReturnType; const reset = () => { - uiActions = uiActionsTestPlugin(); + uiActions = uiActionsPluginMock.createPlugin(); uiActions.setup.registerTrigger({ id: CONTACT_USER_TRIGGER, - actionIds: ['SEND_MESSAGE_ACTION'], }); + uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'SEND_MESSAGE_ACTION'); executeFn.mockReset(); openContextMenuSpy.mockReset(); @@ -53,14 +54,15 @@ beforeEach(reset); test('executes a single action mapped to a trigger', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test1'], }; const action = createTestAction('test1', () => true); + setup.registerTrigger(trigger); setup.registerAction(action); + setup.attachAction(trigger.id, 'test1'); const context = {}; const start = doStart(); @@ -72,12 +74,13 @@ test('executes a single action mapped to a trigger', async () => { test('throws an error if there are no compatible actions to execute', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['testaction'], }; + setup.registerTrigger(trigger); + setup.attachAction(trigger.id, 'testaction'); const context = {}; const start = doStart(); @@ -88,14 +91,15 @@ test('throws an error if there are no compatible actions to execute', async () = test('does not execute an incompatible action', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test1'], }; const action = createTestAction<{ name: string }>('test1', ({ name }) => name === 'executeme'); + setup.registerTrigger(trigger); setup.registerAction(action); + setup.attachAction(trigger.id, 'test1'); const start = doStart(); const context = { @@ -108,16 +112,18 @@ test('does not execute an incompatible action', async () => { test('shows a context menu when more than one action is mapped to a trigger', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test1', 'test2'], }; const action1 = createTestAction('test1', () => true); const action2 = createTestAction('test2', () => true); + setup.registerTrigger(trigger); setup.registerAction(action1); setup.registerAction(action2); + setup.attachAction(trigger.id, 'test1'); + setup.attachAction(trigger.id, 'test2'); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -134,7 +140,6 @@ test('passes whole action context to isCompatible()', async () => { const trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test'], }; const action = createTestAction<{ foo: string }>('test', ({ foo }) => { expect(foo).toEqual('bar'); @@ -143,6 +148,8 @@ test('passes whole action context to isCompatible()', async () => { setup.registerTrigger(trigger); setup.registerAction(action); + setup.attachAction(trigger.id, 'test'); + const start = doStart(); const context = { foo: 'bar' }; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts similarity index 93% rename from src/plugins/ui_actions/public/triggers/get_trigger_actions.test.ts rename to src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index aef4114ffb4c6..e91acd4c7151b 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -18,7 +18,7 @@ */ import { Action } from '../actions'; -import { uiActionsTestPlugin } from '../tests/test_plugin'; +import { uiActionsPluginMock } from '../mocks'; const action1: Action = { id: 'action1', @@ -32,11 +32,10 @@ const action2: Action = { } as any; test('returns actions set on trigger', () => { - const { setup, doStart } = uiActionsTestPlugin(); + const { setup, doStart } = uiActionsPluginMock.createPlugin(); setup.registerAction(action1); setup.registerAction(action2); setup.registerTrigger({ - actionIds: [], description: 'foo', id: 'trigger', title: 'baz', diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts similarity index 82% rename from src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.test.ts rename to src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index f4d2ea48ff6b9..a966003973aba 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -18,35 +18,30 @@ */ import { createSayHelloAction } from '../tests/test_samples/say_hello_action'; -import { UiActionsTestPluginReturn, uiActionsTestPlugin } from '../tests/test_plugin'; +import { uiActionsPluginMock } from '../mocks'; import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples'; import { Action } from '../actions'; +import { Trigger } from '../triggers'; let action: Action<{ name: string }>; -let uiActions: UiActionsTestPluginReturn; +let uiActions: ReturnType; beforeEach(() => { - uiActions = uiActionsTestPlugin(); + uiActions = uiActionsPluginMock.createPlugin(); action = createSayHelloAction({} as any); uiActions.setup.registerAction(action); uiActions.setup.registerTrigger({ id: 'trigger', title: 'trigger', - actionIds: [], }); uiActions.setup.attachAction('trigger', action.id); }); -test('can register and get actions', async () => { - const { setup, plugin } = uiActions; +test('can register action', async () => { + const { setup } = uiActions; const helloWorldAction = createHelloWorldAction({} as any); - const length = (plugin as any).actions.size; setup.registerAction(helloWorldAction); - - expect((plugin as any).actions.size - length).toBe(1); - expect((plugin as any).actions.get(action.id)).toBe(action); - expect((plugin as any).actions.get(helloWorldAction.id)).toBe(helloWorldAction); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -55,10 +50,9 @@ test('getTriggerCompatibleActions returns attached actions', async () => { setup.registerAction(helloWorldAction); - const testTrigger = { + const testTrigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: [], }; setup.registerTrigger(testTrigger); setup.attachAction('MY-TRIGGER', helloWorldAction.id); @@ -78,13 +72,13 @@ test('filters out actions not applicable based on the context', async () => { setup.registerAction(restrictedAction); - const testTrigger = { + const testTrigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: [restrictedAction.id], }; setup.registerTrigger(testTrigger); + setup.attachAction(testTrigger.id, restrictedAction.id); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); @@ -107,10 +101,9 @@ test(`throws an error with an invalid trigger ID`, async () => { test(`with a trigger mapping that maps to an non-existing action returns empty list`, async () => { const { setup, doStart } = uiActions; - const testTrigger = { + const testTrigger: Trigger = { id: '123', title: '123', - actionIds: ['I do not exist'], }; setup.registerTrigger(testTrigger); diff --git a/src/plugins/ui_actions/public/tests/index.ts b/src/plugins/ui_actions/public/tests/index.ts index 6f5610a7beb64..dbc34abb8acb4 100644 --- a/src/plugins/ui_actions/public/tests/index.ts +++ b/src/plugins/ui_actions/public/tests/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { uiActionsTestPlugin } from './test_plugin'; +export * from './test_samples'; diff --git a/src/plugins/ui_actions/public/tests/test_plugin.ts b/src/plugins/ui_actions/public/tests/test_plugin.ts deleted file mode 100644 index dcc42fd9f6fb2..0000000000000 --- a/src/plugins/ui_actions/public/tests/test_plugin.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { CoreSetup, CoreStart } from 'src/core/public'; -import { UiActionsPlugin, UiActionsSetup, UiActionsStart } from '../plugin'; - -export interface UiActionsTestPluginReturn { - plugin: UiActionsPlugin; - coreSetup: CoreSetup; - coreStart: CoreStart; - setup: UiActionsSetup; - doStart: (anotherCoreStart?: CoreStart) => UiActionsStart; -} - -export const uiActionsTestPlugin = ( - coreSetup: CoreSetup = {} as CoreSetup, - coreStart: CoreStart = {} as CoreStart -): UiActionsTestPluginReturn => { - const initializerContext = {} as any; - const plugin = new UiActionsPlugin(initializerContext); - const setup = plugin.setup(coreSetup); - - return { - plugin, - coreSetup, - coreStart, - setup, - doStart: (anotherCoreStart: CoreStart = coreStart) => { - const start = plugin.start(anotherCoreStart); - return start; - }, - }; -}; diff --git a/src/plugins/ui_actions/public/triggers/detach_action.ts b/src/plugins/ui_actions/public/triggers/detach_action.ts deleted file mode 100644 index 710dcf9f5621b..0000000000000 --- a/src/plugins/ui_actions/public/triggers/detach_action.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { UiActionsApiPure } from '../types'; - -export const detachAction: UiActionsApiPure['detachAction'] = ({ triggers }) => ( - triggerId, - actionId -) => { - const trigger = triggers.get(triggerId); - - if (!trigger) { - throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for detaching action [actionId = ${actionId}].` - ); - } - - trigger.actionIds = trigger.actionIds.filter(id => id !== actionId); -}; diff --git a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.ts b/src/plugins/ui_actions/public/triggers/execute_trigger_actions.ts deleted file mode 100644 index 71f69eb3bdc29..0000000000000 --- a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { UiActionsApiPure } from '../types'; -import { buildContextMenuForActions, openContextMenu } from '../context_menu'; -import { Action } from '../actions'; - -const executeSingleAction = async (action: Action, actionContext: A) => { - const href = action.getHref && action.getHref(actionContext); - - // TODO: Do we need a `getHref()` special case? - if (href) { - window.location.href = href; - return; - } - - await action.execute(actionContext); -}; - -export const executeTriggerActions: UiActionsApiPure['executeTriggerActions'] = ({ api }) => async ( - triggerId, - actionContext -) => { - const actions = await api.getTriggerCompatibleActions!(triggerId, actionContext); - - if (!actions.length) { - throw new Error( - `No compatible actions found to execute for trigger [triggerId = ${triggerId}].` - ); - } - - if (actions.length === 1) { - await executeSingleAction(actions[0], actionContext); - return; - } - - const panel = await buildContextMenuForActions({ - actions, - actionContext, - closeMenu: () => session.close(), - }); - const session = openContextMenu([panel]); -}; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_actions.ts b/src/plugins/ui_actions/public/triggers/get_trigger_actions.ts deleted file mode 100644 index 37d7d5534c8c1..0000000000000 --- a/src/plugins/ui_actions/public/triggers/get_trigger_actions.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { UiActionsApiPure } from '../types'; -import { Action } from '../actions'; - -export const getTriggerActions: UiActionsApiPure['getTriggerActions'] = ({ - api, - actions, -}) => id => { - const trigger = api.getTrigger!(id); - return trigger.actionIds.map(actionId => actions.get(actionId)).filter(Boolean) as Action[]; -}; diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index 3006a5428f45e..a34c6eda61ba0 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { IncompatibleActionError } from './incompatible_action_error'; +export { Trigger } from './trigger'; diff --git a/src/plugins/ui_actions/public/triggers/register_trigger.ts b/src/plugins/ui_actions/public/triggers/register_trigger.ts deleted file mode 100644 index c9a7bb211d05a..0000000000000 --- a/src/plugins/ui_actions/public/triggers/register_trigger.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { UiActionsApiPure } from '../types'; - -export const registerTrigger: UiActionsApiPure['registerTrigger'] = ({ triggers }) => trigger => { - if (triggers.has(trigger.id)) { - throw new Error(`Trigger [trigger.id = ${trigger.id}] already registered.`); - } - - triggers.set(trigger.id, trigger); -}; diff --git a/src/plugins/ui_actions/public/triggers/registry.test.ts b/src/plugins/ui_actions/public/triggers/registry.test.ts deleted file mode 100644 index 6edb2b19a95e4..0000000000000 --- a/src/plugins/ui_actions/public/triggers/registry.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createApi } from '../api'; -import { createDeps } from '../tests/helpers'; - -const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; - -test('can register trigger', () => { - const deps = createDeps(); - const { api } = createApi(deps); - - api.registerTrigger({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); - - expect(deps.triggers.get('bar')).toEqual({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); -}); - -test('can register action', () => { - const deps = createDeps(); - const { api } = createApi(deps); - - api.registerAction({ - id: HELLO_WORLD_ACTION_ID, - order: 13, - } as any); - - expect(deps.actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({ - id: HELLO_WORLD_ACTION_ID, - order: 13, - }); -}); - -test('can attach an action to a trigger', () => { - const deps = createDeps(); - const { api } = createApi(deps); - const trigger = { - id: 'MY-TRIGGER', - actionIds: [], - }; - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - expect(trigger.actionIds).toEqual([]); - - api.registerTrigger(trigger); - api.registerAction(action); - api.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); - - expect(trigger.actionIds).toEqual([HELLO_WORLD_ACTION_ID]); -}); - -test('can detach an action to a trigger', () => { - const deps = createDeps(); - const { api } = createApi(deps); - const trigger = { - id: 'MY-TRIGGER', - actionIds: [], - }; - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - expect(trigger.actionIds).toEqual([]); - - api.registerTrigger(trigger); - api.registerAction(action); - api.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); - api.detachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); - - expect(trigger.actionIds).toEqual([]); -}); - -test('detaching an invalid action from a trigger throws an error', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - api.registerAction(action); - expect(() => api.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( - 'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].' - ); -}); - -test('attaching an invalid action to a trigger throws an error', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - api.registerAction(action); - expect(() => api.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( - 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].' - ); -}); - -test('cannot register another action with the same ID', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - api.registerAction(action); - expect(() => api.registerAction(action)).toThrowError( - 'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.' - ); -}); - -test('cannot register another trigger with the same ID', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const trigger = { id: 'MY-TRIGGER' } as any; - - api.registerTrigger(trigger); - expect(() => api.registerTrigger(trigger)).toThrowError( - 'Trigger [trigger.id = MY-TRIGGER] already registered.' - ); -}); diff --git a/src/plugins/ui_actions/public/triggers/trigger.ts b/src/plugins/ui_actions/public/triggers/trigger.ts index 3db11953053d5..ba83f5619e250 100644 --- a/src/plugins/ui_actions/public/triggers/trigger.ts +++ b/src/plugins/ui_actions/public/triggers/trigger.ts @@ -21,5 +21,4 @@ export interface Trigger { id: string; title?: string; description?: string; - actionIds: string[]; } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index ed4728342b751..9bd6ffdef2af3 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -20,39 +20,6 @@ import { Action } from './actions/action'; import { Trigger } from './triggers/trigger'; -export { Action } from './actions'; -export { Trigger } from './triggers/trigger'; - -export type ExecuteTriggerActions = (triggerId: string, actionContext: A) => Promise; - -export type GetActionsCompatibleWithTrigger = ( - triggerId: string, - context: C -) => Promise; - -export interface UiActionsApi { - attachAction: (triggerId: string, actionId: string) => void; - detachAction: (triggerId: string, actionId: string) => void; - executeTriggerActions: ExecuteTriggerActions; - getTrigger: (id: string) => Trigger; - getTriggerActions: (id: string) => Action[]; - getTriggerCompatibleActions: (triggerId: string, context: C) => Promise>>; - registerAction: (action: Action) => void; - registerTrigger: (trigger: Trigger) => void; -} - -export interface UiActionsDependencies { - actions: ActionRegistry; - triggers: TriggerRegistry; -} - -export interface UiActionsDependenciesInternal extends UiActionsDependencies { - api: Readonly>; -} - -export type UiActionsApiPure = { - [K in keyof UiActionsApi]: (deps: UiActionsDependenciesInternal) => UiActionsApi[K]; -}; - export type TriggerRegistry = Map; export type ActionRegistry = Map; +export type TriggerToActionsRegistry = Map; diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index e102dc2a64ee8..91951aa2f3edf 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -85,7 +85,7 @@ export class Collector { protected defaultFormatterForBulkUpload(result: T) { return { type: this.type, - payload: result, + payload: (result as unknown) as U, }; } } diff --git a/src/plugins/usage_collection/server/collector/usage_collector.ts b/src/plugins/usage_collection/server/collector/usage_collector.ts index 05c701bd3abf4..bf861a94fccff 100644 --- a/src/plugins/usage_collection/server/collector/usage_collector.ts +++ b/src/plugins/usage_collection/server/collector/usage_collector.ts @@ -24,14 +24,14 @@ export class UsageCollector ex T, U > { - protected defaultUsageFormatterForBulkUpload(result: T) { + protected defaultFormatterForBulkUpload(result: T) { return { type: KIBANA_STATS_TYPE, - payload: { + payload: ({ usage: { [this.type]: result, }, - }, + } as unknown) as U, }; } } diff --git a/test/api_integration/apis/general/index.js b/test/api_integration/apis/general/index.js index dca47dddbd4c8..2420297fb1af9 100644 --- a/test/api_integration/apis/general/index.js +++ b/test/api_integration/apis/general/index.js @@ -21,6 +21,5 @@ export default function({ loadTestFile }) { describe('general', () => { loadTestFile(require.resolve('./cookies')); loadTestFile(require.resolve('./csp')); - loadTestFile(require.resolve('./prototype_pollution')); }); } diff --git a/test/api_integration/apis/general/prototype_pollution.ts b/test/api_integration/apis/general/prototype_pollution.ts deleted file mode 100644 index 3e74480ebe9eb..0000000000000 --- a/test/api_integration/apis/general/prototype_pollution.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { FtrProviderContext } from 'test/api_integration/ftr_provider_context'; - -// eslint-disable-next-line import/no-default-export -export default function({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - describe('prototype pollution smoke test', () => { - it('prevents payloads with the "constructor.prototype" pollution vector from being accepted', async () => { - await supertest - .post('/api/saved_objects/_log_legacy_import') - .send([ - { - constructor: { - prototype: 'foo', - }, - }, - ]) - .expect(400, { - statusCode: 400, - error: 'Bad Request', - message: "'constructor.prototype' is an invalid key", - validation: { source: 'payload', keys: [] }, - }); - }); - - it('prevents payloads with the "__proto__" pollution vector from being accepted', async () => { - await supertest - .post('/api/saved_objects/_log_legacy_import') - .send(JSON.parse(`{"foo": { "__proto__": {} } }`)) - .expect(400, { - statusCode: 400, - error: 'Bad Request', - message: "'__proto__' is an invalid key", - validation: { source: 'payload', keys: [] }, - }); - }); - }); -} diff --git a/test/api_integration/apis/saved_objects/export.js b/test/api_integration/apis/saved_objects/export.js index 6648ac7b90622..ace65f190dec2 100644 --- a/test/api_integration/apis/saved_objects/export.js +++ b/test/api_integration/apis/saved_objects/export.js @@ -192,12 +192,9 @@ export default function({ getService }) { statusCode: 400, error: 'Bad Request', message: - 'child "type" fails because ["type" at position 0 fails because ' + - '["0" must be one of [config, dashboard, index-pattern, query, search, url, visualization]]]', - validation: { - source: 'payload', - keys: ['type.0'], - }, + '[request body.type]: types that failed validation:\n' + + '- [request body.type.0]: expected value of type [string] but got [Array]\n' + + '- [request body.type.1.0]: wigwags is not exportable', }); }); }); @@ -215,8 +212,7 @@ export default function({ getService }) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: '"value" must be an object', - validation: { source: 'payload', keys: ['value'] }, + message: '[request body]: expected a plain object value, but found [null] instead.', }); }); }); @@ -421,8 +417,7 @@ export default function({ getService }) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: '"value" contains a conflict between exclusive peers [type, objects]', - validation: { source: 'payload', keys: ['value'] }, + message: `Can't specify both "types" and "objects" properties when exporting`, }); }); }); diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 43c32156b6ada..54a19602fd414 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -230,12 +230,9 @@ export default function({ getService }) { .then(resp => { expect(resp.body).to.eql({ error: 'Bad Request', - message: 'child "type" fails because ["type" is required]', + message: + '[request query.type]: expected at least one defined value but got [undefined]', statusCode: 400, - validation: { - keys: ['type'], - source: 'query', - }, }); })); }); diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.js b/test/api_integration/apis/saved_objects/resolve_import_errors.js index 0375ad86b8f4a..60d2f42d51d13 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.js +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.js @@ -85,8 +85,7 @@ export default function({ getService }) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: 'child "file" fails because ["file" is required]', - validation: { source: 'payload', keys: ['file'] }, + message: '[request body.file]: expected value of type [Stream] but got [undefined]', }); }); }); diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx index 41e466fddd11e..a50248a5b6fa3 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx @@ -22,7 +22,7 @@ import { EuiPage, EuiPageBody, EuiPageContent, EuiPageContentHeader } from '@ela import { first } from 'rxjs/operators'; import { IInterpreterRenderHandlers, ExpressionValue } from 'src/plugins/expressions'; import { RequestAdapter, DataAdapter } from '../../../../../../../../src/plugins/inspector'; -import { Adapters, ExpressionRenderHandler, ExpressionDataHandler } from '../../types'; +import { Adapters, ExpressionRenderHandler } from '../../types'; import { getExpressions } from '../../services'; declare global { @@ -31,7 +31,7 @@ declare global { expressions: string, context?: ExpressionValue, initialContext?: ExpressionValue - ) => ReturnType; + ) => any; renderPipelineResponse: (context?: ExpressionValue) => Promise; } } @@ -61,12 +61,9 @@ class Main extends React.Component<{}, State> { data: new DataAdapter(), }; return getExpressions() - .execute(expression, { + .execute(expression, context || { type: 'null' }, { inspectorAdapters: adapters, - context, - // TODO: naming / typing is confusing and doesn't match here - // searchContext is also a way to set initialContext and Context can't be set to SearchContext - searchContext: initialContext as any, + search: initialContext as any, }) .getData(); }; diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts index 6e0a93e4a3cb1..123baa1183c48 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts @@ -17,12 +17,7 @@ * under the License. */ -import { - ExpressionsStart, - ExpressionRenderHandler, - ExpressionDataHandler, -} from 'src/plugins/expressions/public'; - +import { ExpressionsStart, ExpressionRenderHandler } from 'src/plugins/expressions/public'; import { Adapters } from 'src/plugins/inspector/public'; -export { ExpressionsStart, ExpressionRenderHandler, ExpressionDataHandler, Adapters }; +export { ExpressionsStart, ExpressionRenderHandler, Adapters }; diff --git a/test/interpreter_functional/test_suites/run_pipeline/basic.ts b/test/interpreter_functional/test_suites/run_pipeline/basic.ts index 77853b0bcd6a4..a2172dd2da1ba 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/basic.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/basic.ts @@ -22,7 +22,7 @@ import { ExpectExpression, expectExpressionProvider } from './helpers'; import { FtrProviderContext } from '../../../functional/ftr_provider_context'; // this file showcases how to use testing utilities defined in helpers.ts together with the kbn_tp_run_pipeline -// test plugin to write autmated tests for interprete +// test plugin to write automated tests for interpreter export default function({ getService, updateBaselines, diff --git a/test/interpreter_functional/test_suites/run_pipeline/helpers.ts b/test/interpreter_functional/test_suites/run_pipeline/helpers.ts index 015c311c30aef..00693845bb266 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/helpers.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/helpers.ts @@ -20,10 +20,8 @@ import expect from '@kbn/expect'; import { ExpressionValue } from 'src/plugins/expressions'; import { FtrProviderContext } from '../../../functional/ftr_provider_context'; -import { ExpressionDataHandler } from '../../plugins/kbn_tp_run_pipeline/public/np_ready/types'; -type UnWrapPromise = T extends Promise ? U : T; -export type ExpressionResult = UnWrapPromise>; +export type ExpressionResult = any; export type ExpectExpression = ( name: string, @@ -112,7 +110,7 @@ export function expectExpressionProvider({ if (!_currentContext.type) _currentContext.type = 'null'; return window .runPipeline(_expression, _currentContext, _initialContext) - .then(expressionResult => { + .then((expressionResult: any) => { done(expressionResult); return expressionResult; }); diff --git a/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml b/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml index 3082391f23a15..db57db9a1abe9 100644 --- a/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml +++ b/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml @@ -2,3 +2,6 @@ # Disabled plugins ######################## logging.verbose: true +elasticsearch.username: "kibana_system_user" +elasticsearch.password: "changeme" +xpack.security.encryptionKey: "something_at_least_32_characters" diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx index 51d8b43dac0ea..b58a450d26644 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx @@ -5,6 +5,7 @@ */ import { EuiLink } from '@elastic/eui'; +import { Location } from 'history'; import React from 'react'; import url from 'url'; import rison, { RisonValue } from 'rison-node'; @@ -12,6 +13,7 @@ import { useLocation } from '../../../../hooks/useLocation'; import { getTimepickerRisonData } from '../rison_helpers'; import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../common/index_pattern_constants'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; +import { AppMountContextBasePath } from '../../../../context/ApmPluginContext'; interface Props { query: { @@ -30,10 +32,15 @@ interface Props { children: React.ReactNode; } -export function DiscoverLink({ query = {}, ...rest }: Props) { - const { core } = useApmPluginContext(); - const location = useLocation(); - +export const getDiscoverHref = ({ + basePath, + location, + query +}: { + basePath: AppMountContextBasePath; + location: Location; + query: Props['query']; +}) => { const risonQuery = { _g: getTimepickerRisonData(location.search), _a: { @@ -43,11 +50,23 @@ export function DiscoverLink({ query = {}, ...rest }: Props) { }; const href = url.format({ - pathname: core.http.basePath.prepend('/app/kibana'), + pathname: basePath.prepend('/app/kibana'), hash: `/discover?_g=${rison.encode(risonQuery._g)}&_a=${rison.encode( risonQuery._a as RisonValue )}` }); + return href; +}; + +export function DiscoverLink({ query = {}, ...rest }: Props) { + const { core } = useApmPluginContext(); + const location = useLocation(); + + const href = getDiscoverHref({ + basePath: core.http.basePath, + query, + location + }); return ; } diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx index 8ff5e3010d6cc..e4f3557a2ce51 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx @@ -10,11 +10,13 @@ import React from 'react'; import url from 'url'; import { fromQuery } from './url_helpers'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { AppMountContextBasePath } from '../../../context/ApmPluginContext'; interface InfraQueryParams { time?: number; from?: number; to?: number; + filter?: string; } interface Props extends EuiLinkAnchorProps { @@ -23,12 +25,24 @@ interface Props extends EuiLinkAnchorProps { children?: React.ReactNode; } -export function InfraLink({ path, query = {}, ...rest }: Props) { - const { core } = useApmPluginContext(); +export const getInfraHref = ({ + basePath, + query, + path +}: { + basePath: AppMountContextBasePath; + query: InfraQueryParams; + path?: string; +}) => { const nextSearch = fromQuery(query); - const href = url.format({ - pathname: core.http.basePath.prepend('/app/infra'), + return url.format({ + pathname: basePath.prepend('/app/infra'), hash: compact([path, nextSearch]).join('?') }); +}; + +export function InfraLink({ path, query = {}, ...rest }: Props) { + const { core } = useApmPluginContext(); + const href = getInfraHref({ basePath: core.http.basePath, query, path }); return ; } diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 040d29aaa56dd..99f0b0d4fc223 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -4,240 +4,85 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiPopover, - EuiLink -} from '@elastic/eui'; -import url from 'url'; +import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useState, FunctionComponent } from 'react'; -import { pick } from 'lodash'; +import React, { FunctionComponent, useState } from 'react'; +import { + ActionMenu, + ActionMenuDivider, + Section, + SectionLink, + SectionLinks, + SectionSubtitle, + SectionTitle +} from '../../../../../../../plugins/observability/public'; import { Transaction } from '../../../../typings/es_schemas/ui/Transaction'; -import { DiscoverTransactionLink } from '../Links/DiscoverLinks/DiscoverTransactionLink'; -import { InfraLink } from '../Links/InfraLink'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { fromQuery } from '../Links/url_helpers'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; - -function getInfraMetricsQuery(transaction: Transaction) { - const plus5 = new Date(transaction['@timestamp']); - const minus5 = new Date(plus5.getTime()); - - plus5.setMinutes(plus5.getMinutes() + 5); - minus5.setMinutes(minus5.getMinutes() - 5); - - return { - from: minus5.getTime(), - to: plus5.getTime() - }; -} - -function ActionMenuButton({ onClick }: { onClick: () => void }) { - return ( - - {i18n.translate('xpack.apm.transactionActionMenu.actionsButtonLabel', { - defaultMessage: 'Actions' - })} - - ); -} +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { getSections } from './sections'; interface Props { readonly transaction: Transaction; } -interface InfraConfigItem { - icon: string; - label: string; - condition?: boolean; - path: string; - query: Record; -} - -export const TransactionActionMenu: FunctionComponent = ( - props: Props -) => { - const { transaction } = props; - +const ActionMenuButton = ({ onClick }: { onClick: () => void }) => ( + + {i18n.translate('xpack.apm.transactionActionMenu.actionsButtonLabel', { + defaultMessage: 'Actions' + })} + +); + +export const TransactionActionMenu: FunctionComponent = ({ + transaction +}: Props) => { const { core } = useApmPluginContext(); - - const [isOpen, setIsOpen] = useState(false); - + const location = useLocation(); const { urlParams } = useUrlParams(); - const hostName = transaction.host?.hostname; - const podId = transaction.kubernetes?.pod.uid; - const containerId = transaction.container?.id; - - const time = Math.round(transaction.timestamp.us / 1000); - const infraMetricsQuery = getInfraMetricsQuery(transaction); - - const infraConfigItems: InfraConfigItem[] = [ - { - icon: 'logsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showPodLogsLinkLabel', - { defaultMessage: 'Show pod logs' } - ), - condition: !!podId, - path: `/link-to/pod-logs/${podId}`, - query: { time } - }, - { - icon: 'logsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showContainerLogsLinkLabel', - { defaultMessage: 'Show container logs' } - ), - condition: !!containerId, - path: `/link-to/container-logs/${containerId}`, - query: { time } - }, - { - icon: 'logsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showHostLogsLinkLabel', - { defaultMessage: 'Show host logs' } - ), - condition: !!hostName, - path: `/link-to/host-logs/${hostName}`, - query: { time } - }, - { - icon: 'logsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showTraceLogsLinkLabel', - { defaultMessage: 'Show trace logs' } - ), - condition: true, - path: `/link-to/logs`, - query: { - time, - filter: `trace.id:"${transaction.trace.id}" OR ${transaction.trace.id}` - } - }, - { - icon: 'metricsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showPodMetricsLinkLabel', - { defaultMessage: 'Show pod metrics' } - ), - condition: !!podId, - path: `/link-to/pod-detail/${podId}`, - query: infraMetricsQuery - }, - { - icon: 'metricsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showContainerMetricsLinkLabel', - { defaultMessage: 'Show container metrics' } - ), - condition: !!containerId, - path: `/link-to/container-detail/${containerId}`, - query: infraMetricsQuery - }, - { - icon: 'metricsApp', - label: i18n.translate( - 'xpack.apm.transactionActionMenu.showHostMetricsLinkLabel', - { defaultMessage: 'Show host metrics' } - ), - condition: !!hostName, - path: `/link-to/host-detail/${hostName}`, - query: infraMetricsQuery - } - ]; - - const infraItems = infraConfigItems.map( - ({ icon, label, condition, path, query }, index) => ({ - icon, - key: `infra-link-${index}`, - child: ( - - {label} - - ), - condition - }) - ); + const [isOpen, setIsOpen] = useState(false); - const uptimeLink = url.format({ - pathname: core.http.basePath.prepend('/app/uptime'), - hash: `/?${fromQuery( - pick( - { - dateRangeStart: urlParams.rangeFrom, - dateRangeEnd: urlParams.rangeTo, - search: `url.domain:"${transaction.url?.domain}"` - }, - (val: string) => !!val - ) - )}` + const sections = getSections({ + transaction, + basePath: core.http.basePath, + location, + urlParams }); - const menuItems = [ - ...infraItems, - { - icon: 'discoverApp', - key: 'discover-transaction', - condition: true, - child: ( - - {i18n.translate( - 'xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel', - { - defaultMessage: 'View sample document' - } - )} - - ) - }, - { - icon: 'uptimeApp', - key: 'uptime', - child: ( - - {i18n.translate('xpack.apm.transactionActionMenu.viewInUptime', { - defaultMessage: 'View monitor status' - })} - - ), - condition: transaction.url?.domain - } - ] - .filter(({ condition }) => condition) - .map(({ icon, key, child }) => ( - - - {child} - - - - - - )); - return ( - setIsOpen(!isOpen)} />} - isOpen={isOpen} closePopover={() => setIsOpen(false)} + isOpen={isOpen} anchorPosition="downRight" - panelPaddingSize="none" + button={ setIsOpen(!isOpen)} />} > - - + {sections.map((section, idx) => { + const isLastSection = idx !== sections.length - 1; + return ( +
+ {section.map(item => ( +
+ {item.title && {item.title}} + {item.subtitle && ( + {item.subtitle} + )} + + {item.actions.map(action => ( + + ))} + +
+ ))} + {isLastSection && } +
+ ); + })} + ); }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 2bfa5cf1274fa..e9f89034f58ee 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -36,7 +36,7 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithMinimalData ); - expect(queryByText('Show trace logs')).not.toBeNull(); + expect(queryByText('Trace logs')).not.toBeNull(); }); it('should not render the pod links when there is no pod id', async () => { @@ -44,8 +44,8 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithMinimalData ); - expect(queryByText('Show pod logs')).toBeNull(); - expect(queryByText('Show pod metrics')).toBeNull(); + expect(queryByText('Pod logs')).toBeNull(); + expect(queryByText('Pod metrics')).toBeNull(); }); it('should render the pod links when there is a pod id', async () => { @@ -53,8 +53,8 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithKubernetesData ); - expect(queryByText('Show pod logs')).not.toBeNull(); - expect(queryByText('Show pod metrics')).not.toBeNull(); + expect(queryByText('Pod logs')).not.toBeNull(); + expect(queryByText('Pod metrics')).not.toBeNull(); }); it('should not render the container links when there is no container id', async () => { @@ -62,8 +62,8 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithMinimalData ); - expect(queryByText('Show container logs')).toBeNull(); - expect(queryByText('Show container metrics')).toBeNull(); + expect(queryByText('Container logs')).toBeNull(); + expect(queryByText('Container metrics')).toBeNull(); }); it('should render the container links when there is a container id', async () => { @@ -71,8 +71,8 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithContainerData ); - expect(queryByText('Show container logs')).not.toBeNull(); - expect(queryByText('Show container metrics')).not.toBeNull(); + expect(queryByText('Container logs')).not.toBeNull(); + expect(queryByText('Container metrics')).not.toBeNull(); }); it('should not render the host links when there is no hostname', async () => { @@ -80,8 +80,8 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithMinimalData ); - expect(queryByText('Show host logs')).toBeNull(); - expect(queryByText('Show host metrics')).toBeNull(); + expect(queryByText('Host logs')).toBeNull(); + expect(queryByText('Host metrics')).toBeNull(); }); it('should render the host links when there is a hostname', async () => { @@ -89,8 +89,8 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithHostData ); - expect(queryByText('Show host logs')).not.toBeNull(); - expect(queryByText('Show host metrics')).not.toBeNull(); + expect(queryByText('Host logs')).not.toBeNull(); + expect(queryByText('Host metrics')).not.toBeNull(); }); it('should not render the uptime link if there is no url available', async () => { @@ -98,7 +98,7 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithMinimalData ); - expect(queryByText('View monitor status')).toBeNull(); + expect(queryByText('Status')).toBeNull(); }); it('should not render the uptime link if there is no domain available', async () => { @@ -106,7 +106,7 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithUrlWithoutDomain ); - expect(queryByText('View monitor status')).toBeNull(); + expect(queryByText('Status')).toBeNull(); }); it('should render the uptime link if there is a url with a domain', async () => { @@ -114,7 +114,7 @@ describe('TransactionActionMenu component', () => { Transactions.transactionWithUrlAndDomain ); - expect(queryByText('View monitor status')).not.toBeNull(); + expect(queryByText('Status')).not.toBeNull(); }); it('should match the snapshot', async () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts new file mode 100644 index 0000000000000..52c2d27eabb82 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Location } from 'history'; +import { getSections } from '../sections'; +import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction'; +import { AppMountContextBasePath } from '../../../../context/ApmPluginContext'; + +describe('Transaction action menu', () => { + const basePath = ({ + prepend: jest.fn() + } as unknown) as AppMountContextBasePath; + const date = '2020-02-06T11:00:00.000Z'; + const timestamp = { us: new Date(date).getTime() }; + + it('shows required sections only', () => { + const transaction = ({ + timestamp, + trace: { id: '123' }, + transaction: { id: '123' }, + '@timestamp': date + } as unknown) as Transaction; + expect( + getSections({ + transaction, + basePath, + location: ({} as unknown) as Location, + urlParams: {} + }) + ).toEqual([ + [ + { + key: 'traceDetails', + title: 'Trace details', + subtitle: 'View trace logs to get further details.', + actions: [ + { + key: 'traceLogs', + label: 'Trace logs', + href: + '#/link-to/logs?time=1580986800&filter=trace.id:%22123%22%20OR%20123', + condition: true + } + ] + } + ], + [ + { + key: 'kibana', + actions: [ + { + key: 'sampleDocument', + label: 'View sample document', + href: + '#/discover?_g=(refreshInterval:(pause:true,value:\'0\'),time:(from:now-24h,to:now))&_a=(index:apm_static_index_pattern_id,interval:auto,query:(language:kuery,query:\'processor.event:"transaction" AND transaction.id:"123" AND trace.id:"123"\'))', + condition: true + } + ] + } + ] + ]); + }); + + it('shows pod and required sections only', () => { + const transaction = ({ + kubernetes: { pod: { uid: '123' } }, + timestamp, + trace: { id: '123' }, + transaction: { id: '123' }, + '@timestamp': date + } as unknown) as Transaction; + expect( + getSections({ + transaction, + basePath, + location: ({} as unknown) as Location, + urlParams: {} + }) + ).toEqual([ + [ + { + key: 'podDetails', + title: 'Pod details', + subtitle: + 'View logs and metrics for this pod to get further details.', + actions: [ + { + key: 'podLogs', + label: 'Pod logs', + href: '#/link-to/pod-logs/123?time=1580986800', + condition: true + }, + { + key: 'podMetrics', + label: 'Pod metrics', + href: + '#/link-to/pod-detail/123?from=1580986500000&to=1580987100000', + condition: true + } + ] + }, + { + key: 'traceDetails', + title: 'Trace details', + subtitle: 'View trace logs to get further details.', + actions: [ + { + key: 'traceLogs', + label: 'Trace logs', + href: + '#/link-to/logs?time=1580986800&filter=trace.id:%22123%22%20OR%20123', + condition: true + } + ] + } + ], + [ + { + key: 'kibana', + actions: [ + { + key: 'sampleDocument', + label: 'View sample document', + href: + '#/discover?_g=(refreshInterval:(pause:true,value:\'0\'),time:(from:now-24h,to:now))&_a=(index:apm_static_index_pattern_id,interval:auto,query:(language:kuery,query:\'processor.event:"transaction" AND transaction.id:"123" AND trace.id:"123"\'))', + condition: true + } + ] + } + ] + ]); + }); + + it('shows host and required sections only', () => { + const transaction = ({ + host: { hostname: 'foo' }, + timestamp, + trace: { id: '123' }, + transaction: { id: '123' }, + '@timestamp': date + } as unknown) as Transaction; + expect( + getSections({ + transaction, + basePath, + location: ({} as unknown) as Location, + urlParams: {} + }) + ).toEqual([ + [ + { + key: 'hostDetails', + title: 'Host details', + subtitle: 'View host logs and metrics to get further details.', + actions: [ + { + key: 'hostLogs', + label: 'Host logs', + href: '#/link-to/host-logs/foo?time=1580986800', + condition: true + }, + { + key: 'hostMetrics', + label: 'Host metrics', + href: + '#/link-to/host-detail/foo?from=1580986500000&to=1580987100000', + condition: true + } + ] + }, + { + key: 'traceDetails', + title: 'Trace details', + subtitle: 'View trace logs to get further details.', + actions: [ + { + key: 'traceLogs', + label: 'Trace logs', + href: + '#/link-to/logs?time=1580986800&filter=trace.id:%22123%22%20OR%20123', + condition: true + } + ] + } + ], + [ + { + key: 'kibana', + actions: [ + { + key: 'sampleDocument', + label: 'View sample document', + href: + '#/discover?_g=(refreshInterval:(pause:true,value:\'0\'),time:(from:now-24h,to:now))&_a=(index:apm_static_index_pattern_id,interval:auto,query:(language:kuery,query:\'processor.event:"transaction" AND transaction.id:"123" AND trace.id:"123"\'))', + condition: true + } + ] + } + ] + ]); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts new file mode 100644 index 0000000000000..77445a2600960 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts @@ -0,0 +1,299 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { Location } from 'history'; +import { pick, isEmpty } from 'lodash'; +import moment from 'moment'; +import url from 'url'; +import { Transaction } from '../../../../typings/es_schemas/ui/Transaction'; +import { IUrlParams } from '../../../context/UrlParamsContext/types'; +import { getDiscoverHref } from '../Links/DiscoverLinks/DiscoverLink'; +import { getDiscoverQuery } from '../Links/DiscoverLinks/DiscoverTransactionLink'; +import { getInfraHref } from '../Links/InfraLink'; +import { fromQuery } from '../Links/url_helpers'; +import { AppMountContextBasePath } from '../../../context/ApmPluginContext'; + +function getInfraMetricsQuery(transaction: Transaction) { + const timestamp = new Date(transaction['@timestamp']).getTime(); + const fiveMinutes = moment.duration(5, 'minutes').asMilliseconds(); + + return { + from: timestamp - fiveMinutes, + to: timestamp + fiveMinutes + }; +} + +interface Action { + key: string; + label: string; + href: string; + condition: boolean; +} + +interface Section { + key: string; + title?: string; + subtitle?: string; + actions: Action[]; +} + +type SectionRecord = Record; + +export const getSections = ({ + transaction, + basePath, + location, + urlParams +}: { + transaction: Transaction; + basePath: AppMountContextBasePath; + location: Location; + urlParams: IUrlParams; +}) => { + const hostName = transaction.host?.hostname; + const podId = transaction.kubernetes?.pod.uid; + const containerId = transaction.container?.id; + + const time = Math.round(transaction.timestamp.us / 1000); + const infraMetricsQuery = getInfraMetricsQuery(transaction); + + const uptimeLink = url.format({ + pathname: basePath.prepend('/app/uptime'), + hash: `/?${fromQuery( + pick( + { + dateRangeStart: urlParams.rangeFrom, + dateRangeEnd: urlParams.rangeTo, + search: `url.domain:"${transaction.url?.domain}"` + }, + (val: string) => !isEmpty(val) + ) + )}` + }); + + const podActions: Action[] = [ + { + key: 'podLogs', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showPodLogsLinkLabel', + { defaultMessage: 'Pod logs' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/pod-logs/${podId}`, + query: { time } + }), + condition: !!podId + }, + { + key: 'podMetrics', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showPodMetricsLinkLabel', + { defaultMessage: 'Pod metrics' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/pod-detail/${podId}`, + query: infraMetricsQuery + }), + condition: !!podId + } + ]; + + const containerActions: Action[] = [ + { + key: 'containerLogs', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showContainerLogsLinkLabel', + { defaultMessage: 'Container logs' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/container-logs/${containerId}`, + query: { time } + }), + condition: !!containerId + }, + { + key: 'containerMetrics', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showContainerMetricsLinkLabel', + { defaultMessage: 'Container metrics' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/container-detail/${containerId}`, + query: infraMetricsQuery + }), + condition: !!containerId + } + ]; + + const hostActions: Action[] = [ + { + key: 'hostLogs', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showHostLogsLinkLabel', + { defaultMessage: 'Host logs' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/host-logs/${hostName}`, + query: { time } + }), + condition: !!hostName + }, + { + key: 'hostMetrics', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showHostMetricsLinkLabel', + { defaultMessage: 'Host metrics' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/host-detail/${hostName}`, + query: infraMetricsQuery + }), + condition: !!hostName + } + ]; + + const logActions: Action[] = [ + { + key: 'traceLogs', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.showTraceLogsLinkLabel', + { defaultMessage: 'Trace logs' } + ), + href: getInfraHref({ + basePath, + path: `/link-to/logs`, + query: { + time, + filter: `trace.id:"${transaction.trace.id}" OR ${transaction.trace.id}` + } + }), + condition: true + } + ]; + + const uptimeActions: Action[] = [ + { + key: 'monitorStatus', + label: i18n.translate('xpack.apm.transactionActionMenu.viewInUptime', { + defaultMessage: 'Status' + }), + href: uptimeLink, + condition: !!transaction.url?.domain + } + ]; + + const kibanaActions: Action[] = [ + { + key: 'sampleDocument', + label: i18n.translate( + 'xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel', + { + defaultMessage: 'View sample document' + } + ), + href: getDiscoverHref({ + basePath, + query: getDiscoverQuery(transaction), + location + }), + condition: true + } + ]; + + const sectionRecord: SectionRecord = { + observability: [ + { + key: 'podDetails', + title: i18n.translate('xpack.apm.transactionActionMenu.pod.title', { + defaultMessage: 'Pod details' + }), + subtitle: i18n.translate( + 'xpack.apm.transactionActionMenu.pod.subtitle', + { + defaultMessage: + 'View logs and metrics for this pod to get further details.' + } + ), + actions: podActions + }, + { + key: 'containerDetails', + title: i18n.translate( + 'xpack.apm.transactionActionMenu.container.title', + { + defaultMessage: 'Container details' + } + ), + subtitle: i18n.translate( + 'xpack.apm.transactionActionMenu.container.subtitle', + { + defaultMessage: + 'View logs and metrics for this container to get further details.' + } + ), + actions: containerActions + }, + { + key: 'hostDetails', + title: i18n.translate('xpack.apm.transactionActionMenu.host.title', { + defaultMessage: 'Host details' + }), + subtitle: i18n.translate( + 'xpack.apm.transactionActionMenu.host.subtitle', + { + defaultMessage: 'View host logs and metrics to get further details.' + } + ), + actions: hostActions + }, + { + key: 'traceDetails', + title: i18n.translate('xpack.apm.transactionActionMenu.trace.title', { + defaultMessage: 'Trace details' + }), + subtitle: i18n.translate( + 'xpack.apm.transactionActionMenu.trace.subtitle', + { + defaultMessage: 'View trace logs to get further details.' + } + ), + actions: logActions + }, + { + key: 'statusDetails', + title: i18n.translate('xpack.apm.transactionActionMenu.status.title', { + defaultMessage: 'Status details' + }), + subtitle: i18n.translate( + 'xpack.apm.transactionActionMenu.status.subtitle', + { + defaultMessage: 'View status to get further details.' + } + ), + actions: uptimeActions + } + ], + kibana: [{ key: 'kibana', actions: kibanaActions }] + }; + + // Filter out actions that shouldnt be shown and sections without any actions. + return Object.values(sectionRecord) + .map(sections => + sections + .map(section => ({ + ...section, + actions: section.actions.filter(action => action.condition) + })) + .filter(section => !isEmpty(section.actions)) + ) + .filter(sections => !isEmpty(sections)); +}; diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx index 86efd9b31974e..7a9aaa6dfb920 100644 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx @@ -8,6 +8,8 @@ import { createContext } from 'react'; import { AppMountContext, PackageInfo } from 'kibana/public'; import { ApmPluginSetupDeps, ConfigSchema } from '../new-platform/plugin'; +export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; + export interface ApmPluginContextValue { config: ConfigSchema; core: AppMountContext['core']; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index a07bd475cdfcb..55363ebe4d8f3 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Subject } from 'rxjs'; import { Embeddable } from './embeddable'; import { ReactExpressionRendererProps } from 'src/plugins/expressions/public'; -import { Query, TimeRange, Filter } from 'src/plugins/data/public'; +import { Query, TimeRange, Filter, TimefilterContract } from 'src/plugins/data/public'; import { Document } from '../../persistence'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; jest.mock('../../../../../../../src/plugins/inspector/public/', () => ({ isAvailable: false, @@ -44,6 +46,7 @@ describe('embeddable', () => { it('should render expression with expression renderer', () => { const embeddable = new Embeddable( + dataPluginMock.createSetupContract().query.timefilter.timefilter, expressionRenderer, { editUrl: '', @@ -64,6 +67,7 @@ describe('embeddable', () => { const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; const embeddable = new Embeddable( + dataPluginMock.createSetupContract().query.timefilter.timefilter, expressionRenderer, { editUrl: '', @@ -89,6 +93,7 @@ describe('embeddable', () => { const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; const embeddable = new Embeddable( + dataPluginMock.createSetupContract().query.timefilter.timefilter, expressionRenderer, { editUrl: '', @@ -112,6 +117,7 @@ describe('embeddable', () => { const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: true } }]; const embeddable = new Embeddable( + dataPluginMock.createSetupContract().query.timefilter.timefilter, expressionRenderer, { editUrl: '', @@ -130,4 +136,31 @@ describe('embeddable', () => { expect(expressionRenderer).toHaveBeenCalledTimes(1); }); + + it('should re-render on auto refresh fetch observable', () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: '' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: true } }]; + + const autoRefreshFetchSubject = new Subject(); + const timefilter = ({ + getAutoRefreshFetch$: () => autoRefreshFetchSubject.asObservable(), + } as unknown) as TimefilterContract; + + const embeddable = new Embeddable( + timefilter, + expressionRenderer, + { + editUrl: '', + editable: true, + savedVis, + }, + { id: '123', timeRange, query, filters } + ); + embeddable.render(mountpoint); + + autoRefreshFetchSubject.next(); + + expect(expressionRenderer).toHaveBeenCalledTimes(2); + }); }); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index a3a55f26ff7c2..252ba5c9bc0bc 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -7,7 +7,13 @@ import _ from 'lodash'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Query, TimeRange, Filter, IIndexPattern } from 'src/plugins/data/public'; +import { + Query, + TimeRange, + Filter, + IIndexPattern, + TimefilterContract, +} from 'src/plugins/data/public'; import { Subscription } from 'rxjs'; import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; import { @@ -43,6 +49,7 @@ export class Embeddable extends AbstractEmbeddable this.onContainerStateChanged(input)); this.onContainerStateChanged(initialInput); + + this.autoRefreshFetchSubscription = timefilter + .getAutoRefreshFetch$() + .subscribe(this.reload.bind(this)); } onContainerStateChanged(containerState: LensEmbeddableInput) { @@ -125,6 +137,7 @@ export class Embeddable extends AbstractEmbeddable, private savedObjectsClient: SavedObjectsClientContract, @@ -85,6 +90,7 @@ export class EmbeddableFactory extends AbstractEmbeddableFactory { ); return new Embeddable( + this.timefilter, this.expressionRenderer, { savedVis, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx index cd121a1f96a2b..e606c69c8c386 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx @@ -14,6 +14,7 @@ import { embeddablePluginMock } from '../../../../../../src/plugins/embeddable/p import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './service'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; export function createMockVisualization(): jest.Mocked { return { @@ -103,7 +104,7 @@ export function createExpressionRendererMock(): jest.Mock< export function createMockSetupDependencies() { return ({ - data: {}, + data: dataPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), } as unknown) as MockedSetupDependencies; @@ -111,11 +112,7 @@ export function createMockSetupDependencies() { export function createMockStartDependencies() { return ({ - data: { - indexPatterns: { - indexPatterns: {}, - }, - }, + data: dataPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), } as unknown) as MockedStartDependencies; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx index 9a3d724705a1a..7a0bb3a2cc50f 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx @@ -79,6 +79,7 @@ export class EditorFrameService { plugins.embeddable.registerEmbeddableFactory( 'lens', new EmbeddableFactory( + plugins.data.query.timefilter.timefilter, core.http, core.application.capabilities, core.savedObjects.client, diff --git a/x-pack/legacy/plugins/ml/public/application/contexts/kibana/index.ts b/x-pack/legacy/plugins/ml/public/application/contexts/kibana/index.ts index 7ebbd45fd372a..2add1b6ea161c 100644 --- a/x-pack/legacy/plugins/ml/public/application/contexts/kibana/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/contexts/kibana/index.ts @@ -6,3 +6,4 @@ export { useMlKibana, StartServices, MlKibanaReactContextValue } from './kibana_context'; export { useUiSettings } from './use_ui_settings_context'; +export { useTimefilter } from './use_timefilter'; diff --git a/x-pack/legacy/plugins/ml/public/application/contexts/kibana/use_timefilter.test.ts b/x-pack/legacy/plugins/ml/public/application/contexts/kibana/use_timefilter.test.ts new file mode 100644 index 0000000000000..98ddd874c695c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/contexts/kibana/use_timefilter.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useTimefilter } from './use_timefilter'; + +jest.mock('./kibana_context', () => ({ + useMlKibana: () => { + return { + services: { + data: { + query: { + timefilter: { + timefilter: { + disableTimeRangeSelector: jest.fn(), + disableAutoRefreshSelector: jest.fn(), + enableTimeRangeSelector: jest.fn(), + enableAutoRefreshSelector: jest.fn(), + }, + }, + }, + }, + }, + }; + }, +})); + +describe('useTimefilter', () => { + test('will not trigger any date picker settings by default', () => { + const { result } = renderHook(() => useTimefilter()); + const timefilter = result.current; + + expect(timefilter.disableTimeRangeSelector).toHaveBeenCalledTimes(0); + expect(timefilter.disableAutoRefreshSelector).toHaveBeenCalledTimes(0); + expect(timefilter.enableTimeRangeSelector).toHaveBeenCalledTimes(0); + expect(timefilter.enableTimeRangeSelector).toHaveBeenCalledTimes(0); + }); + + test('custom disabled overrides', () => { + const { result } = renderHook(() => + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }) + ); + const timefilter = result.current; + + expect(timefilter.disableTimeRangeSelector).toHaveBeenCalledTimes(1); + expect(timefilter.disableAutoRefreshSelector).toHaveBeenCalledTimes(1); + expect(timefilter.enableTimeRangeSelector).toHaveBeenCalledTimes(0); + expect(timefilter.enableTimeRangeSelector).toHaveBeenCalledTimes(0); + }); + + test('custom enabled overrides', () => { + const { result } = renderHook(() => + useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }) + ); + const timefilter = result.current; + + expect(timefilter.disableTimeRangeSelector).toHaveBeenCalledTimes(0); + expect(timefilter.disableAutoRefreshSelector).toHaveBeenCalledTimes(0); + expect(timefilter.enableTimeRangeSelector).toHaveBeenCalledTimes(1); + expect(timefilter.enableTimeRangeSelector).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/application/contexts/kibana/use_timefilter.ts b/x-pack/legacy/plugins/ml/public/application/contexts/kibana/use_timefilter.ts new file mode 100644 index 0000000000000..374e101f63dc8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/contexts/kibana/use_timefilter.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect } from 'react'; + +import { useMlKibana } from './kibana_context'; + +interface UseTimefilterOptions { + timeRangeSelector?: boolean; + autoRefreshSelector?: boolean; +} + +export const useTimefilter = ({ + timeRangeSelector, + autoRefreshSelector, +}: UseTimefilterOptions = {}) => { + const { services } = useMlKibana(); + const { timefilter } = services.data.query.timefilter; + + useEffect(() => { + if (timeRangeSelector === true) { + timefilter.enableTimeRangeSelector(); + } else if (timeRangeSelector === false) { + timefilter.disableTimeRangeSelector(); + } + + if (autoRefreshSelector === true) { + timefilter.enableAutoRefreshSelector(); + } else if (autoRefreshSelector === false) { + timefilter.disableAutoRefreshSelector(); + } + }, [timeRangeSelector, autoRefreshSelector]); + + return timefilter; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx b/x-pack/legacy/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx index ae0c034f972d6..0f56f78c708ee 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { isFullLicense } from '../license/check_license'; -import { useMlKibana } from '../contexts/kibana'; +import { useTimefilter } from '../contexts/kibana'; import { NavigationMenu } from '../components/navigation_menu'; @@ -49,10 +49,7 @@ function startTrialDescription() { } export const DatavisualizerSelector: FC = () => { - const { services } = useMlKibana(); - const { timefilter } = services.data.query.timefilter; - timefilter.disableTimeRangeSelector(); - timefilter.disableAutoRefreshSelector(); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const startTrialVisible = isFullLicense() === false; diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index bb95d3e420d2a..beb5918e277ae 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -325,9 +325,17 @@ export class ImportView extends Component { onIndexChange = e => { const name = e.target.value; + const { indexNames, indexPattern, indexPatternNames } = this.state; + this.setState({ index: name, - indexNameError: isIndexNameValid(name, this.state.indexNames), + indexNameError: isIndexNameValid(name, indexNames), + // if index pattern has been altered, check that it still matches the inputted index + ...(indexPattern === '' + ? {} + : { + indexPatternNameError: isIndexPatternNameValid(indexPattern, indexPatternNames, name), + }), }); }; diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx index 9dcb9d25692e9..5c32d62c39f84 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx @@ -7,7 +7,7 @@ import React, { FC, Fragment } from 'react'; import { IUiSettingsClient } from 'src/core/public'; -import { useMlKibana } from '../../contexts/kibana'; +import { useTimefilter } from '../../contexts/kibana'; import { NavigationMenu } from '../../components/navigation_menu'; import { getIndexPatternsContract } from '../../util/index_utils'; @@ -19,10 +19,7 @@ export interface FileDataVisualizerPageProps { } export const FileDataVisualizerPage: FC = ({ kibanaConfig }) => { - const { services } = useMlKibana(); - const { timefilter } = services.data.query.timefilter; - timefilter.disableTimeRangeSelector(); - timefilter.disableAutoRefreshSelector(); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const indexPatterns = getIndexPatternsContract(); return ( diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/legacy/plugins/ml/public/application/datavisualizer/index_based/page.tsx index a6508ea868724..84c07651d323d 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -38,7 +38,7 @@ import { FullTimeRangeSelector } from '../../components/full_time_range_selector import { mlTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; import { useMlContext, SavedSearchQuery } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; -import { useMlKibana } from '../../contexts/kibana'; +import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; import { TimeBuckets } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; @@ -97,11 +97,13 @@ function getDefaultPageState(): DataVisualizerPageState { } export const Page: FC = () => { - const { services } = useMlKibana(); const mlContext = useMlContext(); - const { timefilter } = services.data.query.timefilter; const { combinedQuery, currentIndexPattern, currentSavedSearch, kibanaConfig } = mlContext; + const timefilter = useTimefilter({ + timeRangeSelector: currentIndexPattern.timeFieldName !== undefined, + autoRefreshSelector: true, + }); const dataLoader = new DataLoader(currentIndexPattern, kibanaConfig); const [globalState, setGlobalState] = useUrlState('_g'); @@ -122,13 +124,6 @@ export const Page: FC = () => { const [lastRefresh, setLastRefresh] = useState(0); useEffect(() => { - if (currentIndexPattern.timeFieldName !== undefined) { - timefilter.enableTimeRangeSelector(); - } else { - timefilter.disableTimeRangeSelector(); - } - - timefilter.enableAutoRefreshSelector(); timeBasedIndexCheck(currentIndexPattern, true); }, []); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx index b0046f7b8d699..2c6726338d2f1 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx @@ -29,7 +29,7 @@ import { useTableInterval } from '../../components/controls/select_interval'; import { useTableSeverity } from '../../components/controls/select_severity'; import { useUrlState } from '../../util/url_state'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; -import { useMlKibana } from '../../contexts/kibana'; +import { useTimefilter } from '../../contexts/kibana'; const breadcrumbs = [ ML_BREADCRUMB, @@ -70,8 +70,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [appState, setAppState] = useUrlState('_a'); const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); - const { services } = useMlKibana(); - const { timefilter } = services.data.query.timefilter; + const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange, getDateFormatTz()); @@ -111,9 +110,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim }, [globalState?.time?.from, globalState?.time?.to]); useEffect(() => { - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; if (viewByFieldName !== undefined) { explorerService.setViewBySwimlaneFieldName(viewByFieldName); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/jobs_list.tsx index ca2c0750397e5..c1d686d356dda 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -14,8 +14,8 @@ import { MlRoute, PageLoader, PageProps } from '../router'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { JobsPage } from '../../jobs/jobs_list'; +import { useTimefilter } from '../../contexts/kibana'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; -import { useMlKibana } from '../../contexts/kibana'; const breadcrumbs = [ ML_BREADCRUMB, @@ -36,8 +36,7 @@ export const jobListRoute: MlRoute = { const PageWrapper: FC = ({ deps }) => { const { context } = useResolver(undefined, undefined, deps.config, basicResolvers(deps)); - const { services } = useMlKibana(); - const { timefilter } = services.data.query.timefilter; + const timefilter = useTimefilter({ timeRangeSelector: false, autoRefreshSelector: true }); const [globalState, setGlobalState] = useUrlState('_g'); @@ -48,9 +47,6 @@ const PageWrapper: FC = ({ deps }) => { const blockRefresh = refreshValue === 0 || refreshPause === true; useEffect(() => { - timefilter.disableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - // If the refreshInterval defaults to 0s/pause=true, set it to 30s/pause=false, // otherwise pass on the globalState's settings to the date picker. const refreshInterval = diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/overview.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/overview.tsx index 85227c11582d9..b1e00158efb94 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/overview.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/overview.tsx @@ -16,6 +16,7 @@ import { checkFullLicense } from '../../license/check_license'; import { checkGetJobsPrivilege } from '../../privilege/check_privilege'; import { getMlNodeCount } from '../../ml_nodes_check'; import { loadMlServerInfo } from '../../services/ml_server_info'; +import { useTimefilter } from '../../contexts/kibana'; import { ML_BREADCRUMB } from '../breadcrumbs'; const breadcrumbs = [ @@ -41,6 +42,7 @@ const PageWrapper: FC = ({ deps }) => { getMlNodeCount, loadMlServerInfo, }); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); return ( diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx index fdbfcb3397c75..c1bfaa2fe6c1e 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; +import { useTimefilter } from '../../../contexts/kibana'; import { checkFullLicense } from '../../../license/check_license'; import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check_privilege'; import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; @@ -45,6 +46,8 @@ const PageWrapper: FC = ({ deps }) => { getMlNodeCount, }); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const canCreateCalendar = checkPermission('canCreateCalendar'); const canDeleteCalendar = checkPermission('canDeleteCalendar'); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx index 7f622a1bba62b..7af2e49e3a69e 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; +import { useTimefilter } from '../../../contexts/kibana'; import { checkFullLicense } from '../../../license/check_license'; import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check_privilege'; import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; @@ -77,6 +78,8 @@ const PageWrapper: FC = ({ location, mode, deps }) => { checkMlNodesAvailable, }); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const canCreateCalendar = checkPermission('canCreateCalendar'); const canDeleteCalendar = checkPermission('canDeleteCalendar'); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list.tsx index 6a4ce271bff17..9c5c06b76247c 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; +import { useTimefilter } from '../../../contexts/kibana'; import { checkFullLicense } from '../../../license/check_license'; import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check_privilege'; import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; @@ -46,6 +47,8 @@ const PageWrapper: FC = ({ deps }) => { getMlNodeCount, }); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const canCreateFilter = checkPermission('canCreateFilter'); const canDeleteFilter = checkPermission('canDeleteFilter'); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx index 4fa15ebaac21a..752b889490e58 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; +import { useTimefilter } from '../../../contexts/kibana'; import { checkFullLicense } from '../../../license/check_license'; import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check_privilege'; import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; @@ -77,6 +78,8 @@ const PageWrapper: FC = ({ location, mode, deps }) => { checkMlNodesAvailable, }); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const canCreateFilter = checkPermission('canCreateFilter'); const canDeleteFilter = checkPermission('canDeleteFilter'); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/settings.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/settings.tsx index 846512503ede5..10efb2dcc60c7 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/settings/settings.tsx @@ -14,6 +14,7 @@ import React, { FC } from 'react'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; +import { useTimefilter } from '../../../contexts/kibana'; import { checkFullLicense } from '../../../license/check_license'; import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check_privilege'; import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; @@ -35,6 +36,8 @@ const PageWrapper: FC = ({ deps }) => { getMlNodeCount, }); + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const canGetFilters = checkPermission('canGetFilters'); const canGetCalendars = checkPermission('canGetCalendars'); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx index 0ae42aa44e089..8633947374a8b 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx @@ -12,33 +12,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { TimeSeriesExplorerUrlStateManager } from './timeseriesexplorer'; -jest.mock('ui/new_platform', () => ({ - npStart: { - plugins: { - data: { - query: { - timefilter: { - timefilter: { - enableTimeRangeSelector: jest.fn(), - enableAutoRefreshSelector: jest.fn(), - getRefreshInterval: jest.fn(), - setRefreshInterval: jest.fn(), - getTime: jest.fn(), - isAutoRefreshSelectorEnabled: jest.fn(), - isTimeRangeSelectorEnabled: jest.fn(), - getRefreshIntervalUpdate$: jest.fn(), - getTimeUpdate$: jest.fn(), - getEnabledUpdated$: jest.fn(), - }, - history: { get: jest.fn() }, - }, - }, - }, - }, - }, -})); - -jest.mock('../../contexts/kibana', () => ({ +jest.mock('../../contexts/kibana/kibana_context', () => ({ useMlKibana: () => { return { services: { @@ -47,6 +21,8 @@ jest.mock('../../contexts/kibana', () => ({ query: { timefilter: { timefilter: { + disableTimeRangeSelector: jest.fn(), + disableAutoRefreshSelector: jest.fn(), enableTimeRangeSelector: jest.fn(), enableAutoRefreshSelector: jest.fn(), getRefreshInterval: jest.fn(), diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 5bc2435db078c..f8a6f6c454fc0 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -35,7 +35,7 @@ import { useRefresh } from '../use_refresh'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; -import { useMlKibana } from '../../contexts/kibana'; +import { useTimefilter } from '../../contexts/kibana'; export const timeSeriesExplorerRoute: MlRoute = { path: '/timeseriesexplorer', @@ -88,8 +88,7 @@ export const TimeSeriesExplorerUrlStateManager: FC(); - const { services } = useMlKibana(); - const { timefilter } = services.data.query.timefilter; + const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const refresh = useRefresh(); useEffect(() => { @@ -106,11 +105,6 @@ export const TimeSeriesExplorerUrlStateManager: FC { - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - }, []); - // We cannot simply infer bounds from the globalState's `time` attribute // with `moment` since it can contain custom strings such as `now-15m`. // So when globalState's `time` changes, we update the timefilter and use diff --git a/x-pack/legacy/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/legacy/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 0489528fa0f63..935e67ec05eff 100644 --- a/x-pack/legacy/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/legacy/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -50,9 +50,6 @@ class NewCalendarUI extends Component { } componentDidMount() { - const { timefilter } = this.props.kibana.services.data.query.timefilter; - timefilter.disableTimeRangeSelector(); - timefilter.disableAutoRefreshSelector(); this.formSetup(); } diff --git a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index cf13d329182ba..9317d3c6c3e07 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -450,6 +450,31 @@ export const elasticsearchJsPlugin = (Client, config, components) => { method: 'POST', }); + ml.buckets = ca({ + urls: [ + { + fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/buckets', + req: { + jobId: { + type: 'string', + }, + }, + }, + { + fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/buckets/<%=timestamp%>', + req: { + jobId: { + type: 'string', + }, + timestamp: { + type: 'string', + }, + }, + }, + ], + method: 'POST', + }); + ml.overallBuckets = ca({ url: { fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/overall_buckets', diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/index.js b/x-pack/legacy/plugins/ml/server/models/job_service/index.js index 6f409e70e68b8..70b855e80a770 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/index.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/index.js @@ -8,11 +8,7 @@ import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; import { newJobCapsProvider } from './new_job_caps'; -import { - newJobChartsProvider, - categorizationExamplesProvider, - topCategoriesProvider, -} from './new_job'; +import { newJobChartsProvider, topCategoriesProvider } from './new_job'; export function jobServiceProvider(callAsCurrentUser) { return { @@ -21,7 +17,6 @@ export function jobServiceProvider(callAsCurrentUser) { ...groupsProvider(callAsCurrentUser), ...newJobCapsProvider(callAsCurrentUser), ...newJobChartsProvider(callAsCurrentUser), - ...categorizationExamplesProvider(callAsCurrentUser), ...topCategoriesProvider(callAsCurrentUser), }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index 76473bd55db7f..ea2c71b04f56d 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -17,7 +17,10 @@ import { ValidationResults } from './validation_results'; const CHUNK_SIZE = 100; -export function categorizationExamplesProvider(callWithRequest: callWithRequestType) { +export function categorizationExamplesProvider( + callWithRequest: callWithRequestType, + callWithInternalUser: callWithRequestType +) { const validationResults = new ValidationResults(); async function categorizationExamples( @@ -109,7 +112,7 @@ export function categorizationExamplesProvider(callWithRequest: callWithRequestT } async function loadTokens(examples: string[], analyzer: CategorizationAnalyzer) { - const { tokens }: { tokens: Token[] } = await callWithRequest('indices.analyze', { + const { tokens }: { tokens: Token[] } = await callWithInternalUser('indices.analyze', { body: { ...getAnalyzer(analyzer), text: examples, diff --git a/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts b/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts index b37fcba737802..deb62678a777c 100644 --- a/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts +++ b/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts @@ -6,18 +6,6 @@ import { schema } from '@kbn/config-schema'; -const analyzerSchema = { - tokenizer: schema.string(), - filter: schema.maybe( - schema.arrayOf( - schema.object({ - type: schema.string(), - stopwords: schema.arrayOf(schema.maybe(schema.string())), - }) - ) - ), -}; - export const categorizationFieldExamplesSchema = { indexPatternTitle: schema.string(), query: schema.any(), @@ -26,7 +14,7 @@ export const categorizationFieldExamplesSchema = { timeField: schema.maybe(schema.string()), start: schema.number(), end: schema.number(), - analyzer: schema.object(analyzerSchema), + analyzer: schema.any(), }; export const chartSchema = { diff --git a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts index 64af1f67bce29..927646e4f0acc 100644 --- a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts @@ -36,7 +36,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobs'); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -67,7 +67,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { const { jobId } = request.params; const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobs', { jobId }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -94,7 +94,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobStats'); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -125,7 +125,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { const { jobId } = request.params; const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobStats', { jobId }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -160,7 +160,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { body: request.body, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -195,7 +195,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { body: request.body, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -228,7 +228,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { jobId, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -265,7 +265,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { } const results = await context.ml!.mlClient.callAsCurrentUser('ml.closeJob', options); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -302,7 +302,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { } const results = await context.ml!.mlClient.callAsCurrentUser('ml.deleteJob', options); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -332,7 +332,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { body: request.body, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -368,7 +368,57 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { duration, }); return response.ok({ - body: { ...results }, + body: results, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup AnomalyDetectors + * + * @api {post} /api/ml/anomaly_detectors/:jobId/results/buckets Obtain bucket scores for the specified job ID + * @apiName GetOverallBuckets + * @apiDescription The get buckets API presents a chronological view of the records, grouped by bucket. + * + * @apiParam {String} jobId Job ID. + * @apiParam {String} timestamp. + * + * @apiSuccess {Number} count + * @apiSuccess {Object[]} buckets + */ + router.post( + { + path: '/api/ml/anomaly_detectors/{jobId}/results/buckets/{timestamp?}', + validate: { + params: schema.object({ + jobId: schema.string(), + timestamp: schema.maybe(schema.string()), + }), + body: schema.object({ + anomaly_score: schema.maybe(schema.number()), + desc: schema.maybe(schema.boolean()), + end: schema.maybe(schema.string()), + exclude_interim: schema.maybe(schema.boolean()), + expand: schema.maybe(schema.boolean()), + 'page.from': schema.maybe(schema.number()), + 'page.size': schema.maybe(schema.number()), + sort: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), + }), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const results = await context.ml!.mlClient.callAsCurrentUser('ml.buckets', { + jobId: request.params.jobId, + timestamp: request.params.timestamp, + ...request.body, + }); + return response.ok({ + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -413,7 +463,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { end: request.body.end, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -449,7 +499,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { }; const results = await context.ml!.mlClient.callAsCurrentUser('ml.categories', options); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts index f134820adbb48..6541fa541a59f 100644 --- a/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts @@ -40,7 +40,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.getDataFrameAnalytics'); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -71,7 +71,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti analyticsId, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -97,7 +97,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti 'ml.getDataFrameAnalyticsStats' ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -131,7 +131,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -170,7 +170,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -201,7 +201,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -241,7 +241,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -277,7 +277,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -310,7 +310,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti analyticsId, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -353,7 +353,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti options ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.ts b/x-pack/legacy/plugins/ml/server/routes/job_service.ts index 9aa3960e59e4c..5ddbd4cdfd5a5 100644 --- a/x-pack/legacy/plugins/ml/server/routes/job_service.ts +++ b/x-pack/legacy/plugins/ml/server/routes/job_service.ts @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { schema } from '@kbn/config-schema'; +import { IScopedClusterClient } from 'src/core/server'; import { licensePreRoutingFactory } from '../new_platform/licence_check_pre_routing_factory'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../new_platform/plugin'; +import { isSecurityDisabled } from '../lib/security_utils'; import { categorizationFieldExamplesSchema, chartSchema, @@ -21,11 +24,31 @@ import { } from '../new_platform/job_service_schema'; // @ts-ignore no declaration module import { jobServiceProvider } from '../models/job_service'; +import { categorizationExamplesProvider } from '../models/job_service/new_job'; /** * Routes for job service */ export function jobServiceRoutes({ xpackMainPlugin, router }: RouteInitialization) { + async function hasPermissionToCreateJobs( + callAsCurrentUser: IScopedClusterClient['callAsCurrentUser'] + ) { + if (isSecurityDisabled(xpackMainPlugin) === true) { + return true; + } + + const resp = await callAsCurrentUser('ml.privilegeCheck', { + body: { + cluster: [ + 'cluster:admin/xpack/ml/job/put', + 'cluster:admin/xpack/ml/job/open', + 'cluster:admin/xpack/ml/datafeeds/put', + ], + }, + }); + return resp.has_all_requested; + } + /** * @apiGroup JobService * @@ -545,8 +568,17 @@ export function jobServiceRoutes({ xpackMainPlugin, router }: RouteInitializatio }, licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { try { - const { validateCategoryExamples } = jobServiceProvider( - context.ml!.mlClient.callAsCurrentUser + // due to the use of the _analyze endpoint which is called by the kibana user, + // basic job creation privileges are required to use this endpoint + if ((await hasPermissionToCreateJobs(context.ml!.mlClient.callAsCurrentUser)) === false) { + throw Boom.forbidden( + 'Insufficient privileges, the machine_learning_admin role is required.' + ); + } + + const { validateCategoryExamples } = categorizationExamplesProvider( + context.ml!.mlClient.callAsCurrentUser, + context.ml!.mlClient.callAsInternalUser ); const { indexPatternTitle, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/beats_stats_results.json similarity index 100% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/beats_stats_results.json diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/create_query.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.test.ts similarity index 89% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/create_query.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.test.ts index 63c779ab4b520..a85d084f83d83 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/create_query.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.test.ts @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import { set } from 'lodash'; -import { createTypeFilter, createQuery } from '../create_query.js'; +import { createTypeFilter, createQuery } from './create_query'; describe('Create Type Filter', () => { it('Builds a type filter syntax', () => { const typeFilter = createTypeFilter('my_type'); - expect(typeFilter).to.eql({ + expect(typeFilter).toStrictEqual({ bool: { should: [{ term: { _type: 'my_type' } }, { term: { type: 'my_type' } }] }, }); }); @@ -36,7 +35,7 @@ describe('Create Query', () => { ], }, }; - expect(result).to.be.eql(expected); + expect(result).toStrictEqual(expected); }); it('Uses `type` option to add type filter with minimal fields', () => { @@ -47,7 +46,7 @@ describe('Create Query', () => { { term: { _type: 'test-type-yay' } }, { term: { type: 'test-type-yay' } }, ]); - expect(result).to.be.eql(expected); + expect(result).toStrictEqual(expected); }); it('Uses `type` option to add type filter with all other option fields', () => { @@ -77,6 +76,6 @@ describe('Create Query', () => { ], }, }; - expect(result).to.be.eql(expected); + expect(result).toStrictEqual(expected); }); }); diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.ts similarity index 80% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.ts index 6fcbb677b307d..9a801094458bd 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/create_query.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { defaults } from 'lodash'; import moment from 'moment'; /* @@ -14,7 +13,7 @@ import moment from 'moment'; * TODO: this backwards compatibility helper will only be supported for 5.x-6. This * function should be removed in 7.0 */ -export const createTypeFilter = type => { +export const createTypeFilter = (type: string) => { return { bool: { should: [{ term: { _type: type } }, { term: { type } }], @@ -22,6 +21,18 @@ export const createTypeFilter = type => { }; }; +export interface QueryOptions { + type?: string; + filters?: object[]; + clusterUuid?: string; + start?: string | number; + end?: string | number; +} + +interface RangeFilter { + range: { [key: string]: { format?: string; gte?: string | number; lte?: string | number } }; +} + /* * Creates the boilerplace for querying monitoring data, including filling in * start time and end time, and injecting additional filters. @@ -36,9 +47,8 @@ export const createTypeFilter = type => { * @param {Date} options.start - numeric timestamp (optional) * @param {Date} options.end - numeric timestamp (optional) */ -export function createQuery(options) { - options = defaults(options, { filters: [] }); - const { type, clusterUuid, start, end, filters } = options; +export function createQuery(options: QueryOptions) { + const { type, clusterUuid, start, end, filters = [] } = options; let typeFilter; if (type) { @@ -50,7 +60,7 @@ export function createQuery(options) { clusterUuidFilter = { term: { cluster_uuid: clusterUuid } }; } - let timeRangeFilter; + let timeRangeFilter: RangeFilter | undefined; if (start || end) { timeRangeFilter = { range: { diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_all_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts similarity index 78% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_all_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts index f27fde50242f4..470642f9dd8a3 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_all_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import sinon from 'sinon'; -import { addStackStats, getAllStats, handleAllStats } from '../get_all_stats'; +import { addStackStats, getAllStats, handleAllStats } from './get_all_stats'; +import { ESClusterStats } from './get_es_stats'; +import { KibanaStats } from './get_kibana_stats'; +import { ClustersHighLevelStats } from './get_high_level_stats'; -// FAILING: https://github.com/elastic/kibana/issues/51371 -describe.skip('get_all_stats', () => { +describe('get_all_stats', () => { const size = 123; const start = 0; const end = 1; @@ -100,9 +101,6 @@ describe.skip('get_all_stats', () => { describe('getAllStats', () => { it('returns clusters', async () => { - const clusterUuidsResponse = { - aggregations: { cluster_uuids: { buckets: [{ key: 'a' }] } }, - }; const esStatsResponse = { hits: { hits: [{ _id: 'a', _source: { cluster_uuid: 'a' } }], @@ -177,15 +175,25 @@ describe.skip('get_all_stats', () => { callCluster .withArgs('search') .onCall(0) - .returns(Promise.resolve(clusterUuidsResponse)) - .onCall(1) .returns(Promise.resolve(esStatsResponse)) - .onCall(2) + .onCall(1) .returns(Promise.resolve(kibanaStatsResponse)) + .onCall(2) + .returns(Promise.resolve(logstashStatsResponse)) .onCall(3) - .returns(Promise.resolve(logstashStatsResponse)); + .returns(Promise.resolve({})) // Beats stats + .onCall(4) + .returns(Promise.resolve({})); // Beats state - expect(await getAllStats({ callCluster, server, start, end })).to.eql(allClusters); + expect( + await getAllStats([{ clusterUuid: 'a' }], { + callCluster: callCluster as any, + usageCollection: {} as any, + server, + start, + end, + }) + ).toStrictEqual(allClusters); }); it('returns empty clusters', async () => { @@ -195,21 +203,33 @@ describe.skip('get_all_stats', () => { callCluster.withArgs('search').returns(Promise.resolve(clusterUuidsResponse)); - expect(await getAllStats({ callCluster, server, start, end })).to.eql([]); + expect( + await getAllStats([], { + callCluster: callCluster as any, + usageCollection: {} as any, + server, + start, + end, + }) + ).toStrictEqual([]); }); }); describe('handleAllStats', () => { it('handles response', () => { - const clusters = handleAllStats(esClusters, { kibana: kibanaStats, logstash: logstashStats }); + const clusters = handleAllStats(esClusters as ESClusterStats[], { + kibana: (kibanaStats as unknown) as KibanaStats, + logstash: (logstashStats as unknown) as ClustersHighLevelStats, + beats: {}, + }); - expect(clusters).to.eql(expectedClusters); + expect(clusters).toStrictEqual(expectedClusters); }); it('handles no clusters response', () => { - const clusters = handleAllStats([], {}); + const clusters = handleAllStats([], {} as any); - expect(clusters).to.have.length(0); + expect(clusters).toHaveLength(0); }); }); @@ -230,9 +250,9 @@ describe.skip('get_all_stats', () => { }, }; - addStackStats(cluster, stats, 'xyz'); + addStackStats(cluster as ESClusterStats, stats, 'xyz'); - expect(cluster.stack_stats.xyz).to.be(stats.a); + expect((cluster as any).stack_stats.xyz).toStrictEqual(stats.a); }); }); }); diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.ts similarity index 66% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.ts index 87281a19141ae..aa5e937387daf 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_all_stats.ts @@ -6,22 +6,26 @@ import { get, set, merge } from 'lodash'; +import { StatsGetter } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; import { LOGSTASH_SYSTEM_ID, KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID } from '../../common/constants'; -import { getElasticsearchStats } from './get_es_stats'; -import { getKibanaStats } from './get_kibana_stats'; +import { getElasticsearchStats, ESClusterStats } from './get_es_stats'; +import { getKibanaStats, KibanaStats } from './get_kibana_stats'; import { getBeatsStats } from './get_beats_stats'; import { getHighLevelStats } from './get_high_level_stats'; +type PromiseReturnType any> = ReturnType extends Promise + ? R + : T; + /** * Get statistics for all products joined by Elasticsearch cluster. + * Returns the array of clusters joined with the Kibana and Logstash instances. * - * @param {Object} server The Kibana server instance used to call ES as the internal user - * @param {function} callCluster The callWithRequest or callWithInternalUser handler - * @param {Date} start The starting range to request data - * @param {Date} end The ending range to request data - * @return {Promise} The array of clusters joined with the Kibana and Logstash instances. */ -export async function getAllStats(clustersDetails, { server, callCluster, start, end }) { +export const getAllStats: StatsGetter = async ( + clustersDetails, + { server, callCluster, start, end } +) => { const clusterUuids = clustersDetails.map(clusterDetails => clusterDetails.clusterUuid); const [esClusters, kibana, logstash, beats] = await Promise.all([ @@ -32,7 +36,7 @@ export async function getAllStats(clustersDetails, { server, callCluster, start, ]); return handleAllStats(esClusters, { kibana, logstash, beats }); -} +}; /** * Combine the statistics from the stack to create "cluster" stats that associate all products together based on the cluster @@ -41,9 +45,21 @@ export async function getAllStats(clustersDetails, { server, callCluster, start, * @param {Array} clusters The Elasticsearch clusters * @param {Object} kibana The Kibana instances keyed by Cluster UUID * @param {Object} logstash The Logstash nodes keyed by Cluster UUID - * @return {Array} The clusters joined with the Kibana and Logstash instances under each cluster's {@code stack_stats}. + * + * Returns the clusters joined with the Kibana and Logstash instances under each cluster's {@code stack_stats}. */ -export function handleAllStats(clusters, { kibana, logstash, beats }) { +export function handleAllStats( + clusters: ESClusterStats[], + { + kibana, + logstash, + beats, + }: { + kibana: KibanaStats; + logstash: PromiseReturnType; + beats: PromiseReturnType; + } +) { return clusters.map(cluster => { // if they are using Kibana or Logstash, then add it to the cluster details under cluster.stack_stats addStackStats(cluster, kibana, KIBANA_SYSTEM_ID); @@ -62,8 +78,12 @@ export function handleAllStats(clusters, { kibana, logstash, beats }) { * @param {Object} allProductStats Product stats, keyed by Cluster UUID * @param {String} product The product name being added (e.g., 'kibana' or 'logstash') */ -export function addStackStats(cluster, allProductStats, product) { - const productStats = get(allProductStats, cluster.cluster_uuid); +export function addStackStats( + cluster: ESClusterStats & { stack_stats?: { [product: string]: K } }, + allProductStats: T, + product: string +) { + const productStats = allProductStats[cluster.cluster_uuid]; // Don't add it if they're not using (or configured to report stats) this product for this cluster if (productStats) { @@ -75,12 +95,20 @@ export function addStackStats(cluster, allProductStats, product) { } } -export function mergeXPackStats(cluster, allProductStats, path, product) { +export function mergeXPackStats( + cluster: ESClusterStats & { stack_stats?: { xpack?: { [product: string]: unknown } } }, + allProductStats: T, + path: string, + product: string +) { const productStats = get(allProductStats, cluster.cluster_uuid + '.' + path); if (productStats || productStats === 0) { - if (!get(cluster, 'stack_stats.xpack')) { - set(cluster, 'stack_stats.xpack', {}); + if (!cluster.stack_stats) { + cluster.stack_stats = {}; + } + if (!cluster.stack_stats.xpack) { + cluster.stack_stats.xpack = {}; } const mergeStats = {}; diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts similarity index 75% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts index 522be71555fba..30888e1af3f53 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchBeatsStats, processResults } from '../get_beats_stats'; +import { fetchBeatsStats, processResults } from './get_beats_stats'; import sinon from 'sinon'; -import expect from '@kbn/expect'; -import beatsStatsResultSet from './fixtures/beats_stats_results'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const beatsStatsResultSet = require('./__mocks__/fixtures/beats_stats_results'); const getBaseOptions = () => ({ clusters: {}, @@ -22,8 +22,8 @@ describe('Get Beats Stats', () => { const clusterUuids = ['aCluster', 'bCluster', 'cCluster']; const start = 100; const end = 200; - let server; - let callCluster; + let server = { config: () => ({ get: sinon.stub() }) }; + let callCluster = sinon.stub(); beforeEach(() => { const getStub = { get: sinon.stub() }; @@ -32,34 +32,34 @@ describe('Get Beats Stats', () => { callCluster = sinon.stub(); }); - it('should set `from: 0, to: 10000` in the query', () => { - fetchBeatsStats(server, callCluster, clusterUuids, start, end); + it('should set `from: 0, to: 10000` in the query', async () => { + await fetchBeatsStats(server, callCluster, clusterUuids, start, end, {} as any); const { args } = callCluster.firstCall; const [api, { body }] = args; - expect(api).to.be('search'); - expect(body.from).to.be(0); - expect(body.size).to.be(10000); + expect(api).toEqual('search'); + expect(body.from).toEqual(0); + expect(body.size).toEqual(10000); }); - it('should set `from: 10000, from: 10000` in the query', () => { - fetchBeatsStats(server, callCluster, clusterUuids, start, end, { page: 1 }); + it('should set `from: 10000, from: 10000` in the query', async () => { + await fetchBeatsStats(server, callCluster, clusterUuids, start, end, { page: 1 } as any); const { args } = callCluster.firstCall; const [api, { body }] = args; - expect(api).to.be('search'); - expect(body.from).to.be(10000); - expect(body.size).to.be(10000); + expect(api).toEqual('search'); + expect(body.from).toEqual(10000); + expect(body.size).toEqual(10000); }); - it('should set `from: 20000, from: 10000` in the query', () => { - fetchBeatsStats(server, callCluster, clusterUuids, start, end, { page: 2 }); + it('should set `from: 20000, from: 10000` in the query', async () => { + await fetchBeatsStats(server, callCluster, clusterUuids, start, end, { page: 2 } as any); const { args } = callCluster.firstCall; const [api, { body }] = args; - expect(api).to.be('search'); - expect(body.from).to.be(20000); - expect(body.size).to.be(10000); + expect(api).toEqual('search'); + expect(body.from).toEqual(20000); + expect(body.size).toEqual(10000); }); }); @@ -68,9 +68,9 @@ describe('Get Beats Stats', () => { const resultsEmpty = undefined; const options = getBaseOptions(); - processResults(resultsEmpty, options); + processResults(resultsEmpty as any, options); - expect(options.clusters).to.eql({}); + expect(options.clusters).toStrictEqual({}); }); it('should summarize single result with some missing fields', () => { @@ -92,9 +92,9 @@ describe('Get Beats Stats', () => { }; const options = getBaseOptions(); - processResults(results, options); + processResults(results as any, options); - expect(options.clusters).to.eql({ + expect(options.clusters).toStrictEqual({ FlV4ckTxQ0a78hmBkzzc9A: { count: 1, versions: {}, @@ -122,11 +122,11 @@ describe('Get Beats Stats', () => { const options = getBaseOptions(); // beatsStatsResultSet is an array of many small query results - beatsStatsResultSet.forEach(results => { + beatsStatsResultSet.forEach((results: any) => { processResults(results, options); }); - expect(options.clusters).to.eql({ + expect(options.clusters).toStrictEqual({ W7hppdX7R229Oy3KQbZrTw: { count: 5, versions: { '7.0.0-alpha1': 5 }, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts similarity index 60% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts index 5722228b60207..975a3bfee6333 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts @@ -5,6 +5,8 @@ */ import { get } from 'lodash'; +import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; +import { SearchResponse } from 'elasticsearch'; import { createQuery } from './create_query'; import { INDEX_PATTERN_BEATS } from '../../common/constants'; @@ -33,6 +35,107 @@ const getBaseStats = () => ({ }, }); +export interface BeatsStats { + cluster_uuid: string; + beats_stats?: { + beat?: { + version?: string; + type?: string; + host?: string; + }; + metrics?: { + libbeat?: { + output?: { + type?: string; + }; + pipeline?: { + events?: { + published?: number; + }; + }; + }; + }; + }; + beats_state?: { + beat?: { + type?: string; + }; + state?: { + input?: { + names: string[]; + count: number; + }; + module?: { + names: string[]; + count: number; + }; + heartbeat?: HeartbeatBase; + functionbeat?: { + functions?: { + count?: number; + }; + }; + host?: { + architecture: string; + os: { platform: string }; + }; + }; + }; +} + +interface HeartbeatBase { + monitors: number; + endpoints: number; + // I have to add the '| number' bit because otherwise TS complains about 'monitors' and 'endpoints' not being of type HeartbeatBase + [key: string]: HeartbeatBase | number | undefined; +} + +export interface BeatsBaseStats { + // stats + versions: { [version: string]: number }; + types: { [type: string]: number }; + outputs: { [outputType: string]: number }; + count: number; + eventsPublished: number; + hosts: number; + // state + input: { + count: number; + names: string[]; + }; + module: { + count: number; + names: string[]; + }; + architecture: { + count: number; + architectures: BeatsArchitecture[]; + }; + heartbeat?: HeartbeatBase; + functionbeat?: { + functions: { + count: number; + }; + }; +} + +export interface BeatsProcessOptions { + clusters: { [clusterUuid: string]: BeatsBaseStats }; // the result object to be built up + clusterHostSets: { [clusterUuid: string]: Set }; // passed to processResults for tracking state in the results generation + clusterInputSets: { [clusterUuid: string]: Set }; // passed to processResults for tracking state in the results generation + clusterModuleSets: { [clusterUuid: string]: Set }; // passed to processResults for tracking state in the results generation + clusterArchitectureMaps: { + // passed to processResults for tracking state in the results generation + [clusterUuid: string]: Map; + }; +} + +export interface BeatsArchitecture { + name: string; + architecture: string; + count: number; +} + /* * Update a clusters object with processed beat stats * @param {Array} results - array of Beats docs from ES @@ -41,12 +144,18 @@ const getBaseStats = () => ({ * @param {Object} clusterModuleSets - the object keyed by cluster UUIDs to count the unique modules */ export function processResults( - results = [], - { clusters, clusterHostSets, clusterInputSets, clusterModuleSets, clusterArchitectureMaps } + results: SearchResponse, + { + clusters, + clusterHostSets, + clusterInputSets, + clusterModuleSets, + clusterArchitectureMaps, + }: BeatsProcessOptions ) { - const currHits = get(results, 'hits.hits', []); + const currHits = results?.hits?.hits || []; currHits.forEach(hit => { - const clusterUuid = get(hit, '_source.cluster_uuid'); + const clusterUuid = hit._source.cluster_uuid; if (clusters[clusterUuid] === undefined) { clusters[clusterUuid] = getBaseStats(); clusterHostSets[clusterUuid] = new Set(); @@ -57,30 +166,30 @@ export function processResults( const processBeatsStatsResults = () => { const { versions, types, outputs } = clusters[clusterUuid]; - const thisVersion = get(hit, '_source.beats_stats.beat.version'); + const thisVersion = hit._source.beats_stats?.beat?.version; if (thisVersion !== undefined) { const thisVersionAccum = versions[thisVersion] || 0; versions[thisVersion] = thisVersionAccum + 1; } - const thisType = get(hit, '_source.beats_stats.beat.type'); + const thisType = hit._source.beats_stats?.beat?.type; if (thisType !== undefined) { const thisTypeAccum = types[thisType] || 0; types[thisType] = thisTypeAccum + 1; } - const thisOutput = get(hit, '_source.beats_stats.metrics.libbeat.output.type'); + const thisOutput = hit._source.beats_stats?.metrics?.libbeat?.output?.type; if (thisOutput !== undefined) { const thisOutputAccum = outputs[thisOutput] || 0; outputs[thisOutput] = thisOutputAccum + 1; } - const thisEvents = get(hit, '_source.beats_stats.metrics.libbeat.pipeline.events.published'); + const thisEvents = hit._source.beats_stats?.metrics?.libbeat?.pipeline?.events?.published; if (thisEvents !== undefined) { clusters[clusterUuid].eventsPublished += thisEvents; } - const thisHost = get(hit, '_source.beats_stats.beat.host'); + const thisHost = hit._source.beats_stats?.beat?.host; if (thisHost !== undefined) { const hostsMap = clusterHostSets[clusterUuid]; hostsMap.add(thisHost); @@ -89,7 +198,7 @@ export function processResults( }; const processBeatsStateResults = () => { - const stateInput = get(hit, '_source.beats_state.state.input'); + const stateInput = hit._source.beats_state?.state?.input; if (stateInput !== undefined) { const inputSet = clusterInputSets[clusterUuid]; stateInput.names.forEach(name => inputSet.add(name)); @@ -97,8 +206,8 @@ export function processResults( clusters[clusterUuid].input.count += stateInput.count; } - const stateModule = get(hit, '_source.beats_state.state.module'); - const statsType = get(hit, '_source.beats_state.beat.type'); + const stateModule = hit._source.beats_state?.state?.module; + const statsType = hit._source.beats_state?.beat?.type; if (stateModule !== undefined) { const moduleSet = clusterModuleSets[clusterUuid]; stateModule.names.forEach(name => moduleSet.add(statsType + '.' + name)); @@ -106,7 +215,7 @@ export function processResults( clusters[clusterUuid].module.count += stateModule.count; } - const heartbeatState = get(hit, '_source.beats_state.state.heartbeat'); + const heartbeatState = hit._source.beats_state?.state?.heartbeat; if (heartbeatState !== undefined) { if (!clusters[clusterUuid].hasOwnProperty('heartbeat')) { clusters[clusterUuid].heartbeat = { @@ -114,7 +223,7 @@ export function processResults( endpoints: 0, }; } - const clusterHb = clusters[clusterUuid].heartbeat; + const clusterHb = clusters[clusterUuid].heartbeat!; clusterHb.monitors += heartbeatState.monitors; clusterHb.endpoints += heartbeatState.endpoints; @@ -133,12 +242,12 @@ export function processResults( endpoints: 0, }; } - clusterHb[proto].monitors += val.monitors; - clusterHb[proto].endpoints += val.endpoints; + (clusterHb[proto] as HeartbeatBase).monitors += val.monitors; + (clusterHb[proto] as HeartbeatBase).endpoints += val.endpoints; } } - const functionbeatState = get(hit, '_source.beats_state.state.functionbeat'); + const functionbeatState = hit._source.beats_state?.state?.functionbeat; if (functionbeatState !== undefined) { if (!clusters[clusterUuid].hasOwnProperty('functionbeat')) { clusters[clusterUuid].functionbeat = { @@ -148,14 +257,11 @@ export function processResults( }; } - clusters[clusterUuid].functionbeat.functions.count += get( - functionbeatState, - 'functions.count', - 0 - ); + clusters[clusterUuid].functionbeat!.functions.count += + functionbeatState.functions?.count || 0; } - const stateHost = get(hit, '_source.beats_state.state.host'); + const stateHost = hit._source.beats_state?.state?.host; if (stateHost !== undefined) { const hostMap = clusterArchitectureMaps[clusterUuid]; const hostKey = `${stateHost.architecture}/${stateHost.os.platform}`; @@ -198,14 +304,14 @@ export function processResults( * @return {Promise} */ async function fetchBeatsByType( - server, - callCluster, - clusterUuids, - start, - end, - { page = 0, ...options } = {}, - type -) { + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'], + end: StatsCollectionConfig['end'], + { page = 0, ...options }: { page?: number } & BeatsProcessOptions, + type: string +): Promise { const params = { index: INDEX_PATTERN_BEATS, ignoreUnavailable: true, @@ -232,7 +338,7 @@ async function fetchBeatsByType( { bool: { must_not: { term: { [`${type}.beat.type`]: 'apm-server' } }, - must: { term: { type: type } }, + must: { term: { type } }, }, }, ], @@ -244,8 +350,8 @@ async function fetchBeatsByType( }, }; - const results = await callCluster('search', params); - const hitsLength = get(results, 'hits.hits.length', 0); + const results = await callCluster>('search', params); + const hitsLength = results?.hits?.hits.length || 0; if (hitsLength > 0) { // further augment the clusters object with more stats processResults(results, options); @@ -265,20 +371,40 @@ async function fetchBeatsByType( return Promise.resolve(); } -export async function fetchBeatsStats(...args) { - return fetchBeatsByType(...args, 'beats_stats'); +export async function fetchBeatsStats( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'], + end: StatsCollectionConfig['end'], + options: { page?: number } & BeatsProcessOptions +) { + return fetchBeatsByType(server, callCluster, clusterUuids, start, end, options, 'beats_stats'); } -export async function fetchBeatsStates(...args) { - return fetchBeatsByType(...args, 'beats_state'); +export async function fetchBeatsStates( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'], + end: StatsCollectionConfig['end'], + options: { page?: number } & BeatsProcessOptions +) { + return fetchBeatsByType(server, callCluster, clusterUuids, start, end, options, 'beats_state'); } /* * Call the function for fetching and summarizing beats stats * @return {Object} - Beats stats in an object keyed by the cluster UUIDs */ -export async function getBeatsStats(server, callCluster, clusterUuids, start, end) { - const options = { +export async function getBeatsStats( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'], + end: StatsCollectionConfig['end'] +) { + const options: BeatsProcessOptions = { clusters: {}, // the result object to be built up clusterHostSets: {}, // passed to processResults for tracking state in the results generation clusterInputSets: {}, // passed to processResults for tracking state in the results generation diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_cluster_uuids.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts similarity index 77% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_cluster_uuids.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts index 1f62c677dbb21..4f952b9dec6da 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_cluster_uuids.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import sinon from 'sinon'; import { getClusterUuids, fetchClusterUuids, handleClusterUuidsResponse, -} from '../get_cluster_uuids'; +} from './get_cluster_uuids'; describe('get_cluster_uuids', () => { const callCluster = sinon.stub(); @@ -35,20 +34,24 @@ describe('get_cluster_uuids', () => { const expectedUuids = response.aggregations.cluster_uuids.buckets .map(bucket => bucket.key) .map(expectedUuid => ({ clusterUuid: expectedUuid })); - const start = new Date(); - const end = new Date(); + const start = new Date().toISOString(); + const end = new Date().toISOString(); describe('getClusterUuids', () => { it('returns cluster UUIDs', async () => { callCluster.withArgs('search').returns(Promise.resolve(response)); - expect(await getClusterUuids({ server, callCluster, start, end })).to.eql(expectedUuids); + expect( + await getClusterUuids({ server, callCluster, start, end, usageCollection: {} as any }) + ).toStrictEqual(expectedUuids); }); }); describe('fetchClusterUuids', () => { it('searches for clusters', async () => { callCluster.returns(Promise.resolve(response)); - expect(await fetchClusterUuids({ server, callCluster, start, end })).to.be(response); + expect( + await fetchClusterUuids({ server, callCluster, start, end, usageCollection: {} as any }) + ).toStrictEqual(response); }); }); @@ -56,12 +59,12 @@ describe('get_cluster_uuids', () => { // filterPath makes it easy to ignore anything unexpected because it will come back empty it('handles unexpected response', () => { const clusterUuids = handleClusterUuidsResponse({}); - expect(clusterUuids.length).to.be(0); + expect(clusterUuids.length).toStrictEqual(0); }); it('handles valid response', () => { const clusterUuids = handleClusterUuidsResponse(response); - expect(clusterUuids).to.eql(expectedUuids); + expect(clusterUuids).toStrictEqual(expectedUuids); }); it('handles no buckets response', () => { @@ -73,7 +76,7 @@ describe('get_cluster_uuids', () => { }, }); - expect(clusterUuids.length).to.be(0); + expect(clusterUuids.length).toStrictEqual(0); }); }); }); diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_es_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts similarity index 82% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_es_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts index 536e831640fad..70ed2240b47d4 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_es_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import sinon from 'sinon'; import { fetchElasticsearchStats, getElasticsearchStats, handleElasticsearchStats, -} from '../get_es_stats'; +} from './get_es_stats'; describe('get_es_stats', () => { const callWith = sinon.stub(); @@ -41,7 +40,9 @@ describe('get_es_stats', () => { it('returns clusters', async () => { callWith.withArgs('search').returns(Promise.resolve(response)); - expect(await getElasticsearchStats(server, callWith, clusterUuids)).to.eql(expectedClusters); + expect(await getElasticsearchStats(server, callWith, clusterUuids)).toStrictEqual( + expectedClusters + ); }); }); @@ -49,28 +50,28 @@ describe('get_es_stats', () => { it('searches for clusters', async () => { callWith.returns(response); - expect(await fetchElasticsearchStats(server, callWith, clusterUuids)).to.be(response); + expect(await fetchElasticsearchStats(server, callWith, clusterUuids)).toStrictEqual(response); }); }); describe('handleElasticsearchStats', () => { // filterPath makes it easy to ignore anything unexpected because it will come back empty it('handles unexpected response', () => { - const clusters = handleElasticsearchStats({}); + const clusters = handleElasticsearchStats({} as any); - expect(clusters.length).to.be(0); + expect(clusters.length).toStrictEqual(0); }); it('handles valid response', () => { - const clusters = handleElasticsearchStats(response); + const clusters = handleElasticsearchStats(response as any); - expect(clusters).to.eql(expectedClusters); + expect(clusters).toStrictEqual(expectedClusters); }); it('handles no hits response', () => { - const clusters = handleElasticsearchStats({ hits: { hits: [] } }); + const clusters = handleElasticsearchStats({ hits: { hits: [] } } as any); - expect(clusters.length).to.be(0); + expect(clusters.length).toStrictEqual(0); }); }); }); diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.ts similarity index 71% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.ts index 52d34258b5fa4..f0ae1163d3f52 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_es_stats.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; +import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; +import { SearchResponse } from 'elasticsearch'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; /** @@ -13,10 +14,14 @@ import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; * @param {Object} server The server instance * @param {function} callCluster The callWithRequest or callWithInternalUser handler * @param {Array} clusterUuids The string Cluster UUIDs to fetch details for - * @return {Promise} Array of the Elasticsearch clusters. */ -export function getElasticsearchStats(server, callCluster, clusterUuids) { - return fetchElasticsearchStats(server, callCluster, clusterUuids).then(handleElasticsearchStats); +export async function getElasticsearchStats( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[] +) { + const response = await fetchElasticsearchStats(server, callCluster, clusterUuids); + return handleElasticsearchStats(response); } /** @@ -25,9 +30,14 @@ export function getElasticsearchStats(server, callCluster, clusterUuids) { * @param {Object} server The server instance * @param {function} callCluster The callWithRequest or callWithInternalUser handler * @param {Array} clusterUuids Cluster UUIDs to limit the request against - * @return {Promise} Response for the aggregations to fetch details for the product. + * + * Returns the response for the aggregations to fetch details for the product. */ -export function fetchElasticsearchStats(server, callCluster, clusterUuids) { +export function fetchElasticsearchStats( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[] +) { const config = server.config(); const params = { index: INDEX_PATTERN_ELASTICSEARCH, @@ -67,13 +77,16 @@ export function fetchElasticsearchStats(server, callCluster, clusterUuids) { return callCluster('search', params); } +export interface ESClusterStats { + cluster_uuid: string; + type: 'cluster_stats'; +} + /** * Extract the cluster stats for each cluster. - * - * @return {Array} The Elasticsearch clusters. */ -export function handleElasticsearchStats(response) { - const clusters = get(response, 'hits.hits', []); +export function handleElasticsearchStats(response: SearchResponse) { + const clusters = response.hits?.hits || []; return clusters.map(cluster => cluster._source); } diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_high_level_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts similarity index 91% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_high_level_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts index 1c1f8dc888d01..76c80e2eb3d37 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_high_level_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import sinon from 'sinon'; import { fetchHighLevelStats, getHighLevelStats, handleHighLevelStatsResponse, -} from '../get_high_level_stats'; +} from './get_high_level_stats'; describe('get_high_level_stats', () => { const callWith = sinon.stub(); @@ -244,9 +243,9 @@ describe('get_high_level_stats', () => { it('returns clusters', async () => { callWith.withArgs('search').returns(Promise.resolve(response)); - expect(await getHighLevelStats(server, callWith, clusterUuids, start, end, product)).to.eql( - expectedClusters - ); + expect( + await getHighLevelStats(server, callWith, clusterUuids, start, end, product) + ).toStrictEqual(expectedClusters); }); }); @@ -254,30 +253,30 @@ describe('get_high_level_stats', () => { it('searches for clusters', async () => { callWith.returns(Promise.resolve(response)); - expect(await fetchHighLevelStats(server, callWith, clusterUuids, start, end, product)).to.be( - response - ); + expect( + await fetchHighLevelStats(server, callWith, clusterUuids, start, end, product) + ).toStrictEqual(response); }); }); describe('handleHighLevelStatsResponse', () => { // filterPath makes it easy to ignore anything unexpected because it will come back empty it('handles unexpected response', () => { - const clusters = handleHighLevelStatsResponse({}, product); + const clusters = handleHighLevelStatsResponse({} as any, product); - expect(clusters).to.eql({}); + expect(clusters).toStrictEqual({}); }); it('handles valid response', () => { - const clusters = handleHighLevelStatsResponse(response, product); + const clusters = handleHighLevelStatsResponse(response as any, product); - expect(clusters).to.eql(expectedClusters); + expect(clusters).toStrictEqual(expectedClusters); }); it('handles no hits response', () => { - const clusters = handleHighLevelStatsResponse({ hits: { hits: [] } }, product); + const clusters = handleHighLevelStatsResponse({ hits: { hits: [] } } as any, product); - expect(clusters).to.eql({}); + expect(clusters).toStrictEqual({}); }); }); }); diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts similarity index 66% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts index b87f632308e4d..f67f80940d9f4 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts @@ -5,6 +5,8 @@ */ import { get } from 'lodash'; +import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; +import { SearchResponse } from 'elasticsearch'; import { createQuery } from './create_query'; import { INDEX_PATTERN_KIBANA, @@ -17,13 +19,40 @@ import { TELEMETRY_QUERY_SOURCE, } from '../../common/constants'; +export interface ClusterCloudStats { + name: string; + count: number; + vms: number; + regions: Array<{ region: string; count: number }>; + vm_types: Array<{ vm_type: string; count: number }>; + zones: Array<{ zone: string; count: number }>; +} + +export interface ClusterHighLevelStats { + count: number; + versions: Array<{ version: string; count: number }>; + os: { + platforms: Array<{ platform: string; count: number }>; + platformReleases: Array<{ platformRelease: string; count: number }>; + distros: Array<{ distro: string; count: number }>; + distroReleases: Array<{ distroRelease: string; count: number }>; + }; + cloud: ClusterCloudStats[] | undefined; +} + +export interface ClustersHighLevelStats { + [clusterUuid: string]: ClusterHighLevelStats; +} + +type Counter = Map; + /** * Update a counter associated with the {@code key}. * * @param {Map} map Map to update the counter for the {@code key}. * @param {String} key The key to increment a counter for. */ -function incrementByKey(map, key) { +function incrementByKey(map: Counter, key?: string) { if (!key) { return; } @@ -37,13 +66,29 @@ function incrementByKey(map, key) { map.set(key, count + 1); } +interface InternalCloudMap { + count: number; + unique: Set; + vm_type: Counter; + region: Counter; + zone: Counter; +} + +interface CloudEntry { + id: string; + name: string; + vm_type: string; + region: string; + zone: string; +} + /** * Help to reduce Cloud metrics into unidentifiable metrics (e.g., count IDs so that they can be dropped). * * @param {Map} clouds Existing cloud data by cloud name. * @param {Object} cloud Cloud object loaded from Elasticsearch data. */ -function reduceCloudForCluster(cloudMap, cloud) { +function reduceCloudForCluster(cloudMap: Map, cloud?: CloudEntry) { if (!cloud) { return; } @@ -74,22 +119,48 @@ function reduceCloudForCluster(cloudMap, cloud) { incrementByKey(cloudByName.zone, cloud.zone); } +interface InternalClusterMap { + count: number; + versions: Counter; + cloudMap: Map; + os: { + platforms: Counter; + platformReleases: Counter; + distros: Counter; + distroReleases: Counter; + }; +} + +interface OSData { + platform?: string; + platformRelease?: string; + distro?: string; + distroRelease?: string; +} + /** * Group the instances (hits) by clusters. * * @param {Array} instances Array of hits from the request containing the cluster UUID and version. * @param {String} product The product to limit too ('kibana', 'logstash', 'beats') - * @return {Map} A map of the Cluster UUID to an {@link Object} containing the {@code count} and {@code versions} {@link Map} + * + * Returns a map of the Cluster UUID to an {@link Object} containing the {@code count} and {@code versions} {@link Map} */ -function groupInstancesByCluster(instances, product) { - const clusterMap = new Map(); +function groupInstancesByCluster( + instances: Array<{ _source: T }>, + product: string +) { + const clusterMap = new Map(); // hits are sorted arbitrarily by product UUID instances.map(instance => { - const clusterUuid = get(instance, '_source.cluster_uuid'); - const version = get(instance, `_source.${product}_stats.${product}.version`); - const cloud = get(instance, `_source.${product}_stats.cloud`); - const os = get(instance, `_source.${product}_stats.os`); + const clusterUuid = instance._source.cluster_uuid; + const version: string | undefined = get( + instance, + `_source.${product}_stats.${product}.version` + ); + const cloud: CloudEntry | undefined = get(instance, `_source.${product}_stats.cloud`); + const os: OSData | undefined = get(instance, `_source.${product}_stats.os`); if (clusterUuid) { let cluster = clusterMap.get(clusterUuid); @@ -134,16 +205,12 @@ function groupInstancesByCluster(instances, product) { * { [keyName]: key1, count: value1 }, * { [keyName]: key2, count: value2 } * ] - * - * @param {Map} map [description] - * @param {String} keyName [description] - * @return {Array} [description] */ -function mapToList(map, keyName) { - const list = []; +function mapToList(map: Map, keyName: string): T[] { + const list: T[] = []; for (const [key, count] of map) { - list.push({ [keyName]: key, count }); + list.push(({ [keyName]: key, count } as unknown) as T); } return list; @@ -154,7 +221,7 @@ function mapToList(map, keyName) { * * @param {*} product The product id, which should be in the constants file */ -function getIndexPatternForStackProduct(product) { +function getIndexPatternForStackProduct(product: string) { switch (product) { case KIBANA_SYSTEM_ID: return INDEX_PATTERN_KIBANA; @@ -176,23 +243,41 @@ function getIndexPatternForStackProduct(product) { * @param {Date} start Start time to limit the stats * @param {Date} end End time to limit the stats * @param {String} product The product to limit too ('kibana', 'logstash', 'beats') - * @return {Promise} Object keyed by the cluster UUIDs to make grouping easier. + * + * Returns an object keyed by the cluster UUIDs to make grouping easier. */ -export function getHighLevelStats(server, callCluster, clusterUuids, start, end, product) { - return fetchHighLevelStats( +export async function getHighLevelStats( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'], + end: StatsCollectionConfig['end'], + product: string +) { + const response = await fetchHighLevelStats( server, callCluster, clusterUuids, start, end, product - ).then(response => handleHighLevelStatsResponse(response, product)); + ); + return handleHighLevelStatsResponse(response, product); } -export async function fetchHighLevelStats(server, callCluster, clusterUuids, start, end, product) { +export async function fetchHighLevelStats< + T extends { cluster_uuid?: string } = { cluster_uuid?: string } +>( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'] | undefined, + end: StatsCollectionConfig['end'] | undefined, + product: string +): Promise> { const config = server.config(); const isKibanaIndex = product === KIBANA_SYSTEM_ID; - const filters = [{ terms: { cluster_uuid: clusterUuids } }]; + const filters: object[] = [{ terms: { cluster_uuid: clusterUuids } }]; // we should supply this from a parameter in the future so that this remains generic if (isKibanaIndex) { @@ -257,13 +342,17 @@ export async function fetchHighLevelStats(server, callCluster, clusterUuids, sta * * @param {Object} response The response from the aggregation * @param {String} product The product to limit too ('kibana', 'logstash', 'beats') - * @return {Object} Object keyed by the cluster UUIDs to make grouping easier. + * + * Returns an object keyed by the cluster UUIDs to make grouping easier. */ -export function handleHighLevelStatsResponse(response, product) { - const instances = get(response, 'hits.hits', []); +export function handleHighLevelStatsResponse( + response: SearchResponse<{ cluster_uuid?: string }>, + product: string +) { + const instances = response.hits?.hits || []; const clusterMap = groupInstancesByCluster(instances, product); - const clusters = {}; + const clusters: ClustersHighLevelStats = {}; for (const [clusterUuid, cluster] of clusterMap) { // it's unlikely this will be an array of more than one, but it is one just incase @@ -271,14 +360,15 @@ export function handleHighLevelStatsResponse(response, product) { // remap the clouds (most likely singular or empty) for (const [name, cloud] of cluster.cloudMap) { - clouds.push({ + const cloudStats: ClusterCloudStats = { name, count: cloud.count, vms: cloud.unique.size, regions: mapToList(cloud.region, 'region'), vm_types: mapToList(cloud.vm_type, 'vm_type'), zones: mapToList(cloud.zone, 'zone'), - }); + }; + clouds.push(cloudStats); } // map stats for product by cluster so that it can be joined with ES cluster stats diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_kibana_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.test.ts similarity index 79% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_kibana_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.test.ts index 98e0afa28fba3..0092e848c827b 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_kibana_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.test.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getUsageStats, combineStats, rollUpTotals, ensureTimeSpan } from '../get_kibana_stats'; -import expect from '@kbn/expect'; +import { + getUsageStats, + combineStats, + rollUpTotals, + ensureTimeSpan, + KibanaUsageStats, +} from './get_kibana_stats'; +import { SearchResponse } from 'elasticsearch'; describe('Get Kibana Stats', () => { describe('Make a map of usage stats for each cluster', () => { - it('passes through if there are no kibana instances', () => { - const rawStats = {}; - expect(getUsageStats(rawStats)).to.eql({}); + test('passes through if there are no kibana instances', () => { + const rawStats = {} as SearchResponse; + expect(getUsageStats(rawStats)).toStrictEqual({}); }); describe('with single cluster', () => { describe('single index', () => { - it('for a single unused instance', () => { + test('for a single unused instance', () => { const rawStats = { hits: { hits: [ @@ -39,7 +45,7 @@ describe('Get Kibana Stats', () => { }, ], }, - }; + } as any; const expected = { clusterone: { dashboard: { total: 0 }, @@ -53,10 +59,10 @@ describe('Get Kibana Stats', () => { }, }; - expect(getUsageStats(rawStats)).to.eql(expected); + expect(getUsageStats(rawStats)).toStrictEqual(expected); }); - it('for a single instance of active usage', () => { + test('for a single instance of active usage', () => { const rawStats = { hits: { hits: [ @@ -79,7 +85,7 @@ describe('Get Kibana Stats', () => { }, ], }, - }; + } as any; const expected = { clusterone: { dashboard: { total: 1 }, @@ -92,11 +98,49 @@ describe('Get Kibana Stats', () => { plugins: {}, }, }; + expect(getUsageStats(rawStats)).toStrictEqual(expected); + }); - expect(getUsageStats(rawStats)).to.eql(expected); + test('it merges the plugin stats and kibana', () => { + const rawStats = { + hits: { + hits: [ + { + _source: { + cluster_uuid: 'clusterone', + kibana_stats: { + kibana: { version: '7.0.0-alpha1-test02' }, + usage: { + dashboard: { total: 1 }, + visualization: { total: 3 }, + search: { total: 1 }, + index_pattern: { total: 1 }, + graph_workspace: { total: 1 }, + timelion_sheet: { total: 1 }, + index: '.kibana-test-01', + }, + }, + }, + }, + ], + }, + } as any; + const expected = { + clusterone: { + dashboard: { total: 1 }, + visualization: { total: 3 }, + search: { total: 1 }, + index_pattern: { total: 1 }, + graph_workspace: { total: 1 }, + timelion_sheet: { total: 1 }, + indices: 1, + plugins: {}, + }, + }; + expect(getUsageStats(rawStats)).toStrictEqual(expected); }); - it('flattens x-pack stats', () => { + test('flattens x-pack stats', () => { const rawStats = { hits: { hits: [ @@ -126,8 +170,9 @@ describe('Get Kibana Stats', () => { }, ], }, - }; - expect(getUsageStats(rawStats)).to.eql({ + } as any; + + expect(getUsageStats(rawStats)).toStrictEqual({ clusterone: { dashboard: { total: 1 }, visualization: { total: 3 }, @@ -143,7 +188,7 @@ describe('Get Kibana Stats', () => { }); describe('separate indices', () => { - it('with one unused instance', () => { + test('with one unused instance', () => { const rawStats = { hits: { hits: [ @@ -200,7 +245,7 @@ describe('Get Kibana Stats', () => { }, ], }, - }; + } as any; const expected = { clusterone: { dashboard: { total: 1 }, @@ -213,11 +258,10 @@ describe('Get Kibana Stats', () => { plugins: {}, }, }; - - expect(getUsageStats(rawStats)).to.eql(expected); + expect(getUsageStats(rawStats)).toStrictEqual(expected); }); - it('with all actively used instances', () => { + test('with all actively used instances', () => { const rawStats = { hits: { hits: [ @@ -274,7 +318,7 @@ describe('Get Kibana Stats', () => { }, ], }, - }; + } as any; const expected = { clusterone: { dashboard: { total: 4 }, @@ -287,15 +331,14 @@ describe('Get Kibana Stats', () => { plugins: {}, }, }; - - expect(getUsageStats(rawStats)).to.eql(expected); + expect(getUsageStats(rawStats)).toStrictEqual(expected); }); }); }); describe('with multiple clusters', () => { describe('separate indices', () => { - it('with all actively used instances', () => { + test('with all actively used instances', () => { const rawStats = { hits: { hits: [ @@ -369,7 +412,7 @@ describe('Get Kibana Stats', () => { }, ], }, - }; + } as any; const expected = { clusterone: { dashboard: { total: 4 }, @@ -392,29 +435,28 @@ describe('Get Kibana Stats', () => { plugins: {}, }, }; - - expect(getUsageStats(rawStats)).to.eql(expected); + expect(getUsageStats(rawStats)).toStrictEqual(expected); }); }); }); }); describe('Combines usage stats with high-level stats', () => { - it('passes through if there are no kibana instances', () => { + test('passes through if there are no kibana instances', () => { const highLevelStats = {}; const usageStats = {}; - expect(combineStats(highLevelStats, usageStats)).to.eql({}); + expect(combineStats(highLevelStats, usageStats)).toStrictEqual({}); }); describe('adds usage stats to high-level stats', () => { - it('for a single cluster', () => { + test('for a single cluster', () => { const highLevelStats = { clusterone: { count: 2, versions: [{ count: 2, version: '7.0.0-alpha1-test12' }], }, - }; + } as any; const usageStats = { clusterone: { dashboard: { total: 1 }, @@ -428,7 +470,7 @@ describe('Get Kibana Stats', () => { }, }; - expect(combineStats(highLevelStats, usageStats)).to.eql({ + expect(combineStats(highLevelStats, usageStats)).toStrictEqual({ clusterone: { count: 2, dashboard: { total: 1 }, @@ -444,7 +486,7 @@ describe('Get Kibana Stats', () => { }); }); - it('for multiple single clusters', () => { + test('for multiple single clusters', () => { const highLevelStats = { clusterone: { count: 2, @@ -454,7 +496,7 @@ describe('Get Kibana Stats', () => { count: 1, versions: [{ count: 1, version: '7.0.0-alpha1-test14' }], }, - }; + } as any; const usageStats = { clusterone: { dashboard: { total: 1 }, @@ -478,7 +520,7 @@ describe('Get Kibana Stats', () => { }, }; - expect(combineStats(highLevelStats, usageStats)).to.eql({ + expect(combineStats(highLevelStats, usageStats)).toStrictEqual({ clusterone: { count: 2, dashboard: { total: 1 }, @@ -508,16 +550,16 @@ describe('Get Kibana Stats', () => { }); describe('if usage stats are empty', () => { - it('returns just high-level stats', () => { + test('returns just high-level stats', () => { const highLevelStats = { clusterone: { count: 2, versions: [{ count: 2, version: '7.0.0-alpha1-test12' }], }, - }; + } as any; const usageStats = undefined; - expect(combineStats(highLevelStats, usageStats)).to.eql({ + expect(combineStats(highLevelStats, usageStats)).toStrictEqual({ clusterone: { count: 2, versions: [{ count: 2, version: '7.0.0-alpha1-test12' }], @@ -528,64 +570,64 @@ describe('Get Kibana Stats', () => { }); describe('Rolls up stats when there are multiple Kibana indices for a cluster', () => { - it('by combining the `total` fields where previous was 0', () => { - const rollUp = { my_field: { total: 0 } }; + test('by combining the `total` fields where previous was 0', () => { + const rollUp = { my_field: { total: 0 } } as any; const addOn = { my_field: { total: 1 } }; - expect(rollUpTotals(rollUp, addOn, 'my_field')).to.eql({ total: 1 }); + expect(rollUpTotals(rollUp, addOn, 'my_field' as any)).toStrictEqual({ total: 1 }); }); - it('by combining the `total` fields with > 1 for previous and addOn', () => { - const rollUp = { my_field: { total: 1 } }; + test('by combining the `total` fields with > 1 for previous and addOn', () => { + const rollUp = { my_field: { total: 1 } } as any; const addOn = { my_field: { total: 3 } }; - expect(rollUpTotals(rollUp, addOn, 'my_field')).to.eql({ total: 4 }); + expect(rollUpTotals(rollUp, addOn, 'my_field' as any)).toStrictEqual({ total: 4 }); }); }); describe('Ensure minimum time difference', () => { - it('should return start and end as is when none are provided', () => { + test('should return start and end as is when none are provided', () => { const { start, end } = ensureTimeSpan(undefined, undefined); - expect(start).to.be.undefined; - expect(end).to.be.undefined; + expect(start).toBe(undefined); + expect(end).toBe(undefined); }); - it('should return start and end as is when only end is provided', () => { + test('should return start and end as is when only end is provided', () => { const initialEnd = '2020-01-01T00:00:00Z'; const { start, end } = ensureTimeSpan(undefined, initialEnd); - expect(start).to.be.undefined; - expect(end).to.be.equal(initialEnd); + expect(start).toBe(undefined); + expect(end).toEqual(initialEnd); }); - it('should return start and end as is because they are already 24h away', () => { + test('should return start and end as is because they are already 24h away', () => { const initialStart = '2019-12-31T00:00:00Z'; const initialEnd = '2020-01-01T00:00:00Z'; const { start, end } = ensureTimeSpan(initialStart, initialEnd); - expect(start).to.be.equal(initialStart); - expect(end).to.be.equal(initialEnd); + expect(start).toEqual(initialStart); + expect(end).toEqual(initialEnd); }); - it('should return start and end as is because they are already 24h+ away', () => { + test('should return start and end as is because they are already 24h+ away', () => { const initialStart = '2019-12-31T00:00:00Z'; const initialEnd = '2020-01-01T01:00:00Z'; const { start, end } = ensureTimeSpan(initialStart, initialEnd); - expect(start).to.be.equal(initialStart); - expect(end).to.be.equal(initialEnd); + expect(start).toEqual(initialStart); + expect(end).toEqual(initialEnd); }); - it('should modify start to a date 24h before end', () => { + test('should modify start to a date 24h before end', () => { const initialStart = '2020-01-01T00:00:00.000Z'; const initialEnd = '2020-01-01T01:00:00.000Z'; const { start, end } = ensureTimeSpan(initialStart, initialEnd); - expect(start).to.be.equal('2019-12-31T01:00:00.000Z'); - expect(end).to.be.equal(initialEnd); + expect(start).toEqual('2019-12-31T01:00:00.000Z'); + expect(end).toEqual(initialEnd); }); - it('should modify start to a date 24h before now', () => { + test('should modify start to a date 24h before now', () => { const initialStart = new Date().toISOString(); const { start, end } = ensureTimeSpan(initialStart, undefined); - expect(start).to.not.be.equal(initialStart); - expect(end).to.be.undefined; + expect(start).not.toBe(initialStart); + expect(end).toBe(undefined); }); }); }); diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts similarity index 58% rename from x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.js rename to x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts index 1e22507c5baf4..e2ad64ce04c6b 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts @@ -5,30 +5,78 @@ */ import moment from 'moment'; -import { get, isEmpty, omit } from 'lodash'; +import { isEmpty } from 'lodash'; +import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; +import { SearchResponse } from 'elasticsearch'; import { KIBANA_SYSTEM_ID, TELEMETRY_COLLECTION_INTERVAL } from '../../common/constants'; -import { fetchHighLevelStats, handleHighLevelStatsResponse } from './get_high_level_stats'; +import { + fetchHighLevelStats, + handleHighLevelStatsResponse, + ClustersHighLevelStats, + ClusterHighLevelStats, +} from './get_high_level_stats'; -export function rollUpTotals(rolledUp, addOn, field) { - const rolledUpTotal = get(rolledUp, [field, 'total'], 0); - const addOnTotal = get(addOn, [field, 'total'], 0); +export function rollUpTotals( + rolledUp: ClusterUsageStats, + addOn: { [key: string]: { total?: number } | undefined }, + field: Exclude +) { + const rolledUpTotal = rolledUp[field]?.total || 0; + const addOnTotal = addOn[field]?.total || 0; return { total: rolledUpTotal + addOnTotal }; } -export function rollUpIndices(rolledUp) { +export function rollUpIndices(rolledUp: ClusterUsageStats) { return rolledUp.indices + 1; } +export interface KibanaUsageStats { + cluster_uuid: string; + kibana_stats?: { + usage?: { + index?: string; + } & { + [plugin: string]: { + total: number; + }; + }; + }; +} + +export interface ClusterUsageStats { + dashboard?: { total: number }; + visualization?: { total: number }; + search?: { total: number }; + index_pattern?: { total: number }; + graph_workspace?: { total: number }; + timelion_sheet?: { total: number }; + indices: number; + plugins?: { + xpack?: unknown; + [plugin: string]: unknown; + }; +} + +export interface ClustersUsageStats { + [clusterUuid: string]: ClusterUsageStats | undefined; +} + +export interface KibanaClusterStat extends Partial, ClusterHighLevelStats {} + +export interface KibanaStats { + [clusterUuid: string]: KibanaClusterStat; +} + /* * @param {Object} rawStats */ -export function getUsageStats(rawStats) { +export function getUsageStats(rawStats: SearchResponse) { const clusterIndexCache = new Set(); - const rawStatsHits = get(rawStats, 'hits.hits', []); + const rawStatsHits = rawStats.hits?.hits || []; // get usage stats per cluster / .kibana index return rawStatsHits.reduce((accum, currInstance) => { - const clusterUuid = get(currInstance, '_source.cluster_uuid'); - const currUsage = get(currInstance, '_source.kibana_stats.usage', {}); + const clusterUuid = currInstance._source.cluster_uuid; + const currUsage = currInstance._source.kibana_stats?.usage || {}; const clusterIndexCombination = clusterUuid + currUsage.index; // return early if usage data is empty or if this cluster/index has already been processed @@ -39,7 +87,7 @@ export function getUsageStats(rawStats) { // Get the stats that were read from any number of different .kibana indices in the cluster, // roll them up into cluster-wide totals - const rolledUpStats = get(accum, clusterUuid, { indices: 0 }); + const rolledUpStats = accum[clusterUuid] || { indices: 0 }; const stats = { dashboard: rollUpTotals(rolledUpStats, currUsage, 'dashboard'), visualization: rollUpTotals(rolledUpStats, currUsage, 'visualization'), @@ -51,21 +99,22 @@ export function getUsageStats(rawStats) { }; // Get the stats provided by telemetry collectors. - const pluginsNested = omit(currUsage, [ - 'index', - 'dashboard', - 'visualization', - 'search', - 'index_pattern', - 'graph_workspace', - 'timelion_sheet', - ]); + const { + index, + dashboard, + visualization, + search, + index_pattern, + graph_workspace, + timelion_sheet, + xpack, + ...pluginsTop + } = currUsage; // Stats filtered by telemetry collectors need to be flattened since they're pulled in a generic way. // A plugin might not provide flat stats if it implements formatForBulkUpload in its collector. // e.g: we want `xpack.reporting` to just be `reporting` - const top = omit(pluginsNested, 'xpack'); - const plugins = { ...top, ...pluginsNested.xpack }; + const plugins = { ...pluginsTop, ...xpack }; return { ...accum, @@ -74,10 +123,13 @@ export function getUsageStats(rawStats) { plugins, }, }; - }, {}); + }, {} as ClustersUsageStats); } -export function combineStats(highLevelStats, usageStats = {}) { +export function combineStats( + highLevelStats: ClustersHighLevelStats, + usageStats: ClustersUsageStats = {} +) { return Object.keys(highLevelStats).reduce((accum, currClusterUuid) => { return { ...accum, @@ -86,7 +138,7 @@ export function combineStats(highLevelStats, usageStats = {}) { ...usageStats[currClusterUuid], }, }; - }, {}); + }, {} as KibanaStats); } /** @@ -96,7 +148,10 @@ export function combineStats(highLevelStats, usageStats = {}) { * @param {date} [start] The start time from which to get the telemetry data * @param {date} [end] The end time from which to get the telemetry data */ -export function ensureTimeSpan(start, end) { +export function ensureTimeSpan( + start?: StatsCollectionConfig['start'], + end?: StatsCollectionConfig['end'] +) { // We only care if we have a start date, because that's the limit that might make us lose the document if (start) { const duration = moment.duration(TELEMETRY_COLLECTION_INTERVAL, 'milliseconds'); @@ -117,9 +172,15 @@ export function ensureTimeSpan(start, end) { * Monkey-patch the modules from get_high_level_stats and add in the * specialized usage data that comes with kibana stats (kibana_stats.usage). */ -export async function getKibanaStats(server, callCluster, clusterUuids, start, end) { +export async function getKibanaStats( + server: StatsCollectionConfig['server'], + callCluster: StatsCollectionConfig['callCluster'], + clusterUuids: string[], + start: StatsCollectionConfig['start'], + end: StatsCollectionConfig['end'] +) { const { start: safeStart, end: safeEnd } = ensureTimeSpan(start, end); - const rawStats = await fetchHighLevelStats( + const rawStats = await fetchHighLevelStats( server, callCluster, clusterUuids, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts index 49a925d1dad0b..f0fda5229cb5c 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts @@ -5,7 +5,6 @@ */ import { telemetryCollectionManager } from '../../../../../../src/legacy/core_plugins/telemetry/server'; -// @ts-ignore import { getAllStats } from './get_all_stats'; import { getClusterUuids } from './get_cluster_uuids'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts index 2889d78891a06..6e8ef93a54016 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts @@ -22,11 +22,9 @@ import { FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER, } from '../../../screens/timeline/fields_browser'; -import { - openTimeline, - populateTimeline, - openTimelineFieldsBrowser, -} from '../../../tasks/timeline/main'; +import { populateTimeline, openTimelineFieldsBrowser } from '../../../tasks/timeline/main'; + +import { openTimeline } from '../../../tasks/siem_main'; import { clearFieldsBrowser, diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts index e7411aba11af5..1555470f5eee7 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts @@ -12,10 +12,10 @@ import { } from '../../../screens/inspect'; import { executeTimelineKQL, - openTimeline, openTimelineSettings, openTimelineInspectButton, } from '../../../tasks/timeline/main'; +import { openTimeline } from '../../../tasks/siem_main'; import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../../tasks/login'; import { closesModal, openStatsAndTables } from '../../../tasks/inspect'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts index 3d251c1c6bcac..c3fedfb06939b 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts @@ -13,7 +13,8 @@ import { } from '../../../tasks/hosts/all_hosts'; import { HOSTS_NAMES } from '../../../screens/hosts/all_hosts'; import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../../tasks/login'; -import { openTimeline, createNewTimeline } from '../../../tasks/timeline/main'; +import { createNewTimeline } from '../../../tasks/timeline/main'; +import { openTimeline } from '../../../tasks/siem_main'; import { TIMELINE_DATA_PROVIDERS_EMPTY, TIMELINE_DATA_PROVIDERS, diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts index 63fe56371a4cd..b7faaaac1c06c 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts @@ -4,44 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HOSTS_PAGE } from '../../../urls/navigation'; +import { waitForAllHostsToBeLoaded, dragFirstHostToTimeline } from '../../../tasks/hosts/all_hosts'; +import { loginAndWaitForPage } from '../../../tasks/login'; +import { openTimelineIfClosed, openTimeline } from '../../../tasks/siem_main'; import { TIMELINE_FLYOUT_BODY, TIMELINE_NOT_READY_TO_DROP_BUTTON, -} from '../../lib/timeline/selectors'; -import { ALL_HOSTS_WIDGET_DRAGGABLE_HOSTS } from '../../lib/hosts/selectors'; -import { HOSTS_PAGE } from '../../lib/urls'; -import { waitForAllHostsWidget } from '../../lib/hosts/helpers'; -import { loginAndWaitForPage } from '../../lib/util/helpers'; -import { drag } from '../../lib/drag_n_drop/helpers'; -import { createNewTimeline, toggleTimelineVisibility } from '../../lib/timeline/helpers'; +} from '../../../screens/timeline/main'; +import { createNewTimeline } from '../../../tasks/timeline/main'; describe('timeline flyout button', () => { before(() => { loginAndWaitForPage(HOSTS_PAGE); + waitForAllHostsToBeLoaded(); }); afterEach(() => { - cy.get('[data-test-subj="kibanaChrome"]').then($page => { - if ($page.find('[data-test-subj="flyoutOverlay"]').length === 1) { - toggleTimelineVisibility(); - } - }); - + openTimelineIfClosed(); createNewTimeline(); }); it('toggles open the timeline', () => { - toggleTimelineVisibility(); - + openTimeline(); cy.get(TIMELINE_FLYOUT_BODY).should('have.css', 'visibility', 'visible'); }); it('sets the flyout button background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the flyout button', () => { - waitForAllHostsWidget(); - - cy.get(ALL_HOSTS_WIDGET_DRAGGABLE_HOSTS) - .first() - .then(host => drag(host)); + dragFirstHostToTimeline(); cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should( 'have.css', diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts index 9f21b4e3d53a1..28cc4a6e8827d 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts @@ -4,14 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - assertAtLeastOneEventMatchesSearch, - executeKQL, - hostExistsQuery, - toggleTimelineVisibility, -} from '../../lib/timeline/helpers'; -import { HOSTS_PAGE } from '../../lib/urls'; -import { loginAndWaitForPage } from '../../lib/util/helpers'; +import { SERVER_SIDE_EVENT_COUNT } from '../../../screens/timeline/main'; +import { HOSTS_PAGE } from '../../../urls/navigation'; +import { loginAndWaitForPage, DEFAULT_TIMEOUT } from '../../../tasks/login'; +import { openTimeline } from '../../../tasks/siem_main'; +import { executeTimelineKQL } from '../../../tasks/timeline/main'; describe('timeline search or filter KQL bar', () => { beforeEach(() => { @@ -19,10 +16,12 @@ describe('timeline search or filter KQL bar', () => { }); it('executes a KQL query', () => { - toggleTimelineVisibility(); + const hostExistsQuery = 'host.name: *'; + openTimeline(); + executeTimelineKQL(hostExistsQuery); - executeKQL(hostExistsQuery); - - assertAtLeastOneEventMatchesSearch(); + cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) + .invoke('text') + .should('be.above', 0); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/screens/siem_main.ts b/x-pack/legacy/plugins/siem/cypress/screens/siem_main.ts new file mode 100644 index 0000000000000..d4eeeb036ee95 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/screens/siem_main.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const MAIN_PAGE = '[data-test-subj="kibanaChrome"]'; + +export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts b/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts index 60c9c2ab44372..4c722ffa5f215 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts @@ -32,3 +32,8 @@ export const TIMELINE_DATA_PROVIDERS_EMPTY = export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="dataProviders"] [data-test-subj="providerContainer"]'; + +export const TIMELINE_FLYOUT_BODY = '[data-test-subj="eui-flyout-body"]'; + +export const TIMELINE_NOT_READY_TO_DROP_BUTTON = + '[data-test-subj="flyout-button-not-ready-to-drop"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/siem_main.ts b/x-pack/legacy/plugins/siem/cypress/tasks/siem_main.ts new file mode 100644 index 0000000000000..8501bb3d94e26 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/tasks/siem_main.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MAIN_PAGE, TIMELINE_TOGGLE_BUTTON } from '../screens/siem_main'; +import { DEFAULT_TIMEOUT } from '../tasks/login'; + +export const openTimelineIfClosed = () => { + cy.get(MAIN_PAGE).then($page => { + if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) { + openTimeline(); + } + }); +}; + +export const openTimeline = () => { + cy.get(TIMELINE_TOGGLE_BUTTON, { timeout: DEFAULT_TIMEOUT }).click(); +}; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts b/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts index 068b6dd9f8bd4..f347c072a3584 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts @@ -7,7 +7,6 @@ import { DEFAULT_TIMEOUT } from '../../integration/lib/util/helpers'; import { - TIMELINE_TOGGLE_BUTTON, SEARCH_OR_FILTER_CONTAINER, TIMELINE_FIELDS_BUTTON, SERVER_SIDE_EVENT_COUNT, @@ -19,10 +18,6 @@ import { export const hostExistsQuery = 'host.name: *'; -export const openTimeline = () => { - cy.get(TIMELINE_TOGGLE_BUTTON, { timeout: DEFAULT_TIMEOUT }).click(); -}; - export const populateTimeline = () => { cy.get(`${SEARCH_OR_FILTER_CONTAINER} input`).type(`${hostExistsQuery} {enter}`); cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) diff --git a/x-pack/legacy/plugins/siem/public/components/auto_sizer/__examples__/index.stories.tsx b/x-pack/legacy/plugins/siem/public/components/auto_sizer/__examples__/index.stories.tsx deleted file mode 100644 index 414cea0d3f40d..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/auto_sizer/__examples__/index.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; -import { AutoSizer } from '..'; - -storiesOf('components/AutoSizer', module).add('example', () => ( -
- - {({ measureRef, content }) => ( -
-
- {'width: '} - {content.width} -
-
- {'height: '} - {content.height} -
-
- )} -
-
-)); diff --git a/x-pack/legacy/plugins/siem/public/components/auto_sizer/index.tsx b/x-pack/legacy/plugins/siem/public/components/auto_sizer/index.tsx deleted file mode 100644 index 8b3a85b28b8fe..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/auto_sizer/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import isEqual from 'lodash/fp/isEqual'; -import React from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; - -interface Measurement { - width?: number; - height?: number; -} - -interface Measurements { - bounds: Measurement; - content: Measurement; - windowMeasurement: Measurement; -} - -interface AutoSizerProps { - detectAnyWindowResize?: boolean; - bounds?: boolean; - content?: boolean; - onResize?: (size: Measurements) => void; - children: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: { measureRef: (instance: HTMLElement | null) => any } & Measurements - ) => React.ReactNode; -} - -interface AutoSizerState { - boundsMeasurement: Measurement; - contentMeasurement: Measurement; - windowMeasurement: Measurement; -} - -/** A hard-fork of the `infra` `AutoSizer` ಠ_ಠ */ -export class AutoSizer extends React.PureComponent { - public element: HTMLElement | null = null; - public resizeObserver: ResizeObserver | null = null; - public windowWidth: number = -1; - - public readonly state = { - boundsMeasurement: { - height: void 0, - width: void 0, - }, - contentMeasurement: { - height: void 0, - width: void 0, - }, - windowMeasurement: { - height: void 0, - width: void 0, - }, - }; - - constructor(props: AutoSizerProps) { - super(props); - if (this.props.detectAnyWindowResize) { - window.addEventListener('resize', this.updateMeasurement); - } - this.resizeObserver = new ResizeObserver(entries => { - entries.forEach(entry => { - if (entry.target === this.element) { - this.measure(entry); - } - }); - }); - } - - public componentWillUnmount() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - if (this.props.detectAnyWindowResize) { - window.removeEventListener('resize', this.updateMeasurement); - } - } - - public measure = (entry: ResizeObserverEntry | null) => { - if (!this.element) { - return; - } - - const { content = true, bounds = false } = this.props; - const { - boundsMeasurement: previousBoundsMeasurement, - contentMeasurement: previousContentMeasurement, - windowMeasurement: previousWindowMeasurement, - } = this.state; - - const boundsRect = bounds ? this.element.getBoundingClientRect() : null; - const boundsMeasurement = boundsRect - ? { - height: this.element.getBoundingClientRect().height, - width: this.element.getBoundingClientRect().width, - } - : previousBoundsMeasurement; - const windowMeasurement: Measurement = { - width: window.innerWidth, - height: window.innerHeight, - }; - - if ( - this.props.detectAnyWindowResize && - boundsMeasurement && - boundsMeasurement.width && - this.windowWidth !== -1 && - this.windowWidth > window.innerWidth - ) { - const gap = this.windowWidth - window.innerWidth; - boundsMeasurement.width = boundsMeasurement.width - gap; - } - this.windowWidth = window.innerWidth; - const contentRect = content && entry ? entry.contentRect : null; - const contentMeasurement = - contentRect && entry - ? { - height: entry.contentRect.height, - width: entry.contentRect.width, - } - : previousContentMeasurement; - - if ( - isEqual(boundsMeasurement, previousBoundsMeasurement) && - isEqual(contentMeasurement, previousContentMeasurement) && - isEqual(windowMeasurement, previousWindowMeasurement) - ) { - return; - } - - requestAnimationFrame(() => { - if (!this.resizeObserver) { - return; - } - - this.setState({ boundsMeasurement, contentMeasurement, windowMeasurement }); - - if (this.props.onResize) { - this.props.onResize({ - bounds: boundsMeasurement, - content: contentMeasurement, - windowMeasurement, - }); - } - }); - }; - - public render() { - const { children } = this.props; - const { boundsMeasurement, contentMeasurement, windowMeasurement } = this.state; - - return children({ - bounds: boundsMeasurement, - content: contentMeasurement, - windowMeasurement, - measureRef: this.storeRef, - }); - } - - private updateMeasurement = () => { - window.setTimeout(() => { - this.measure(null); - }, 0); - }; - - private storeRef = (element: HTMLElement | null) => { - if (this.element && this.resizeObserver) { - this.resizeObserver.unobserve(this.element); - } - - if (element && this.resizeObserver) { - this.resizeObserver.observe(element); - } - - this.element = element; - }; -} diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx index 342d7d35f9cb7..27f0222b96b77 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx @@ -331,7 +331,7 @@ describe('AreaChart', () => { }); it(`should render area chart`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(1); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); }); }); @@ -344,7 +344,7 @@ describe('AreaChart', () => { }); it(`should render a chart place holder`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(0); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(1); }); } diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx index 57f78080abc60..fd05b80e41235 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx @@ -16,7 +16,8 @@ import { RecursivePartial, } from '@elastic/charts'; import { getOr, get, isNull, isNumber } from 'lodash/fp'; -import { AutoSizer } from '../auto_sizer'; +import useResizeObserver from 'use-resize-observer'; + import { ChartPlaceHolder } from './chart_place_holder'; import { useTimeZone } from '../../lib/kibana'; import { @@ -124,35 +125,24 @@ export const AreaChartBase = React.memo(AreaChartBaseComponent); AreaChartBase.displayName = 'AreaChartBase'; -export const AreaChartComponent = ({ - areaChart, - configs, -}: { +interface AreaChartComponentProps { areaChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; -}) => { +} + +export const AreaChartComponent: React.FC = ({ areaChart, configs }) => { + const { ref: measureRef, width, height } = useResizeObserver({}); const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); + const chartHeight = getChartHeight(customHeight, height); + const chartWidth = getChartWidth(customWidth, width); return checkIfAnyValidSeriesExist(areaChart) ? ( - - {({ measureRef, content: { height, width } }) => ( - - - - )} - + + + ) : ( - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx index d8e5079dd72a6..0b6635b04d380 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx @@ -278,7 +278,7 @@ describe.each(chartDataSets)('BarChart with valid data [%o]', data => { }); it(`should render chart`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(1); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); }); }); @@ -290,8 +290,8 @@ describe.each(chartHolderDataSets)('BarChart with invalid data [%o]', data => { shallowWrapper = shallow(); }); - it(`should render chart holder`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + it(`should render a ChartPlaceHolder`, () => { + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(0); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(1); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx index d9dd302dae724..1355926d343df 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx @@ -8,9 +8,9 @@ import React from 'react'; import { Chart, BarSeries, Axis, Position, ScaleType, Settings } from '@elastic/charts'; import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; +import useResizeObserver from 'use-resize-observer'; import { useTimeZone } from '../../lib/kibana'; -import { AutoSizer } from '../auto_sizer'; import { ChartPlaceHolder } from './chart_place_holder'; import { chartDefaultSettings, @@ -99,40 +99,25 @@ export const BarChartBase = React.memo(BarChartBaseComponent); BarChartBase.displayName = 'BarChartBase'; -export const BarChartComponent = ({ - barChart, - configs, -}: { +interface BarChartComponentProps { barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; -}) => { +} + +export const BarChartComponent: React.FC = ({ barChart, configs }) => { + const { ref: measureRef, width, height } = useResizeObserver({}); const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); + const chartHeight = getChartHeight(customHeight, height); + const chartWidth = getChartWidth(customWidth, width); return checkIfAnyValidSeriesExist(barChart) ? ( - - {({ measureRef, content: { height, width } }) => ( - - - - )} - + + + ) : ( - + ); }; -BarChartComponent.displayName = 'BarChartComponent'; - export const BarChart = React.memo(BarChartComponent); - -BarChart.displayName = 'BarChart'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx index 3cef3e98c2f0a..8c4228b597dbb 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import useResizeObserver from 'use-resize-observer'; import { mockIndexPattern, TestProviders } from '../../mock'; import { wait } from '../../lib/helpers'; @@ -27,6 +28,10 @@ mockUseFetchIndexPatterns.mockImplementation(() => [ }, ]); +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer'); +mockUseResizeObserver.mockImplementation(() => ({})); + const from = 1566943856794; const to = 1566857456791; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index 14473605a7c88..cbce1f635310a 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -9,13 +9,13 @@ import deepEqual from 'fast-deep-equal'; import { getOr, isEmpty, isEqual, union } from 'lodash/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; +import useResizeObserver from 'use-resize-observer'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; import { Direction } from '../../graphql/types'; import { useKibana } from '../../lib/kibana'; import { KqlMode } from '../../store/timeline/model'; -import { AutoSizer } from '../auto_sizer'; import { HeaderSection } from '../header_section'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; @@ -95,6 +95,7 @@ const EventsViewerComponent: React.FC = ({ toggleColumn, utilityBar, }) => { + const { ref: measureRef, width = 0 } = useResizeObserver({}); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const combinedQueries = combineQueries({ @@ -120,115 +121,108 @@ const EventsViewerComponent: React.FC = ({ return ( - - {({ measureRef, content: { width = 0 } }) => ( - <> - -
- - - {combinedQueries != null ? ( - - {({ - events, - getUpdatedAt, - inspect, - loading, - loadMore, - pageInfo, - refetch, - totalCount = 0, - }) => { - const totalCountMinusDeleted = - totalCount > 0 ? totalCount - deletedEventIds.length : 0; - - const subtitle = `${ - i18n.SHOWING - }: ${totalCountMinusDeleted.toLocaleString()} ${timelineTypeContext.unit?.( - totalCountMinusDeleted - ) ?? i18n.UNIT(totalCountMinusDeleted)}`; - - // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) - return ( - <> - + +
+ + + {combinedQueries != null ? ( + + {({ + events, + getUpdatedAt, + inspect, + loading, + loadMore, + pageInfo, + refetch, + totalCount = 0, + }) => { + const totalCountMinusDeleted = + totalCount > 0 ? totalCount - deletedEventIds.length : 0; + + const subtitle = `${ + i18n.SHOWING + }: ${totalCountMinusDeleted.toLocaleString()} ${timelineTypeContext.unit?.( + totalCountMinusDeleted + ) ?? i18n.UNIT(totalCountMinusDeleted)}`; + + // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) + return ( + <> + + {headerFilterGroup} + + + {utilityBar?.(refetch, totalCountMinusDeleted)} + +
+ + - {headerFilterGroup} - - - {utilityBar?.(refetch, totalCountMinusDeleted)} - -
- - - - !deletedEventIds.includes(e._id))} - id={id} - isEventViewer={true} - height={height} - sort={sort} - toggleColumn={toggleColumn} - /> - -
- -
- - ); - }} - - ) : null} - - )} - + inputId="global" + inspect={inspect} + loading={loading} + refetch={refetch} + /> + + !deletedEventIds.includes(e._id))} + id={id} + isEventViewer={true} + height={height} + sort={sort} + toggleColumn={toggleColumn} + /> + +
+ +
+ + ); + }} +
+ ) : null} + ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index ec8d329f1dfe3..2bedd1cb89b41 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import useResizeObserver from 'use-resize-observer'; import { wait } from '../../lib/helpers'; import { mockIndexPattern, TestProviders } from '../../mock'; @@ -26,6 +27,10 @@ mockUseFetchIndexPatterns.mockImplementation(() => [ }, ]); +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer'); +mockUseResizeObserver.mockImplementation(() => ({})); + const from = 1566943856794; const to = 1566857456791; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap index 67d266d1cbf39..3fcd258b79147 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -1,10 +1,793 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Timeline rendering renders correctly against snapshot 1`] = ` - - - + + + + + + + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx index f7c0d0b475734..78899b7c5d628 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import useResizeObserver from 'use-resize-observer'; import { timelineQuery } from '../../containers/timeline/index.gql_query'; import { mockBrowserFields } from '../../containers/source/mock'; @@ -29,6 +30,10 @@ const testFlyoutHeight = 980; jest.mock('../../lib/kibana'); +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer'); +mockUseResizeObserver.mockImplementation(() => ({})); + describe('Timeline', () => { const sort: Sort = { columnId: '@timestamp', diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index 09457c8f0285a..4b7331ab14c7e 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -8,13 +8,13 @@ import { EuiFlexGroup } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; +import useResizeObserver from 'use-resize-observer'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; import { Direction } from '../../graphql/types'; import { useKibana } from '../../lib/kibana'; import { KqlMode, EventType } from '../../store/timeline/model'; -import { AutoSizer } from '../auto_sizer'; import { ColumnHeader } from './body/column_headers/column_header'; import { defaultHeaders } from './body/column_headers/default_headers'; import { Sort } from './body/sort'; @@ -88,7 +88,7 @@ interface Props { } /** The parent Timeline component */ -export const TimelineComponent = ({ +export const TimelineComponent: React.FC = ({ browserFields, columns, dataProviders, @@ -118,7 +118,10 @@ export const TimelineComponent = ({ start, sort, toggleColumn, -}: Props) => { +}) => { + const { ref: measureRef, width = 0, height: timelineHeaderHeight = 0 } = useResizeObserver< + HTMLDivElement + >({}); const kibana = useKibana(); const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), @@ -132,101 +135,98 @@ export const TimelineComponent = ({ end, }); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + return ( - - {({ measureRef, content: { height: timelineHeaderHeight = 0, width = 0 } }) => ( - + }> + + + + {combinedQueries != null ? ( + c.id)} + sourceId="default" + limit={itemsPerPage} + filterQuery={combinedQueries.filterQuery} + sortField={{ + sortFieldId: sort.columnId, + direction: sort.sortDirection as Direction, + }} > - - - - - {combinedQueries != null ? ( - c.id)} - sourceId="default" - limit={itemsPerPage} - filterQuery={combinedQueries.filterQuery} - sortField={{ - sortFieldId: sort.columnId, - direction: sort.sortDirection as Direction, - }} - > - {({ - events, - inspect, - loading, - totalCount, - pageInfo, - loadMore, - getUpdatedAt, - refetch, - }) => ( - - - -