From 61bb52c65b4a829218039e1e42057f6fe022aa59 Mon Sep 17 00:00:00 2001 From: Marco Antonio Ghiani Date: Wed, 26 Apr 2023 14:23:48 +0200 Subject: [PATCH 1/8] [Infrastructure UI] Implement Metrics explorer views CRUD endpoints (#155621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📓 Summary Part of #152617 Closes #155111 This PR implements the CRUD endpoints for the metrics explorer views. Following the approach used for the InventoryView service, it exposes a client that abstracts all the logic concerned to the `metrics-explorer-view` saved objects. It also follows the guideline provided for [Versioning interfaces](https://docs.elastic.dev/kibana-dev-docs/versioning-interfaces) and [Versioning HTTP APIs](https://docs.elastic.dev/kibana-dev-docs/versioning-http-apis), preparing for the serverless. ## 🤓 Tips for the reviewer You can open the Kibana dev tools and play with the following snippet to test the create APIs, or you can perform the same requests with your preferred client: ``` // Get all GET kbn:/api/infra/metrics_explorer_views // Create one POST kbn:/api/infra/metrics_explorer_views { "attributes": { "name": "My view" } } // Get one GET kbn:/api/infra/metrics_explorer_views/ // Update one PUT kbn:/api/infra/metrics_explorer_views/ { "attributes": { "name": "My view 2" } } // Delete one DELETE kbn:/api/infra/metrics_explorer_views/ ``` --------- Co-authored-by: Marco Antonio Ghiani Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/infra/common/http_api/index.ts | 1 + .../http_api/inventory_views/v1/common.ts | 2 +- .../plugins/infra/common/http_api/latest.ts | 1 + .../metrics_explorer_views/v1/common.ts | 68 ++++ .../v1/create_metrics_explorer_view.ts | 29 ++ .../v1/find_metrics_explorer_view.ts | 36 ++ .../v1/get_metrics_explorer_view.ts | 16 + .../metrics_explorer_views/v1/index.ts | 12 + .../v1/update_metrics_explorer_view.ts | 29 ++ .../common/metrics_explorer_views/defaults.ts | 2 +- .../common/metrics_explorer_views/index.ts | 1 + ....mock.ts => metrics_explorer_view.mock.ts} | 2 +- x-pack/plugins/infra/server/infra_server.ts | 2 + x-pack/plugins/infra/server/mocks.ts | 2 + x-pack/plugins/infra/server/plugin.ts | 15 +- .../server/routes/inventory_views/README.md | 4 + .../inventory_views/create_inventory_view.ts | 2 +- .../inventory_views/delete_inventory_view.ts | 2 +- .../inventory_views/find_inventory_view.ts | 2 +- .../inventory_views/get_inventory_view.ts | 2 +- .../inventory_views/update_inventory_view.ts | 2 +- .../routes/metrics_explorer_views/README.md | 354 ++++++++++++++++++ .../create_metrics_explorer_view.ts | 58 +++ .../delete_metrics_explorer_view.ts | 54 +++ .../find_metrics_explorer_view.ts | 49 +++ .../get_metrics_explorer_view.ts | 62 +++ .../routes/metrics_explorer_views/index.ts | 23 ++ .../update_metrics_explorer_view.ts | 65 ++++ .../metrics_explorer_view/types.ts | 12 +- .../inventory_views/inventory_views_client.ts | 24 +- .../services/metrics_explorer_views/index.ts | 14 + .../metrics_explorer_views_client.mock.ts | 17 + .../metrics_explorer_views_client.test.ts | 262 +++++++++++++ .../metrics_explorer_views_client.ts | 218 +++++++++++ .../metrics_explorer_views_service.mock.ts | 18 + .../metrics_explorer_views_service.ts | 39 ++ .../services/metrics_explorer_views/types.ts | 48 +++ x-pack/plugins/infra/server/types.ts | 2 + 38 files changed, 1528 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts create mode 100644 x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts create mode 100644 x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts rename x-pack/plugins/infra/common/metrics_explorer_views/{metric_explorer_view.mock.ts => metrics_explorer_view.mock.ts} (93%) create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts create mode 100644 x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 355c5925702f7e..e9dc741f39652b 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -20,3 +20,4 @@ export * from './infra'; */ export * from './latest'; export * as inventoryViewsV1 from './inventory_views/v1'; +export * as metricsExplorerViewsV1 from './metrics_explorer_views/v1'; diff --git a/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts b/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts index c229170b8007b5..3db684628334e9 100644 --- a/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts +++ b/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts @@ -63,4 +63,4 @@ export const inventoryViewResponsePayloadRT = rt.type({ data: inventoryViewResponseRT, }); -export type GetInventoryViewResponsePayload = rt.TypeOf; +export type InventoryViewResponsePayload = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/http_api/latest.ts b/x-pack/plugins/infra/common/http_api/latest.ts index 519da4a60dec1b..effdbeda041dae 100644 --- a/x-pack/plugins/infra/common/http_api/latest.ts +++ b/x-pack/plugins/infra/common/http_api/latest.ts @@ -6,3 +6,4 @@ */ export * from './inventory_views/v1'; +export * from './metrics_explorer_views/v1'; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts new file mode 100644 index 00000000000000..76b6daf60a3245 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; +import { either } from 'fp-ts/Either'; + +export const METRICS_EXPLORER_VIEW_URL = '/api/infra/metrics_explorer_views'; +export const METRICS_EXPLORER_VIEW_URL_ENTITY = `${METRICS_EXPLORER_VIEW_URL}/{metricsExplorerViewId}`; +export const getMetricsExplorerViewUrl = (metricsExplorerViewId?: string) => + [METRICS_EXPLORER_VIEW_URL, metricsExplorerViewId].filter(Boolean).join('/'); + +const metricsExplorerViewIdRT = new rt.Type( + 'MetricsExplorerViewId', + rt.string.is, + (u, c) => + either.chain(rt.string.validate(u, c), (id) => { + return id === '0' + ? rt.failure(u, c, `The metrics explorer view with id ${id} is not configurable.`) + : rt.success(id); + }), + String +); + +export const metricsExplorerViewRequestParamsRT = rt.type({ + metricsExplorerViewId: metricsExplorerViewIdRT, +}); + +export type MetricsExplorerViewRequestParams = rt.TypeOf; + +export const metricsExplorerViewRequestQueryRT = rt.partial({ + sourceId: rt.string, +}); + +export type MetricsExplorerViewRequestQuery = rt.TypeOf; + +const metricsExplorerViewAttributesResponseRT = rt.intersection([ + rt.strict({ + name: nonEmptyStringRt, + isDefault: rt.boolean, + isStatic: rt.boolean, + }), + rt.UnknownRecord, +]); + +const metricsExplorerViewResponseRT = rt.exact( + rt.intersection([ + rt.type({ + id: rt.string, + attributes: metricsExplorerViewAttributesResponseRT, + }), + rt.partial({ + updatedAt: rt.number, + version: rt.string, + }), + ]) +); + +export const metricsExplorerViewResponsePayloadRT = rt.type({ + data: metricsExplorerViewResponseRT, +}); + +export type GetMetricsExplorerViewResponsePayload = rt.TypeOf< + typeof metricsExplorerViewResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts new file mode 100644 index 00000000000000..5550404529cf11 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +export const createMetricsExplorerViewAttributesRequestPayloadRT = rt.intersection([ + rt.type({ + name: nonEmptyStringRt, + }), + rt.UnknownRecord, + rt.exact(rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined })), +]); + +export type CreateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf< + typeof createMetricsExplorerViewAttributesRequestPayloadRT +>; + +export const createMetricsExplorerViewRequestPayloadRT = rt.type({ + attributes: createMetricsExplorerViewAttributesRequestPayloadRT, +}); + +export type CreateMetricsExplorerViewRequestPayload = rt.TypeOf< + typeof createMetricsExplorerViewRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.ts new file mode 100644 index 00000000000000..c504b54a4f9141 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +export const findMetricsExplorerViewAttributesResponseRT = rt.strict({ + name: nonEmptyStringRt, + isDefault: rt.boolean, + isStatic: rt.boolean, +}); + +const findMetricsExplorerViewResponseRT = rt.exact( + rt.intersection([ + rt.type({ + id: rt.string, + attributes: findMetricsExplorerViewAttributesResponseRT, + }), + rt.partial({ + updatedAt: rt.number, + version: rt.string, + }), + ]) +); + +export const findMetricsExplorerViewResponsePayloadRT = rt.type({ + data: rt.array(findMetricsExplorerViewResponseRT), +}); + +export type FindMetricsExplorerViewResponsePayload = rt.TypeOf< + typeof findMetricsExplorerViewResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts new file mode 100644 index 00000000000000..8a828e00c917f5 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const getMetricsExplorerViewRequestParamsRT = rt.type({ + metricsExplorerViewId: rt.string, +}); + +export type GetMetricsExplorerViewRequestParams = rt.TypeOf< + typeof getMetricsExplorerViewRequestParamsRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts new file mode 100644 index 00000000000000..62a0b7a633975c --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './common'; +export * from './get_metrics_explorer_view'; +export * from './find_metrics_explorer_view'; +export * from './create_metrics_explorer_view'; +export * from './update_metrics_explorer_view'; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts new file mode 100644 index 00000000000000..5bf327789a65c8 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +export const updateMetricsExplorerViewAttributesRequestPayloadRT = rt.intersection([ + rt.type({ + name: nonEmptyStringRt, + }), + rt.UnknownRecord, + rt.exact(rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined })), +]); + +export type UpdateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf< + typeof updateMetricsExplorerViewAttributesRequestPayloadRT +>; + +export const updateMetricsExplorerViewRequestPayloadRT = rt.type({ + attributes: updateMetricsExplorerViewAttributesRequestPayloadRT, +}); + +export type UpdateMetricsExplorerViewRequestPayload = rt.TypeOf< + typeof updateMetricsExplorerViewRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts b/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts index 88771d1a76fcbc..8c7e6ffff192f5 100644 --- a/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts +++ b/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { NonEmptyString } from '@kbn/io-ts-utils'; import type { MetricsExplorerViewAttributes } from './types'; -export const staticMetricsExplorerViewId = 'static'; +export const staticMetricsExplorerViewId = '0'; export const staticMetricsExplorerViewAttributes: MetricsExplorerViewAttributes = { name: i18n.translate('xpack.infra.savedView.defaultViewNameHosts', { diff --git a/x-pack/plugins/infra/common/metrics_explorer_views/index.ts b/x-pack/plugins/infra/common/metrics_explorer_views/index.ts index 6cc0ccaa93a6d1..ae809a6c7c6157 100644 --- a/x-pack/plugins/infra/common/metrics_explorer_views/index.ts +++ b/x-pack/plugins/infra/common/metrics_explorer_views/index.ts @@ -5,4 +5,5 @@ * 2.0. */ +export * from './defaults'; export * from './types'; diff --git a/x-pack/plugins/infra/common/metrics_explorer_views/metric_explorer_view.mock.ts b/x-pack/plugins/infra/common/metrics_explorer_views/metrics_explorer_view.mock.ts similarity index 93% rename from x-pack/plugins/infra/common/metrics_explorer_views/metric_explorer_view.mock.ts rename to x-pack/plugins/infra/common/metrics_explorer_views/metrics_explorer_view.mock.ts index e921c37dd21f8b..98f6675f42a66d 100644 --- a/x-pack/plugins/infra/common/metrics_explorer_views/metric_explorer_view.mock.ts +++ b/x-pack/plugins/infra/common/metrics_explorer_views/metrics_explorer_view.mock.ts @@ -8,7 +8,7 @@ import { staticMetricsExplorerViewAttributes } from './defaults'; import type { MetricsExplorerView, MetricsExplorerViewAttributes } from './types'; -export const createmetricsExplorerViewMock = ( +export const createMetricsExplorerViewMock = ( id: string, attributes: MetricsExplorerViewAttributes, updatedAt?: number, diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 3b6ea0333f236e..4d29974ceb75f9 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -37,6 +37,7 @@ import { initOverviewRoute } from './routes/overview'; import { initProcessListRoute } from './routes/process_list'; import { initSnapshotRoute } from './routes/snapshot'; import { initInfraMetricsRoute } from './routes/infra'; +import { initMetricsExplorerViewRoutes } from './routes/metrics_explorer_views'; export const initInfraServer = (libs: InfraBackendLibs) => { initIpToHostName(libs); @@ -59,6 +60,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initLogEntriesSummaryHighlightsRoute(libs); initLogViewRoutes(libs); initMetricExplorerRoute(libs); + initMetricsExplorerViewRoutes(libs); initMetricsAPIRoute(libs); initMetadataRoute(libs); initInventoryMetaRoute(libs); diff --git a/x-pack/plugins/infra/server/mocks.ts b/x-pack/plugins/infra/server/mocks.ts index 5a97f4a7d9a524..7e5a349cb1e012 100644 --- a/x-pack/plugins/infra/server/mocks.ts +++ b/x-pack/plugins/infra/server/mocks.ts @@ -10,6 +10,7 @@ import { createLogViewsServiceSetupMock, createLogViewsServiceStartMock, } from './services/log_views/log_views_service.mock'; +import { createMetricsExplorerViewsServiceStartMock } from './services/metrics_explorer_views/metrics_explorer_views_service.mock'; import { InfraPluginSetup, InfraPluginStart } from './types'; const createInfraSetupMock = () => { @@ -26,6 +27,7 @@ const createInfraStartMock = () => { getMetricIndices: jest.fn(), inventoryViews: createInventoryViewsServiceStartMock(), logViews: createLogViewsServiceStartMock(), + metricsExplorerViews: createMetricsExplorerViewsServiceStartMock(), }; return infraStartMock; }; diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 2c114bb75d6e55..eb8777665895a5 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -49,6 +49,7 @@ import { import { InventoryViewsService } from './services/inventory_views'; import { LogEntriesService } from './services/log_entries'; import { LogViewsService } from './services/log_views'; +import { MetricsExplorerViewsService } from './services/metrics_explorer_views'; import { RulesService } from './services/rules'; import { InfraConfig, @@ -122,6 +123,7 @@ export class InfraServerPlugin private metricsRules: RulesService; private inventoryViews: InventoryViewsService; private logViews: LogViewsService; + private metricsExplorerViews: MetricsExplorerViewsService; constructor(context: PluginInitializerContext) { this.config = context.config.get(); @@ -140,6 +142,9 @@ export class InfraServerPlugin this.inventoryViews = new InventoryViewsService(this.logger.get('inventoryViews')); this.logViews = new LogViewsService(this.logger.get('logViews')); + this.metricsExplorerViews = new MetricsExplorerViewsService( + this.logger.get('metricsExplorerViews') + ); } setup(core: InfraPluginCoreSetup, plugins: InfraServerPluginSetupDeps) { @@ -155,12 +160,13 @@ export class InfraServerPlugin ); const inventoryViews = this.inventoryViews.setup(); const logViews = this.logViews.setup(); + const metricsExplorerViews = this.metricsExplorerViews.setup(); // register saved object types core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); - core.savedObjects.registerType(metricsExplorerViewSavedObjectType); core.savedObjects.registerType(inventoryViewSavedObjectType); core.savedObjects.registerType(logViewSavedObjectType); + core.savedObjects.registerType(metricsExplorerViewSavedObjectType); // TODO: separate these out individually and do away with "domains" as a temporary group // and make them available via the request context so we can do away with @@ -237,6 +243,7 @@ export class InfraServerPlugin defineInternalSourceConfiguration: sources.defineInternalSourceConfiguration.bind(sources), inventoryViews, logViews, + metricsExplorerViews, } as InfraPluginSetup; } @@ -258,9 +265,15 @@ export class InfraServerPlugin }, }); + const metricsExplorerViews = this.metricsExplorerViews.start({ + infraSources: this.libs.sources, + savedObjects: core.savedObjects, + }); + return { inventoryViews, logViews, + metricsExplorerViews, getMetricIndices: makeGetMetricIndices(this.libs.sources), }; } diff --git a/x-pack/plugins/infra/server/routes/inventory_views/README.md b/x-pack/plugins/infra/server/routes/inventory_views/README.md index 8a09aedef1b75a..be7d1c37341577 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/README.md +++ b/x-pack/plugins/infra/server/routes/inventory_views/README.md @@ -221,6 +221,8 @@ Updates an inventory view. Any attribute can be updated except for `isDefault` and `isStatic`, which are derived by the source configuration preference set by the user. +Any attempt to update the static view with id `0` will return a `400 The inventory view with id 0 is not configurable.` + ### Request - **Method**: PUT @@ -324,6 +326,8 @@ Status code: 409 Deletes an inventory view. +Any attempt to delete the static view with id `0` will return a `400 The inventory view with id 0 is not configurable.` + ### Request - **Method**: DELETE diff --git a/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts index 8f3d52db7a6dd4..90bb47d8a2d767 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts @@ -28,7 +28,7 @@ export const initCreateInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { body } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts index 83ad61fc46c528..e86e44fc0ac05b 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts @@ -27,7 +27,7 @@ export const initDeleteInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { params } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts index abdfc2f8749e4c..a9de3a426f14f8 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts @@ -27,7 +27,7 @@ export const initFindInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { query } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts index 1a5f5adec136dd..0cb9f815ef0898 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts @@ -30,7 +30,7 @@ export const initGetInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { params, query } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts index d2b583437d177b..0f225e0546fd1d 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts @@ -32,7 +32,7 @@ export const initUpdateInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { body, params, query } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md b/x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md new file mode 100644 index 00000000000000..d14d8298d0d0f8 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md @@ -0,0 +1,354 @@ +# Metrics Explorer Views CRUD api + +## Find all: `GET /api/infra/metrics_explorer_views` + +Retrieves all metrics explorer views in a reduced version. + +### Request + +- **Method**: GET +- **Path**: /api/infra/metrics_explorer_views +- **Query params**: + - `sourceId` _(optional)_: Specify a source id related to the metrics explorer views. Default value: `default`. + +### Response + +```json +GET /api/infra/metrics_explorer_views + +Status code: 200 + +{ + "data": [ + { + "id": "static", + "attributes": { + "name": "Default view", + "isDefault": false, + "isStatic": true + } + }, + { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "Ad-hoc", + "isDefault": true, + "isStatic": false + } + }, + { + "id": "c301ef20-da0c-11ed-aac0-77131228e6f1", + "version": "WzQxMCwxXQ==", + "updatedAt": 1681398386450, + "attributes": { + "name": "Custom", + "isDefault": false, + "isStatic": false + } + } + ] +} +``` + +## Get one: `GET /api/infra/metrics_explorer_views/{metricsExplorerViewId}` + +Retrieves a single metrics explorer view by ID + +### Request + +- **Method**: GET +- **Path**: /api/infra/metrics_explorer_views/{metricsExplorerViewId} +- **Query params**: + - `sourceId` _(optional)_: Specify a source id related to the metrics explorer view. Default value: `default`. + +### Response + +```json +GET /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 200 + +{ + "data": { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "Ad-hoc", + "isDefault": true, + "isStatic": false, + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + "groupBy": [], + "nodeType": "host", + "view": "map", + "customOptions": [], + "customMetrics": [], + "boundsOverride": { + "max": 1, + "min": 0 + }, + "autoBounds": true, + "accountId": "", + "region": "", + "autoReload": false, + "filterQuery": { + "expression": "", + "kind": "kuery" + }, + "legend": { + "palette": "cool", + "reverseColors": false, + "steps": 10 + }, + "timelineOpen": false + } + } +} +``` + +```json +GET /api/infra/metrics_explorer_views/random-id + +Status code: 404 + +{ + "statusCode": 404, + "error": "Not Found", + "message": "Saved object [metrics-explorer-view/random-id] not found" +} +``` + +## Create one: `POST /api/infra/metrics_explorer_views` + +Creates a new metrics explorer view. + +### Request + +- **Method**: POST +- **Path**: /api/infra/metrics_explorer_views +- **Request body**: + ```json + { + "attributes": { + "name": "View name", + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + //... + } + } + ``` + +### Response + +```json +POST /api/infra/metrics_explorer_views + +Status code: 201 + +{ + "data": { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "View name", + "isDefault": false, + "isStatic": false, + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + "groupBy": [], + "nodeType": "host", + "view": "map", + "customOptions": [], + "customMetrics": [], + "boundsOverride": { + "max": 1, + "min": 0 + }, + "autoBounds": true, + "accountId": "", + "region": "", + "autoReload": false, + "filterQuery": { + "expression": "", + "kind": "kuery" + }, + "legend": { + "palette": "cool", + "reverseColors": false, + "steps": 10 + }, + "timelineOpen": false + } + } +} +``` + +Send in the payload a `name` attribute already held by another view: +```json +POST /api/infra/metrics_explorer_views + +Status code: 409 + +{ + "statusCode": 409, + "error": "Conflict", + "message": "A view with that name already exists." +} +``` + +## Update one: `PUT /api/infra/metrics_explorer_views/{metricsExplorerViewId}` + +Updates a metrics explorer view. + +Any attribute can be updated except for `isDefault` and `isStatic`, which are derived by the source configuration preference set by the user. + +Any attempt to update the static view with id `0` will return a `400 The metrics explorer view with id 0 is not configurable.` + +### Request + +- **Method**: PUT +- **Path**: /api/infra/metrics_explorer_views/{metricsExplorerViewId} +- **Query params**: + - `sourceId` _(optional)_: Specify a source id related to the metrics explorer view. Default value: `default`. +- **Request body**: + ```json + { + "attributes": { + "name": "View name", + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + //... + } + } + ``` + +### Response + +```json +PUT /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 200 + +{ + "data": { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "View name", + "isDefault": false, + "isStatic": false, + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + "groupBy": [], + "nodeType": "host", + "view": "map", + "customOptions": [], + "customMetrics": [], + "boundsOverride": { + "max": 1, + "min": 0 + }, + "autoBounds": true, + "accountId": "", + "region": "", + "autoReload": false, + "filterQuery": { + "expression": "", + "kind": "kuery" + }, + "legend": { + "palette": "cool", + "reverseColors": false, + "steps": 10 + }, + "timelineOpen": false + } + } +} +``` + +```json +PUT /api/infra/metrics_explorer_views/random-id + +Status code: 404 + +{ + "statusCode": 404, + "error": "Not Found", + "message": "Saved object [metrics-explorer-view/random-id] not found" +} +``` + +Send in the payload a `name` attribute already held by another view: +```json +PUT /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 409 + +{ + "statusCode": 409, + "error": "Conflict", + "message": "A view with that name already exists." +} +``` + +## Delete one: `DELETE /api/infra/metrics_explorer_views/{metricsExplorerViewId}` + +Deletes a metrics explorer view. + +Any attempt to delete the static view with id `0` will return a `400 The metrics explorer view with id 0 is not configurable.` + +### Request + +- **Method**: DELETE +- **Path**: /api/infra/metrics_explorer_views/{metricsExplorerViewId} + +### Response + +```json +DELETE /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 204 No content +``` + +```json +DELETE /api/infra/metrics_explorer_views/random-id + +Status code: 404 + +{ + "statusCode": 404, + "error": "Not Found", + "message": "Saved object [metrics-explorer-view/random-id] not found" +} +``` diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts new file mode 100644 index 00000000000000..948dd757e7e015 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + createMetricsExplorerViewRequestPayloadRT, + metricsExplorerViewResponsePayloadRT, + METRICS_EXPLORER_VIEW_URL, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initCreateMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'post', + path: METRICS_EXPLORER_VIEW_URL, + validate: { + body: createValidationFunction(createMetricsExplorerViewRequestPayloadRT), + }, + }, + async (_requestContext, request, response) => { + const { body } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerView = await metricsExplorerViewsClient.create(body.attributes); + + return response.custom({ + statusCode: 201, + body: metricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerView }), + }); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts new file mode 100644 index 00000000000000..a3b6f8b05f0997 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + metricsExplorerViewRequestParamsRT, + METRICS_EXPLORER_VIEW_URL_ENTITY, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initDeleteMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'delete', + path: METRICS_EXPLORER_VIEW_URL_ENTITY, + validate: { + params: createValidationFunction(metricsExplorerViewRequestParamsRT), + }, + }, + async (_requestContext, request, response) => { + const { params } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + await metricsExplorerViewsClient.delete(params.metricsExplorerViewId); + + return response.noContent(); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts new file mode 100644 index 00000000000000..fbae7790b04ebb --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createValidationFunction } from '../../../common/runtime_types'; +import { + findMetricsExplorerViewResponsePayloadRT, + metricsExplorerViewRequestQueryRT, + METRICS_EXPLORER_VIEW_URL, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initFindMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'get', + path: METRICS_EXPLORER_VIEW_URL, + validate: { + query: createValidationFunction(metricsExplorerViewRequestQueryRT), + }, + }, + async (_requestContext, request, response) => { + const { query } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerViewsList = await metricsExplorerViewsClient.find(query); + + return response.ok({ + body: findMetricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerViewsList }), + }); + } catch (error) { + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts new file mode 100644 index 00000000000000..b8e71a3c662d6e --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + metricsExplorerViewResponsePayloadRT, + metricsExplorerViewRequestQueryRT, + METRICS_EXPLORER_VIEW_URL_ENTITY, + getMetricsExplorerViewRequestParamsRT, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initGetMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'get', + path: METRICS_EXPLORER_VIEW_URL_ENTITY, + validate: { + params: createValidationFunction(getMetricsExplorerViewRequestParamsRT), + query: createValidationFunction(metricsExplorerViewRequestQueryRT), + }, + }, + async (_requestContext, request, response) => { + const { params, query } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerView = await metricsExplorerViewsClient.get( + params.metricsExplorerViewId, + query + ); + + return response.ok({ + body: metricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerView }), + }); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts new file mode 100644 index 00000000000000..e4a61653744221 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InfraBackendLibs } from '../../lib/infra_types'; +import { initCreateMetricsExplorerViewRoute } from './create_metrics_explorer_view'; +import { initDeleteMetricsExplorerViewRoute } from './delete_metrics_explorer_view'; +import { initFindMetricsExplorerViewRoute } from './find_metrics_explorer_view'; +import { initGetMetricsExplorerViewRoute } from './get_metrics_explorer_view'; +import { initUpdateMetricsExplorerViewRoute } from './update_metrics_explorer_view'; + +export const initMetricsExplorerViewRoutes = ( + dependencies: Pick +) => { + initCreateMetricsExplorerViewRoute(dependencies); + initDeleteMetricsExplorerViewRoute(dependencies); + initFindMetricsExplorerViewRoute(dependencies); + initGetMetricsExplorerViewRoute(dependencies); + initUpdateMetricsExplorerViewRoute(dependencies); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.ts new file mode 100644 index 00000000000000..ebd8caef8e0300 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + metricsExplorerViewRequestParamsRT, + metricsExplorerViewRequestQueryRT, + metricsExplorerViewResponsePayloadRT, + METRICS_EXPLORER_VIEW_URL_ENTITY, + updateMetricsExplorerViewRequestPayloadRT, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initUpdateMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'put', + path: METRICS_EXPLORER_VIEW_URL_ENTITY, + validate: { + params: createValidationFunction(metricsExplorerViewRequestParamsRT), + query: createValidationFunction(metricsExplorerViewRequestQueryRT), + body: createValidationFunction(updateMetricsExplorerViewRequestPayloadRT), + }, + }, + async (_requestContext, request, response) => { + const { body, params, query } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerView = await metricsExplorerViewsClient.update( + params.metricsExplorerViewId, + body.attributes, + query + ); + + return response.ok({ + body: metricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerView }), + }); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts b/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts index 1168b2003994e8..15fe0eb970cc2e 100644 --- a/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts +++ b/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts @@ -5,14 +5,20 @@ * 2.0. */ -import { isoToEpochRt } from '@kbn/io-ts-utils'; +import { isoToEpochRt, nonEmptyStringRt } from '@kbn/io-ts-utils'; import * as rt from 'io-ts'; -import { metricsExplorerViewAttributesRT } from '../../../common/metrics_explorer_views'; + +export const metricsExplorerViewSavedObjectAttributesRT = rt.intersection([ + rt.strict({ + name: nonEmptyStringRt, + }), + rt.UnknownRecord, +]); export const metricsExplorerViewSavedObjectRT = rt.intersection([ rt.type({ id: rt.string, - attributes: metricsExplorerViewAttributesRT, + attributes: metricsExplorerViewSavedObjectAttributesRT, }), rt.partial({ version: rt.string, diff --git a/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts b/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts index 55a8df1024a6e8..c32da344354b62 100644 --- a/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts +++ b/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts @@ -35,18 +35,16 @@ export class InventoryViewsClient implements IInventoryViewsClient { ) {} static STATIC_VIEW_ID = '0'; + static DEFAULT_SOURCE_ID = 'default'; public async find(query: InventoryViewRequestQuery): Promise { this.logger.debug('Trying to load inventory views ...'); - const sourceId = query.sourceId ?? 'default'; + const sourceId = query.sourceId ?? InventoryViewsClient.DEFAULT_SOURCE_ID; const [sourceConfiguration, inventoryViewSavedObject] = await Promise.all([ this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), - this.savedObjectsClient.find({ - type: inventoryViewSavedObjectName, - perPage: 1000, // Fetch 1 page by default with a max of 1000 results - }), + this.getAllViews(), ]); const defaultView = InventoryViewsClient.createStaticView( @@ -72,7 +70,7 @@ export class InventoryViewsClient implements IInventoryViewsClient { ): Promise { this.logger.debug(`Trying to load inventory view with id ${inventoryViewId} ...`); - const sourceId = query.sourceId ?? 'default'; + const sourceId = query.sourceId ?? InventoryViewsClient.DEFAULT_SOURCE_ID; // Handle the case where the requested resource is the static inventory view if (inventoryViewId === InventoryViewsClient.STATIC_VIEW_ID) { @@ -123,7 +121,7 @@ export class InventoryViewsClient implements IInventoryViewsClient { // Validate there is not a view with the same name await this.assertNameConflict(attributes.name, [inventoryViewId]); - const sourceId = query.sourceId ?? 'default'; + const sourceId = query.sourceId ?? InventoryViewsClient.DEFAULT_SOURCE_ID; const [sourceConfiguration, inventoryViewSavedObject] = await Promise.all([ this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), @@ -160,6 +158,13 @@ export class InventoryViewsClient implements IInventoryViewsClient { }; } + private getAllViews() { + return this.savedObjectsClient.find({ + type: inventoryViewSavedObjectName, + perPage: 1000, // Fetch 1 page by default with a max of 1000 results + }); + } + private moveDefaultViewOnTop(views: InventoryView[]) { const defaultViewPosition = views.findIndex((view) => view.attributes.isDefault); @@ -175,10 +180,7 @@ export class InventoryViewsClient implements IInventoryViewsClient { * We want to control conflicting names on the views */ private async assertNameConflict(name: string, whitelist: string[] = []) { - const results = await this.savedObjectsClient.find({ - type: inventoryViewSavedObjectName, - perPage: 1000, - }); + const results = await this.getAllViews(); const hasConflict = [InventoryViewsClient.createStaticView(), ...results.saved_objects].some( (obj) => !whitelist.includes(obj.id) && obj.attributes.name === name diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts new file mode 100644 index 00000000000000..3cd3efd6c0f67d --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricsExplorerViewsService } from './metrics_explorer_views_service'; +export { MetricsExplorerViewsClient } from './metrics_explorer_views_client'; +export type { + MetricsExplorerViewsServiceSetup, + MetricsExplorerViewsServiceStart, + MetricsExplorerViewsServiceStartDeps, +} from './types'; diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts new file mode 100644 index 00000000000000..82a8cba3f6427e --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IMetricsExplorerViewsClient } from './types'; + +export const createMetricsExplorerViewsClientMock = + (): jest.Mocked => ({ + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }); diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts new file mode 100644 index 00000000000000..c903e9af360f89 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { MetricsExplorerViewAttributes } from '../../../common/metrics_explorer_views'; + +import { InfraSource } from '../../lib/sources'; +import { createInfraSourcesMock } from '../../lib/sources/mocks'; +import { metricsExplorerViewSavedObjectName } from '../../saved_objects/metrics_explorer_view'; +import { MetricsExplorerViewsClient } from './metrics_explorer_views_client'; +import { createMetricsExplorerViewMock } from '../../../common/metrics_explorer_views/metrics_explorer_view.mock'; +import { + CreateMetricsExplorerViewAttributesRequestPayload, + UpdateMetricsExplorerViewAttributesRequestPayload, +} from '../../../common/http_api/latest'; + +describe('MetricsExplorerViewsClient class', () => { + const mockFindMetricsExplorerList = ( + savedObjectsClient: jest.Mocked + ) => { + const metricsExplorerViewListMock = [ + createMetricsExplorerViewMock('0', { + isDefault: true, + } as MetricsExplorerViewAttributes), + createMetricsExplorerViewMock('default_id', { + name: 'Default view 2', + isStatic: false, + } as MetricsExplorerViewAttributes), + createMetricsExplorerViewMock('custom_id', { + name: 'Custom', + isStatic: false, + } as MetricsExplorerViewAttributes), + ]; + + savedObjectsClient.find.mockResolvedValue({ + total: 2, + saved_objects: metricsExplorerViewListMock.slice(1).map((view) => ({ + ...view, + type: metricsExplorerViewSavedObjectName, + score: 0, + references: [], + })), + per_page: 1000, + page: 1, + }); + + return metricsExplorerViewListMock; + }; + + describe('.find', () => { + it('resolves the list of existing metrics explorer views', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + const metricsExplorerViewListMock = mockFindMetricsExplorerList(savedObjectsClient); + + const metricsExplorerViewList = await metricsExplorerViewsClient.find({}); + + expect(savedObjectsClient.find).toHaveBeenCalled(); + expect(metricsExplorerViewList).toEqual(metricsExplorerViewListMock); + }); + + it('always resolves at least the static metrics explorer view', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + const metricsExplorerViewListMock = [ + createMetricsExplorerViewMock('0', { + isDefault: true, + } as MetricsExplorerViewAttributes), + ]; + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + savedObjectsClient.find.mockResolvedValue({ + total: 2, + saved_objects: [], + per_page: 1000, + page: 1, + }); + + const metricsExplorerViewList = await metricsExplorerViewsClient.find({}); + + expect(savedObjectsClient.find).toHaveBeenCalled(); + expect(metricsExplorerViewList).toEqual(metricsExplorerViewListMock); + }); + }); + + it('.get resolves the an metrics explorer view by id', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + const metricsExplorerViewMock = createMetricsExplorerViewMock('custom_id', { + name: 'Custom', + isDefault: false, + isStatic: false, + } as MetricsExplorerViewAttributes); + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + savedObjectsClient.get.mockResolvedValue({ + ...metricsExplorerViewMock, + type: metricsExplorerViewSavedObjectName, + references: [], + }); + + const metricsExplorerView = await metricsExplorerViewsClient.get('custom_id', {}); + + expect(savedObjectsClient.get).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual(metricsExplorerViewMock); + }); + + describe('.create', () => { + it('generate a new metrics explorer view', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + const metricsExplorerViewMock = createMetricsExplorerViewMock('new_id', { + name: 'New view', + isStatic: false, + } as MetricsExplorerViewAttributes); + + mockFindMetricsExplorerList(savedObjectsClient); + + savedObjectsClient.create.mockResolvedValue({ + ...metricsExplorerViewMock, + type: metricsExplorerViewSavedObjectName, + references: [], + }); + + const metricsExplorerView = await metricsExplorerViewsClient.create({ + name: 'New view', + } as CreateMetricsExplorerViewAttributesRequestPayload); + + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual(metricsExplorerViewMock); + }); + + it('throws an error when a conflicting name is given', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + mockFindMetricsExplorerList(savedObjectsClient); + + await expect( + async () => + await metricsExplorerViewsClient.create({ + name: 'Custom', + } as CreateMetricsExplorerViewAttributesRequestPayload) + ).rejects.toThrow('A view with that name already exists.'); + }); + }); + + describe('.update', () => { + it('update an existing metrics explorer view by id', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + const metricsExplorerViews = mockFindMetricsExplorerList(savedObjectsClient); + + const metricsExplorerViewMock = { + ...metricsExplorerViews[1], + attributes: { + ...metricsExplorerViews[1].attributes, + name: 'New name', + }, + }; + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + savedObjectsClient.update.mockResolvedValue({ + ...metricsExplorerViewMock, + type: metricsExplorerViewSavedObjectName, + references: [], + }); + + const metricsExplorerView = await metricsExplorerViewsClient.update( + 'default_id', + { + name: 'New name', + } as UpdateMetricsExplorerViewAttributesRequestPayload, + {} + ); + + expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual(metricsExplorerViewMock); + }); + + it('throws an error when a conflicting name is given', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + mockFindMetricsExplorerList(savedObjectsClient); + + await expect( + async () => + await metricsExplorerViewsClient.update( + 'default_id', + { + name: 'Custom', + } as UpdateMetricsExplorerViewAttributesRequestPayload, + {} + ) + ).rejects.toThrow('A view with that name already exists.'); + }); + }); + + it('.delete removes an metrics explorer view by id', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + savedObjectsClient.delete.mockResolvedValue({}); + + const metricsExplorerView = await metricsExplorerViewsClient.delete('custom_id'); + + expect(savedObjectsClient.delete).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual({}); + }); +}); + +const createMetricsExplorerViewsClient = () => { + const logger = loggerMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const infraSources = createInfraSourcesMock(); + + const metricsExplorerViewsClient = new MetricsExplorerViewsClient( + logger, + savedObjectsClient, + infraSources + ); + + return { + infraSources, + metricsExplorerViewsClient, + savedObjectsClient, + }; +}; + +const basicTestSourceConfiguration: InfraSource = { + id: 'ID', + origin: 'stored', + configuration: { + name: 'NAME', + description: 'DESCRIPTION', + logIndices: { + type: 'index_pattern', + indexPatternId: 'INDEX_PATTERN_ID', + }, + logColumns: [], + fields: { + message: [], + }, + metricAlias: 'METRIC_ALIAS', + inventoryDefaultView: '0', + metricsExplorerDefaultView: '0', + anomalyThreshold: 0, + }, +}; diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts new file mode 100644 index 00000000000000..1ba34456d88a8c --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from '@kbn/core/server'; +import Boom from '@hapi/boom'; +import { + staticMetricsExplorerViewAttributes, + staticMetricsExplorerViewId, +} from '../../../common/metrics_explorer_views'; +import type { + CreateMetricsExplorerViewAttributesRequestPayload, + MetricsExplorerViewRequestQuery, +} from '../../../common/http_api/latest'; +import type { + MetricsExplorerView, + MetricsExplorerViewAttributes, +} from '../../../common/metrics_explorer_views'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import type { IInfraSources } from '../../lib/sources'; +import { metricsExplorerViewSavedObjectName } from '../../saved_objects/metrics_explorer_view'; +import { metricsExplorerViewSavedObjectRT } from '../../saved_objects/metrics_explorer_view/types'; +import type { IMetricsExplorerViewsClient } from './types'; + +export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient { + constructor( + private readonly logger: Logger, + private readonly savedObjectsClient: SavedObjectsClientContract, + private readonly infraSources: IInfraSources + ) {} + + static STATIC_VIEW_ID = '0'; + static DEFAULT_SOURCE_ID = 'default'; + + public async find(query: MetricsExplorerViewRequestQuery): Promise { + this.logger.debug('Trying to load metrics explorer views ...'); + + const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID; + + const [sourceConfiguration, metricsExplorerViewSavedObject] = await Promise.all([ + this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), + this.getAllViews(), + ]); + + const defaultView = MetricsExplorerViewsClient.createStaticView( + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + const views = metricsExplorerViewSavedObject.saved_objects.map((savedObject) => + this.mapSavedObjectToMetricsExplorerView( + savedObject, + sourceConfiguration.configuration.metricsExplorerDefaultView + ) + ); + + const metricsExplorerViews = [defaultView, ...views]; + + const sortedMetricsExplorerViews = this.moveDefaultViewOnTop(metricsExplorerViews); + + return sortedMetricsExplorerViews; + } + + public async get( + metricsExplorerViewId: string, + query: MetricsExplorerViewRequestQuery + ): Promise { + this.logger.debug(`Trying to load metrics explorer view with id ${metricsExplorerViewId} ...`); + + const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID; + + // Handle the case where the requested resource is the static metrics explorer view + if (metricsExplorerViewId === MetricsExplorerViewsClient.STATIC_VIEW_ID) { + const sourceConfiguration = await this.infraSources.getSourceConfiguration( + this.savedObjectsClient, + sourceId + ); + + return MetricsExplorerViewsClient.createStaticView( + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + } + + const [sourceConfiguration, metricsExplorerViewSavedObject] = await Promise.all([ + this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), + this.savedObjectsClient.get(metricsExplorerViewSavedObjectName, metricsExplorerViewId), + ]); + + return this.mapSavedObjectToMetricsExplorerView( + metricsExplorerViewSavedObject, + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + } + + public async create( + attributes: CreateMetricsExplorerViewAttributesRequestPayload + ): Promise { + this.logger.debug(`Trying to create metrics explorer view ...`); + + // Validate there is not a view with the same name + await this.assertNameConflict(attributes.name); + + const metricsExplorerViewSavedObject = await this.savedObjectsClient.create( + metricsExplorerViewSavedObjectName, + attributes + ); + + return this.mapSavedObjectToMetricsExplorerView(metricsExplorerViewSavedObject); + } + + public async update( + metricsExplorerViewId: string, + attributes: CreateMetricsExplorerViewAttributesRequestPayload, + query: MetricsExplorerViewRequestQuery + ): Promise { + this.logger.debug( + `Trying to update metrics explorer view with id "${metricsExplorerViewId}"...` + ); + + // Validate there is not a view with the same name + await this.assertNameConflict(attributes.name, [metricsExplorerViewId]); + + const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID; + + const [sourceConfiguration, metricsExplorerViewSavedObject] = await Promise.all([ + this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), + this.savedObjectsClient.update( + metricsExplorerViewSavedObjectName, + metricsExplorerViewId, + attributes + ), + ]); + + return this.mapSavedObjectToMetricsExplorerView( + metricsExplorerViewSavedObject, + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + } + + public delete(metricsExplorerViewId: string): Promise<{}> { + this.logger.debug( + `Trying to delete metrics explorer view with id ${metricsExplorerViewId} ...` + ); + + return this.savedObjectsClient.delete( + metricsExplorerViewSavedObjectName, + metricsExplorerViewId + ); + } + + private getAllViews() { + return this.savedObjectsClient.find({ + type: metricsExplorerViewSavedObjectName, + perPage: 1000, // Fetch 1 page by default with a max of 1000 results + }); + } + + private mapSavedObjectToMetricsExplorerView( + savedObject: SavedObject | SavedObjectsUpdateResponse, + defaultViewId?: string + ) { + const metricsExplorerViewSavedObject = decodeOrThrow(metricsExplorerViewSavedObjectRT)( + savedObject + ); + + return { + id: metricsExplorerViewSavedObject.id, + version: metricsExplorerViewSavedObject.version, + updatedAt: metricsExplorerViewSavedObject.updated_at, + attributes: { + ...metricsExplorerViewSavedObject.attributes, + isDefault: metricsExplorerViewSavedObject.id === defaultViewId, + isStatic: false, + }, + }; + } + + private moveDefaultViewOnTop(views: MetricsExplorerView[]) { + const defaultViewPosition = views.findIndex((view) => view.attributes.isDefault); + + if (defaultViewPosition !== -1) { + const element = views.splice(defaultViewPosition, 1)[0]; + views.unshift(element); + } + + return views; + } + + /** + * We want to control conflicting names on the views + */ + private async assertNameConflict(name: string, whitelist: string[] = []) { + const results = await this.getAllViews(); + + const hasConflict = [ + MetricsExplorerViewsClient.createStaticView(), + ...results.saved_objects, + ].some((obj) => !whitelist.includes(obj.id) && obj.attributes.name === name); + + if (hasConflict) { + throw Boom.conflict('A view with that name already exists.'); + } + } + + private static createStaticView = (defaultViewId?: string): MetricsExplorerView => ({ + id: staticMetricsExplorerViewId, + attributes: { + ...staticMetricsExplorerViewAttributes, + isDefault: defaultViewId === MetricsExplorerViewsClient.STATIC_VIEW_ID, + }, + }); +} diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts new file mode 100644 index 00000000000000..37399309445714 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMetricsExplorerViewsClientMock } from './metrics_explorer_views_client.mock'; +import type { MetricsExplorerViewsServiceSetup, MetricsExplorerViewsServiceStart } from './types'; + +export const createMetricsExplorerViewsServiceSetupMock = + (): jest.Mocked => {}; + +export const createMetricsExplorerViewsServiceStartMock = + (): jest.Mocked => ({ + getClient: jest.fn((_savedObjectsClient: any) => createMetricsExplorerViewsClientMock()), + getScopedClient: jest.fn((_request: any) => createMetricsExplorerViewsClientMock()), + }); diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts new file mode 100644 index 00000000000000..38c7ab4e1f9258 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { MetricsExplorerViewsClient } from './metrics_explorer_views_client'; +import type { + MetricsExplorerViewsServiceSetup, + MetricsExplorerViewsServiceStart, + MetricsExplorerViewsServiceStartDeps, +} from './types'; + +export class MetricsExplorerViewsService { + constructor(private readonly logger: Logger) {} + + public setup(): MetricsExplorerViewsServiceSetup {} + + public start({ + infraSources, + savedObjects, + }: MetricsExplorerViewsServiceStartDeps): MetricsExplorerViewsServiceStart { + const { logger } = this; + + return { + getClient(savedObjectsClient: SavedObjectsClientContract) { + return new MetricsExplorerViewsClient(logger, savedObjectsClient, infraSources); + }, + + getScopedClient(request: KibanaRequest) { + const savedObjectsClient = savedObjects.getScopedClient(request); + + return this.getClient(savedObjectsClient); + }, + }; + } +} diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts new file mode 100644 index 00000000000000..0e64aaa83d27ec --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsServiceStart, +} from '@kbn/core/server'; +import type { + CreateMetricsExplorerViewAttributesRequestPayload, + MetricsExplorerViewRequestQuery, + UpdateMetricsExplorerViewAttributesRequestPayload, +} from '../../../common/http_api/latest'; +import type { MetricsExplorerView } from '../../../common/metrics_explorer_views'; +import type { InfraSources } from '../../lib/sources'; + +export interface MetricsExplorerViewsServiceStartDeps { + infraSources: InfraSources; + savedObjects: SavedObjectsServiceStart; +} + +export type MetricsExplorerViewsServiceSetup = void; + +export interface MetricsExplorerViewsServiceStart { + getClient(savedObjectsClient: SavedObjectsClientContract): IMetricsExplorerViewsClient; + getScopedClient(request: KibanaRequest): IMetricsExplorerViewsClient; +} + +export interface IMetricsExplorerViewsClient { + delete(metricsExplorerViewId: string): Promise<{}>; + find(query: MetricsExplorerViewRequestQuery): Promise; + get( + metricsExplorerViewId: string, + query: MetricsExplorerViewRequestQuery + ): Promise; + create( + metricsExplorerViewAttributes: CreateMetricsExplorerViewAttributesRequestPayload + ): Promise; + update( + metricsExplorerViewId: string, + metricsExplorerViewAttributes: UpdateMetricsExplorerViewAttributesRequestPayload, + query: MetricsExplorerViewRequestQuery + ): Promise; +} diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts index c415103d2256de..49dbca9b276b26 100644 --- a/x-pack/plugins/infra/server/types.ts +++ b/x-pack/plugins/infra/server/types.ts @@ -16,6 +16,7 @@ import type { InfraStaticSourceConfiguration } from '../common/source_configurat import { InfraServerPluginStartDeps } from './lib/adapters/framework'; import { InventoryViewsServiceStart } from './services/inventory_views'; import { LogViewsServiceSetup, LogViewsServiceStart } from './services/log_views/types'; +import { MetricsExplorerViewsServiceStart } from './services/metrics_explorer_views'; export type { InfraConfig } from '../common/plugin_config_types'; @@ -33,6 +34,7 @@ export interface InfraPluginSetup { export interface InfraPluginStart { inventoryViews: InventoryViewsServiceStart; logViews: LogViewsServiceStart; + metricsExplorerViews: MetricsExplorerViewsServiceStart; getMetricIndices: ( savedObjectsClient: SavedObjectsClientContract, sourceId?: string From 2376ee95e24ee0119a5cf4be9e1cc3d6d04f4994 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Wed, 26 Apr 2023 14:37:21 +0200 Subject: [PATCH 2/8] [Lens] Fix formula error popover too wide issue (#155529) ## Summary This PR limits the width for the formula error popup. Screenshot 2023-04-21 at 17 34 03 ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../formula/editor/formula_editor.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx index 941047519cdac1..c565c437bc59d7 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx @@ -908,18 +908,24 @@ export function FormulaEditor({ } > - {warnings.map(({ message, severity }, index) => ( -
- - {message} - -
- ))} +
+ {warnings.map(({ message, severity }, index) => ( +
+ + {message} + +
+ ))} +
) : null} From 049c51093b5cc44a4fcad4a81486db89b2fcd8ad Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 26 Apr 2023 09:02:59 -0400 Subject: [PATCH 3/8] [Dashboard] fix custom time ranges not applied to panel until global query context changes (#155458) Fixes https://github.com/elastic/kibana/issues/155409 https://github.com/elastic/kibana/pull/152516 incorrectly attempted to resolve https://github.com/elastic/kibana/issues/151221. https://github.com/elastic/kibana/pull/152516 updated shouldFetch$ to only check searchSessionId to determine if re-fetching is required. This logic did not work when custom time ranges are applied to panels since custom time ranges do not require new search session id yet the time range changed. This PR reverts shouldFetch$ logic of only checking searchSessionId to determine if re-fetching is required. Instead, this PR moves searchSessionId out of input and into dashboard instance state. That way, `input` updates, such as query, do not trigger additional `input` updates. The PR also updates seachSessionId logic from async to sync so that dashboard can update seachSessionId on input changes prior to child embeddables updating to parent input changes. This avoids the double fetch and allows children to only have a single input update on query state change. There was a functional test, panel_time_range, that should have caught the regression but that test had a bug in it. The assertion that the custom time range was applied looked like `expect(await testSubjects.exists('emptyPlaceholder'))` which will never fail the test because the last part of the expect is missing. Instead, the statements should be `expect(await testSubjects.exists('emptyPlaceholder')).to.be(true)`. These updates to the functional test would have caught the regression (I verified this by making these changes on main and running the test. They do indeed fail). --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: nreese --- .../create/create_dashboard.test.ts | 63 +++++++++++++++++ .../embeddable/create/create_dashboard.ts | 5 +- ...rt_dashboard_search_session_integration.ts | 11 ++- .../embeddable/dashboard_container.test.tsx | 15 ++-- .../embeddable/dashboard_container.tsx | 7 +- .../state/dashboard_container_reducers.ts | 7 -- .../diffing/dashboard_diffing_functions.ts | 21 +++++- .../dashboard_diffing_integration.test.ts | 47 +++++++++---- .../diffing/dashboard_diffing_integration.ts | 70 +++++++++---------- .../filterable_embeddable/should_fetch.tsx | 27 ++++--- .../test_suites/data_plugin/session.ts | 2 +- .../public/embeddable/embeddable.test.tsx | 5 -- .../apps/dashboard/group2/panel_time_range.ts | 8 +-- 13 files changed, 193 insertions(+), 95 deletions(-) diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts index 6f9f40745322ba..947b062b3a5512 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts @@ -7,7 +7,10 @@ */ import { + ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples'; import { @@ -253,3 +256,63 @@ test('creates a control group from the control group factory and waits for it to ); expect(mockControlGroupContainer.untilInitialized).toHaveBeenCalled(); }); + +/* + * dashboard.getInput$() subscriptions are used to update: + * 1) dashboard instance searchSessionId state + * 2) child input on parent input changes + * + * Rxjs subscriptions are executed in the order that they are created. + * This test ensures that searchSessionId update subscription is created before child input subscription + * to ensure child input subscription includes updated searchSessionId. + */ +test('searchSessionId is updated prior to child embeddable parent subscription execution', async () => { + const embeddableFactory = { + create: new ContactCardEmbeddableFactory((() => null) as any, {} as any), + getDefaultInput: jest.fn().mockResolvedValue({ + timeRange: { + to: 'now', + from: 'now-15m', + }, + }), + }; + pluginServices.getServices().embeddable.getEmbeddableFactory = jest + .fn() + .mockReturnValue(embeddableFactory); + let sessionCount = 0; + pluginServices.getServices().data.search.session.start = () => { + sessionCount++; + return `searchSessionId${sessionCount}`; + }; + const dashboard = await createDashboard(embeddableId, { + searchSessionSettings: { + getSearchSessionIdFromURL: () => undefined, + removeSessionIdFromUrl: () => {}, + createSessionRestorationDataProvider: () => {}, + } as unknown as DashboardCreationOptions['searchSessionSettings'], + }); + const embeddable = await dashboard.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Bob', + }); + + expect(embeddable.getInput().searchSessionId).toBe('searchSessionId1'); + + dashboard.updateInput({ + timeRange: { + to: 'now', + from: 'now-7d', + }, + }); + + expect(sessionCount).toBeGreaterThan(1); + const embeddableInput = embeddable.getInput(); + expect((embeddableInput as any).timeRange).toEqual({ + to: 'now', + from: 'now-7d', + }); + expect(embeddableInput.searchSessionId).toBe(`searchSessionId${sessionCount}`); +}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index f0a20e832e431f..ef810f025b84b7 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -217,6 +217,7 @@ export const createDashboard = async ( // -------------------------------------------------------------------------------------- // Set up search sessions integration. // -------------------------------------------------------------------------------------- + let initialSearchSessionId; if (searchSessionSettings) { const { sessionIdToRestore } = searchSessionSettings; @@ -229,7 +230,7 @@ export const createDashboard = async ( } const existingSession = session.getSessionId(); - const initialSearchSessionId = + initialSearchSessionId = sessionIdToRestore ?? (existingSession && incomingEmbeddable ? existingSession : session.start()); @@ -238,7 +239,6 @@ export const createDashboard = async ( creationOptions?.searchSessionSettings ); }); - initialInput.searchSessionId = initialSearchSessionId; } // -------------------------------------------------------------------------------------- @@ -284,6 +284,7 @@ export const createDashboard = async ( const dashboardContainer = new DashboardContainer( initialInput, reduxEmbeddablePackage, + initialSearchSessionId, savedObjectResult?.dashboardInput, dashboardCreationStartTime, undefined, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts index 506083ab253861..7f59b56c228b60 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts @@ -6,14 +6,13 @@ * Side Public License, v 1. */ -import { debounceTime, pairwise, skip } from 'rxjs/operators'; +import { pairwise, skip } from 'rxjs/operators'; import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public'; import { DashboardContainer } from '../../dashboard_container'; import { DashboardContainerInput } from '../../../../../common'; import { pluginServices } from '../../../../services/plugin_services'; -import { CHANGE_CHECK_DEBOUNCE } from '../../../../dashboard_constants'; import { DashboardCreationOptions } from '../../dashboard_container_factory'; import { getShouldRefresh } from '../../../state/diffing/dashboard_diffing_integration'; @@ -57,10 +56,10 @@ export function startDashboardSearchSessionIntegration( // listen to and compare states to determine when to launch a new session. this.getInput$() - .pipe(pairwise(), debounceTime(CHANGE_CHECK_DEBOUNCE)) - .subscribe(async (states) => { + .pipe(pairwise()) + .subscribe((states) => { const [previous, current] = states as DashboardContainerInput[]; - const shouldRefetch = await getShouldRefresh.bind(this)(previous, current); + const shouldRefetch = getShouldRefresh.bind(this)(previous, current); if (!shouldRefetch) return; const currentSearchSessionId = this.getState().explicitInput.searchSessionId; @@ -83,7 +82,7 @@ export function startDashboardSearchSessionIntegration( })(); if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) { - this.dispatch.setSearchSessionId(updatedSearchSessionId); + this.searchSessionId = updatedSearchSessionId; } }); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx index bdce2754ba0dd4..5a360446f03a81 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; import { findTestSubject, nextTick } from '@kbn/test-jest-helpers'; import { I18nProvider } from '@kbn/i18n-react'; import { @@ -29,9 +30,10 @@ import { applicationServiceMock, coreMock } from '@kbn/core/public/mocks'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { createEditModeActionDefinition } from '@kbn/embeddable-plugin/public/lib/test_samples'; -import { buildMockDashboard, getSampleDashboardPanel } from '../../mocks'; +import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks'; import { pluginServices } from '../../services/plugin_services'; import { ApplicationStart } from '@kbn/core-application-browser'; +import { DashboardContainer } from './dashboard_container'; const theme = coreMock.createStart().theme; let application: ApplicationStart | undefined; @@ -171,7 +173,11 @@ test('Container view mode change propagates to new children', async () => { test('searchSessionId propagates to children', async () => { const searchSessionId1 = 'searchSessionId1'; - const container = buildMockDashboard({ searchSessionId: searchSessionId1 }); + const container = new DashboardContainer( + getSampleDashboardInput(), + mockedReduxEmbeddablePackage, + searchSessionId1 + ); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, @@ -181,11 +187,6 @@ test('searchSessionId propagates to children', async () => { }); expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1); - - const searchSessionId2 = 'searchSessionId2'; - container.updateInput({ searchSessionId: searchSessionId2 }); - - expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2); }); test('DashboardContainer in edit mode shows edit mode actions', async () => { diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index d5a5385e779b36..a0aec2c3955240 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -95,6 +95,8 @@ export class DashboardContainer extends Container void; private cleanupStateTools: () => void; @@ -117,6 +119,7 @@ export class DashboardContainer extends Container - ) => { - state.explicitInput.searchSessionId = action.payload; - }, - // ------------------------------------------------------------------------------ // Unsaved Changes Reducers // ------------------------------------------------------------------------------ diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts index 7f2a55044b527f..fe8e18528e2c09 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts @@ -37,7 +37,7 @@ export type DashboardDiffFunctions = { ) => boolean | Promise; }; -export const isKeyEqual = async ( +export const isKeyEqualAsync = async ( key: keyof DashboardContainerInput, diffFunctionProps: DiffFunctionProps, diffingFunctions: DashboardDiffFunctions @@ -52,6 +52,25 @@ export const isKeyEqual = async ( return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); }; +export const isKeyEqual = ( + key: keyof Omit, // only Panels is async + diffFunctionProps: DiffFunctionProps, + diffingFunctions: DashboardDiffFunctions +) => { + const propsAsNever = diffFunctionProps as never; // todo figure out why props has conflicting types in some constituents. + const diffingFunction = diffingFunctions[key]; + if (!diffingFunction) { + return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); + } + + if (diffingFunction?.prototype?.name === 'AsyncFunction') { + throw new Error( + `The function for key "${key}" is async, must use isKeyEqualAsync for asynchronous functions` + ); + } + return diffingFunction(propsAsNever); +}; + /** * A collection of functions which diff individual keys of dashboard state. If a key is missing from this list it is * diffed by the default diffing function, fastIsEqual. diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts index c0953f8bbc98a3..b79eb27af3d793 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts @@ -29,14 +29,14 @@ describe('getShouldRefresh', () => { ); describe('filter changes', () => { - test('should return false when filters do not change', async () => { + test('should return false when filters do not change', () => { const lastInput = { filters: [existsFilter], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); }); - test('should return true when pinned filters change', async () => { + test('should return true when pinned filters change', () => { const pinnedFilter = pinFilter(existsFilter); const lastInput = { filters: [pinnedFilter], @@ -44,10 +44,10 @@ describe('getShouldRefresh', () => { const input = { filters: [toggleFilterNegated(pinnedFilter)], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); }); - test('should return false when disabled filters change', async () => { + test('should return false when disabled filters change', () => { const disabledFilter = disableFilter(existsFilter); const lastInput = { filters: [disabledFilter], @@ -55,29 +55,29 @@ describe('getShouldRefresh', () => { const input = { filters: [toggleFilterNegated(disabledFilter)], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); }); - test('should return false when pinned filter changes to unpinned', async () => { + test('should return false when pinned filter changes to unpinned', () => { const lastInput = { filters: [existsFilter], } as unknown as DashboardContainerInput; const input = { filters: [pinFilter(existsFilter)], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); }); }); describe('timeRange changes', () => { - test('should return false when timeRange does not change', async () => { + test('should return false when timeRange does not change', () => { const lastInput = { timeRange: { from: 'now-15m', to: 'now' }, } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); }); - test('should return true when timeRange changes (timeRestore is true)', async () => { + test('should return true when timeRange changes (timeRestore is true)', () => { const lastInput = { timeRange: { from: 'now-15m', to: 'now' }, timeRestore: true, @@ -86,10 +86,10 @@ describe('getShouldRefresh', () => { timeRange: { from: 'now-30m', to: 'now' }, timeRestore: true, } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); }); - test('should return true when timeRange changes (timeRestore is false)', async () => { + test('should return true when timeRange changes (timeRestore is false)', () => { const lastInput = { timeRange: { from: 'now-15m', to: 'now' }, timeRestore: false, @@ -98,7 +98,26 @@ describe('getShouldRefresh', () => { timeRange: { from: 'now-30m', to: 'now' }, timeRestore: false, } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + }); + }); + + describe('key without custom diffing function (syncColors)', () => { + test('should return false when syncColors do not change', () => { + const lastInput = { + syncColors: false, + } as unknown as DashboardContainerInput; + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); + }); + + test('should return true when syncColors change', () => { + const lastInput = { + syncColors: false, + } as unknown as DashboardContainerInput; + const input = { + syncColors: true, + } as unknown as DashboardContainerInput; + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); }); }); }); diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index f91cfe51fe739c..897ac529fe61d0 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -9,13 +9,13 @@ import { omit } from 'lodash'; import { AnyAction, Middleware } from 'redux'; import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs'; -import { DashboardContainerInput } from '../../../../common'; -import type { DashboardDiffFunctions } from './dashboard_diffing_functions'; import { isKeyEqual, + isKeyEqualAsync, shouldRefreshDiffingFunctions, unsavedChangesDiffingFunctions, } from './dashboard_diffing_functions'; +import { DashboardContainerInput } from '../../../../common'; import { pluginServices } from '../../../services/plugin_services'; import { DashboardContainer, DashboardCreationOptions } from '../..'; import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; @@ -29,7 +29,6 @@ import { dashboardContainerReducers } from '../dashboard_container_reducers'; export const reducersToIgnore: Array = [ 'setTimeslice', 'setFullScreenMode', - 'setSearchSessionId', 'setExpandedPanelId', 'setHasUnsavedChanges', ]; @@ -40,7 +39,6 @@ export const reducersToIgnore: Array = const keysToOmitFromSessionStorage: Array = [ 'lastReloadRequestTime', 'executionContext', - 'searchSessionId', 'timeslice', 'id', @@ -55,7 +53,6 @@ const keysToOmitFromSessionStorage: Array = [ export const keysNotConsideredUnsavedChanges: Array = [ 'lastReloadRequestTime', 'executionContext', - 'searchSessionId', 'timeslice', 'viewMode', 'id', @@ -64,7 +61,7 @@ export const keysNotConsideredUnsavedChanges: Array = [ +const sessionChangeKeys: Array> = [ 'query', 'filters', 'timeRange', @@ -139,42 +136,17 @@ export async function getUnsavedChanges( const allKeys = [...new Set([...Object.keys(lastInput), ...Object.keys(input)])] as Array< keyof DashboardContainerInput >; - return await getInputChanges(this, lastInput, input, allKeys, unsavedChangesDiffingFunctions); -} - -export async function getShouldRefresh( - this: DashboardContainer, - lastInput: DashboardContainerInput, - input: DashboardContainerInput -): Promise { - const inputChanges = await getInputChanges( - this, - lastInput, - input, - refetchKeys, - shouldRefreshDiffingFunctions - ); - return Object.keys(inputChanges).length > 0; -} - -async function getInputChanges( - container: DashboardContainer, - lastInput: DashboardContainerInput, - input: DashboardContainerInput, - keys: Array, - diffingFunctions: DashboardDiffFunctions -): Promise> { - const keyComparePromises = keys.map( + const keyComparePromises = allKeys.map( (key) => new Promise<{ key: keyof DashboardContainerInput; isEqual: boolean }>((resolve) => { if (input[key] === undefined && lastInput[key] === undefined) { resolve({ key, isEqual: true }); } - isKeyEqual( + isKeyEqualAsync( key, { - container, + container: this, currentValue: input[key], currentInput: input, @@ -182,7 +154,7 @@ async function getInputChanges( lastValue: lastInput[key], lastInput, }, - diffingFunctions + unsavedChangesDiffingFunctions ).then((isEqual) => resolve({ key, isEqual })); }) ); @@ -196,6 +168,34 @@ async function getInputChanges( return inputChanges; } +export function getShouldRefresh( + this: DashboardContainer, + lastInput: DashboardContainerInput, + input: DashboardContainerInput +): boolean { + for (const key of sessionChangeKeys) { + if (input[key] === undefined && lastInput[key] === undefined) { + continue; + } + if ( + !isKeyEqual( + key, + { + container: this, + currentValue: input[key], + currentInput: input, + lastValue: lastInput[key], + lastInput, + }, + shouldRefreshDiffingFunctions + ) + ) { + return true; + } + } + return false; +} + function updateUnsavedChangesState( this: DashboardContainer, unsavedChanges: Partial diff --git a/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx b/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx index b8b9fb1f4795f3..68d9df23bb612d 100644 --- a/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx +++ b/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx @@ -27,19 +27,24 @@ export function shouldFetch$< return updated$.pipe(map(() => getInput())).pipe( // wrapping distinctUntilChanged with startWith and skip to prime distinctUntilChanged with an initial input value. startWith(getInput()), - distinctUntilChanged((a: TFilterableEmbeddableInput, b: TFilterableEmbeddableInput) => { - // Only need to diff searchSessionId when container uses search sessions because - // searchSessionId changes with any filter, query, or time changes - if (a.searchSessionId !== undefined || b.searchSessionId !== undefined) { - return a.searchSessionId === b.searchSessionId; - } + distinctUntilChanged( + (previous: TFilterableEmbeddableInput, current: TFilterableEmbeddableInput) => { + if ( + !fastIsEqual( + [previous.searchSessionId, previous.query, previous.timeRange, previous.timeslice], + [current.searchSessionId, current.query, current.timeRange, current.timeslice] + ) + ) { + return false; + } - if (!fastIsEqual([a.query, a.timeRange, a.timeslice], [b.query, b.timeRange, b.timeslice])) { - return false; + return onlyDisabledFiltersChanged( + previous.filters, + current.filters, + shouldRefreshFilterCompareOptions + ); } - - return onlyDisabledFiltersChanged(a.filters, b.filters, shouldRefreshFilterCompareOptions); - }), + ), skip(1) ); } diff --git a/test/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index 6c485a76db32ee..469e6f992e79f0 100644 --- a/test/plugin_functional/test_suites/data_plugin/session.ts +++ b/test/plugin_functional/test_suites/data_plugin/session.ts @@ -106,7 +106,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('starts a session on filter change', async () => { - await filterBar.removeAllFilters(); + await filterBar.removeFilter('animal'); const sessionIds = await getSessionIds(); expect(sessionIds.length).to.be(1); }); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx index c127e9f1130d3e..5e48e1f46dd638 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx @@ -441,11 +441,6 @@ describe('embeddable', () => { expect(expressionRenderer).toHaveBeenCalledTimes(1); - embeddable.updateInput({ - filters: [{ meta: { alias: 'test', negate: false, disabled: false } }], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - embeddable.updateInput({ searchSessionId: 'nextSession', }); diff --git a/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts b/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts index ee07446783603b..2295c90d60c65a 100644 --- a/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts +++ b/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts @@ -50,7 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectExistsTimeRangeBadgeAction(); - expect(await testSubjects.exists('emptyPlaceholder')); + expect(await testSubjects.exists('emptyPlaceholder')).to.be(true); await PageObjects.dashboard.clickQuickSave(); }); @@ -60,7 +60,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectMissingTimeRangeBadgeAction(); - expect(await testSubjects.exists('xyVisChart')); + expect(await testSubjects.exists('xyVisChart')).to.be(true); }); }); @@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectExistsTimeRangeBadgeAction(); - expect(await testSubjects.exists('emptyPlaceholder')); + expect(await testSubjects.exists('emptyPlaceholder')).to.be(true); await PageObjects.dashboard.clickQuickSave(); }); @@ -84,7 +84,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectMissingTimeRangeBadgeAction(); - expect(await testSubjects.exists('xyVisChart')); + expect(await testSubjects.exists('xyVisChart')).to.be(true); }); }); From 341974ae1629e8f2dc86853dfbfec98517f900b4 Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 26 Apr 2023 15:12:57 +0200 Subject: [PATCH 4/8] [Enterprise Search] [Behavioral analytics] Add locations table (#155807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✔️ Add locations table with flag - ✔️ Create locations formula with multi_terms - ✔️ Fix bug with updating explore table after dataView changed - ✔️ Show flags of countries by iso code image --- .../analytics_collection_data_view_logic.ts | 2 +- ...ytics_collection_explore_table_formulas.ts | 4 +- ...ics_collection_explore_table_logic.test.ts | 30 +-- ...nalytics_collection_explore_table_logic.ts | 172 +++++++++++++----- ...nalytics_collection_explore_table_types.ts | 21 ++- ...alytics_collection_explorer_table.test.tsx | 4 +- .../analytics_collection_explorer_table.tsx | 66 ++++++- ...alytics_collection_overview_table.test.tsx | 2 +- .../analytics_collection_overview_table.tsx | 67 ++++++- .../applications/analytics/utils/get_flag.ts | 13 ++ 10 files changed, 299 insertions(+), 82 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts index f00ef0fecc1edf..bebd7e04278c91 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts @@ -20,7 +20,7 @@ export interface AnalyticsCollectionDataViewLogicValues { dataView: DataView | null; } -interface AnalyticsCollectionDataViewLogicActions { +export interface AnalyticsCollectionDataViewLogicActions { fetchedAnalyticsCollection: FetchAnalyticsCollectionActions['apiSuccess']; setDataView(dataView: DataView): { dataView: DataView }; } diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts index 87e9812b94ac7a..b809f23b53d023 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IKibanaSearchRequest, TimeRange } from '@kbn/data-plugin/common'; +import { DataView, IKibanaSearchRequest, TimeRange } from '@kbn/data-plugin/common'; const getSearchQueryRequestParams = (field: string, search: string): { regexp: {} } => { const createRegexQuery = (queryString: string) => { @@ -44,6 +44,7 @@ export const getPaginationRequestParams = (pageIndex: number, pageSize: number) }); export const getBaseSearchTemplate = ( + dataView: DataView, aggregationFieldName: string, { search, @@ -53,6 +54,7 @@ export const getBaseSearchTemplate = ( aggs: IKibanaSearchRequest['params']['aggs'] ): IKibanaSearchRequest => ({ params: { + index: dataView.title, aggs, query: { bool: { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts index 226c521c448947..8c37a7f41e8c2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts @@ -7,6 +7,7 @@ import { LogicMounter } from '../../../__mocks__/kea_logic'; +import { DataView } from '@kbn/data-views-plugin/common'; import { nextTick } from '@kbn/test-jest-helpers'; import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; @@ -87,7 +88,8 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { describe('isLoading', () => { beforeEach(() => { - mount({ selectedTable: ExploreTables.TopReferrers }); + mount({ selectedTable: ExploreTables.Referrers }); + AnalyticsCollectionExploreTableLogic.actions.setDataView({ id: 'test' } as DataView); }); it('should handle onTableChange', () => { @@ -112,7 +114,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { }); it('should handle setSelectedTable', () => { - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true); }); @@ -139,7 +141,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionExploreTableLogic.actions.onTableChange({ page: { index: 2, size: 10 }, }); - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.pageIndex).toEqual(0); }); @@ -172,7 +174,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionExploreTableLogic.actions.onTableChange({ page: { index: 2, size: 10 }, }); - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.pageSize).toEqual(10); }); @@ -193,7 +195,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { it('should handle setSelectedTable', () => { AnalyticsCollectionExploreTableLogic.actions.setSearch('test'); - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.search).toEqual(''); }); @@ -211,10 +213,16 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { }); describe('listeners', () => { + const mockDataView = { id: 'test' } as DataView; + beforeEach(() => { + mount({ selectedTable: ExploreTables.Referrers }); + AnalyticsCollectionExploreTableLogic.actions.setDataView(mockDataView); + }); + it('should fetch items when selectedTable changes', () => { - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); @@ -225,7 +233,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionToolbarLogic.actions.setTimeRange({ from: 'now-7d', to: 'now' }); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); @@ -236,7 +244,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionToolbarLogic.actions.setSearchSessionId('1234'); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: '1234', }); }); @@ -247,7 +255,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionExploreTableLogic.actions.onTableChange({}); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); @@ -262,7 +270,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { await nextTick(); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts index e5e181ccfa2660..26d9a227eb1c00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts @@ -8,6 +8,7 @@ import { kea, MakeLogicType } from 'kea'; import { + DataView, IKibanaSearchRequest, IKibanaSearchResponse, isCompleteResponse, @@ -18,6 +19,7 @@ import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; import { AnalyticsCollectionDataViewLogic, + AnalyticsCollectionDataViewLogicActions, AnalyticsCollectionDataViewLogicValues, } from './analytics_collection_data_view_logic'; @@ -32,9 +34,10 @@ import { ExploreTableItem, ExploreTables, SearchTermsTable, - TopClickedTable, - TopReferrersTable, + ClickedTable, + ReferrersTable, WorsePerformersTable, + LocationsTable, } from './analytics_collection_explore_table_types'; import { AnalyticsCollectionToolbarLogic, @@ -51,43 +54,50 @@ export interface Sorting { interface TableParams { parseResponse(response: IKibanaSearchResponse): { items: T[]; totalCount: number }; - requestParams(props: { - pageIndex: number; - pageSize: number; - search: string; - sorting: Sorting | null; - timeRange: TimeRange; - }): IKibanaSearchRequest; + requestParams( + dataView: DataView, + props: { + pageIndex: number; + pageSize: number; + search: string; + sorting: Sorting | null; + timeRange: TimeRange; + } + ): IKibanaSearchRequest; } const tablesParams: { + [ExploreTables.Clicked]: TableParams; + [ExploreTables.Locations]: TableParams; + [ExploreTables.Referrers]: TableParams; [ExploreTables.SearchTerms]: TableParams; - [ExploreTables.TopClicked]: TableParams; - [ExploreTables.TopReferrers]: TableParams; [ExploreTables.WorsePerformers]: TableParams; } = { [ExploreTables.SearchTerms]: { parseResponse: ( response: IKibanaSearchResponse<{ - aggregations: { + aggregations?: { searches: { buckets: Array<{ doc_count: number; key: string }> }; totalCount: { value: number }; }; }> ) => ({ - items: response.rawResponse.aggregations.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.count]: bucket.doc_count, - [ExploreTableColumns.searchTerms]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.totalCount.value, + items: + response.rawResponse.aggregations?.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.count]: bucket.doc_count, + [ExploreTableColumns.searchTerms]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'search.query' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'search' }, + { eventType: 'search', search, timeRange }, { searches: { terms: { @@ -109,7 +119,7 @@ const tablesParams: { [ExploreTables.WorsePerformers]: { parseResponse: ( response: IKibanaSearchResponse<{ - aggregations: { + aggregations?: { formula: { searches: { buckets: Array<{ doc_count: number; key: string }> }; totalCount: { value: number }; @@ -117,19 +127,22 @@ const tablesParams: { }; }> ) => ({ - items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.count]: bucket.doc_count, - [ExploreTableColumns.query]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.formula.totalCount.value, + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.count]: bucket.doc_count, + [ExploreTableColumns.query]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'search.query' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'search' }, + { eventType: 'search', search, timeRange }, { formula: { aggs: { @@ -153,7 +166,7 @@ const tablesParams: { } ), }, - [ExploreTables.TopClicked]: { + [ExploreTables.Clicked]: { parseResponse: ( response: IKibanaSearchResponse<{ aggregations: { @@ -164,19 +177,22 @@ const tablesParams: { }; }> ) => ({ - items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.count]: bucket.doc_count, - [ExploreTableColumns.page]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.formula.totalCount.value, + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.count]: bucket.doc_count, + [ExploreTableColumns.page]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'search.results.items.page.url' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'search_click' }, + { eventType: 'search_click', search, timeRange }, { formula: { aggs: { @@ -200,10 +216,10 @@ const tablesParams: { } ), }, - [ExploreTables.TopReferrers]: { + [ExploreTables.Referrers]: { parseResponse: ( response: IKibanaSearchResponse<{ - aggregations: { + aggregations?: { formula: { searches: { buckets: Array<{ doc_count: number; key: string }> }; totalCount: { value: number }; @@ -211,19 +227,22 @@ const tablesParams: { }; }> ) => ({ - items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.sessions]: bucket.doc_count, - [ExploreTableColumns.page]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.formula.totalCount.value, + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.sessions]: bucket.doc_count, + [ExploreTableColumns.page]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'page.referrer' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'page_view' }, + { eventType: 'page_view', search, timeRange }, { formula: { aggs: { @@ -247,6 +266,60 @@ const tablesParams: { } ), }, + [ExploreTables.Locations]: { + parseResponse: ( + response: IKibanaSearchResponse<{ + aggregations?: { + formula: { + searches: { buckets: Array<{ doc_count: number; key: string }> }; + totalCount: { value: number }; + }; + }; + }> + ) => ({ + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.sessions]: bucket.doc_count, + [ExploreTableColumns.location]: bucket.key[0], + countryISOCode: bucket.key[1], + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, + }), + requestParams: ( + dataView, + { timeRange, sorting, pageIndex, pageSize, search }, + aggregationFieldName = 'session.location.country_name' + ) => + getBaseSearchTemplate( + dataView, + aggregationFieldName, + { eventType: 'page_view', search, timeRange }, + { + formula: { + aggs: { + ...getTotalCountRequestParams(aggregationFieldName), + searches: { + multi_terms: { + ...getPaginationRequestSizeParams(pageIndex, pageSize), + order: sorting + ? { + [sorting?.field === ExploreTableColumns.sessions ? '_count' : '_key']: + sorting?.direction, + } + : undefined, + terms: [ + { field: aggregationFieldName }, + { field: 'session.location.country_iso_code' }, + ], + }, + ...getPaginationRequestParams(pageIndex, pageSize), + }, + }, + filter: { term: { 'event.action': 'page_view' } }, + }, + } + ), + }, }; export interface AnalyticsCollectionExploreTableLogicValues { @@ -269,6 +342,7 @@ export interface AnalyticsCollectionExploreTableLogicActions { sort?: Sorting; }; reset(): void; + setDataView: AnalyticsCollectionDataViewLogicActions['setDataView']; setItems(items: ExploreTableItem[]): { items: ExploreTableItem[] }; setSearch(search: string): { search: string }; setSelectedTable( @@ -293,7 +367,12 @@ export const AnalyticsCollectionExploreTableLogic = kea< setTotalItemsCount: (count) => ({ count }), }, connect: { - actions: [AnalyticsCollectionToolbarLogic, ['setTimeRange', 'setSearchSessionId']], + actions: [ + AnalyticsCollectionToolbarLogic, + ['setTimeRange', 'setSearchSessionId'], + AnalyticsCollectionDataViewLogic, + ['setDataView'], + ], values: [ AnalyticsCollectionDataViewLogic, ['dataView'], @@ -303,7 +382,11 @@ export const AnalyticsCollectionExploreTableLogic = kea< }, listeners: ({ actions, values }) => { const fetchItems = () => { - if (values.selectedTable === null || !(values.selectedTable in tablesParams)) { + if ( + values.selectedTable === null || + !(values.selectedTable in tablesParams) || + !values.dataView + ) { actions.setItems([]); actions.setTotalItemsCount(0); @@ -315,7 +398,7 @@ export const AnalyticsCollectionExploreTableLogic = kea< const search$ = KibanaLogic.values.data.search .search( - requestParams({ + requestParams(values.dataView, { pageIndex: values.pageIndex, pageSize: values.pageSize, search: values.search, @@ -323,7 +406,7 @@ export const AnalyticsCollectionExploreTableLogic = kea< timeRange, }), { - indexPattern: values.dataView || undefined, + indexPattern: values.dataView, sessionId: values.searchSessionId, } ) @@ -345,6 +428,7 @@ export const AnalyticsCollectionExploreTableLogic = kea< return { onTableChange: fetchItems, + setDataView: fetchItems, setSearch: async (_, breakpoint) => { await breakpoint(SEARCH_COOLDOWN); fetchItems(); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts index c9c6e4c3a0244c..ffca2172440b3b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts @@ -8,14 +8,16 @@ export enum ExploreTables { SearchTerms, WorsePerformers, - TopClicked, - TopReferrers, + Clicked, + Referrers, + Locations, } export enum ExploreTableColumns { count = 'count', searchTerms = 'searchTerms', query = 'query', + location = 'location', page = 'page', sessions = 'sessions', } @@ -30,18 +32,25 @@ export interface WorsePerformersTable { [ExploreTableColumns.query]: string; } -export interface TopClickedTable { +export interface ClickedTable { [ExploreTableColumns.count]: number; [ExploreTableColumns.page]: string; } -export interface TopReferrersTable { +export interface ReferrersTable { [ExploreTableColumns.page]: string; [ExploreTableColumns.sessions]: number; } +export interface LocationsTable { + [ExploreTableColumns.location]: string; + [ExploreTableColumns.sessions]: number; + countryISOCode: string; +} + export type ExploreTableItem = | SearchTermsTable | WorsePerformersTable - | TopClickedTable - | TopReferrersTable; + | ClickedTable + | ReferrersTable + | LocationsTable; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx index fc702a0493369d..3fc29c6d9e687d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx @@ -27,7 +27,7 @@ describe('AnalyticsCollectionExplorerTable', () => { beforeEach(() => { jest.clearAllMocks(); - setMockValues({ items: [], selectedTable: ExploreTables.TopClicked }); + setMockValues({ items: [], selectedTable: ExploreTables.Clicked }); setMockActions(mockActions); }); @@ -46,7 +46,7 @@ describe('AnalyticsCollectionExplorerTable', () => { it('should call setSelectedTable when click on a tab', () => { const tabs = shallow().find('EuiTab'); - expect(tabs.length).toBe(4); + expect(tabs.length).toBe(5); tabs.at(2).simulate('click'); expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.WorsePerformers, { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx index cc104fe93b7ba4..35cf7afbd12432 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx @@ -32,15 +32,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { getFlag } from '../../../utils/get_flag'; import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic'; import { ExploreTableColumns, ExploreTableItem, ExploreTables, SearchTermsTable, - TopClickedTable, - TopReferrersTable, + ClickedTable, + ReferrersTable, WorsePerformersTable, + LocationsTable, } from '../analytics_collection_explore_table_types'; import { AnalyticsCollectionExplorerCallout } from './analytics_collection_explorer_callout'; @@ -63,7 +65,7 @@ const tabs: Array<{ id: ExploreTables; name: string }> = [ ), }, { - id: ExploreTables.TopClicked, + id: ExploreTables.Clicked, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.topClickedTab', { defaultMessage: 'Top clicked results' } @@ -77,7 +79,14 @@ const tabs: Array<{ id: ExploreTables; name: string }> = [ ), }, { - id: ExploreTables.TopReferrers, + id: ExploreTables.Locations, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.locationsTab', + { defaultMessage: 'Locations' } + ), + }, + { + id: ExploreTables.Referrers, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.referrersTab', { defaultMessage: 'Referrers' } @@ -86,9 +95,10 @@ const tabs: Array<{ id: ExploreTables; name: string }> = [ ]; const tableSettings: { + [ExploreTables.Clicked]: TableSetting; + [ExploreTables.Locations]: TableSetting; + [ExploreTables.Referrers]: TableSetting; [ExploreTables.SearchTerms]: TableSetting; - [ExploreTables.TopClicked]: TableSetting; - [ExploreTables.TopReferrers]: TableSetting; [ExploreTables.WorsePerformers]: TableSetting; } = { [ExploreTables.SearchTerms]: { @@ -149,7 +159,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopClicked]: { + [ExploreTables.Clicked]: { columns: [ { field: ExploreTableColumns.page, @@ -184,7 +194,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopReferrers]: { + [ExploreTables.Referrers]: { columns: [ { field: ExploreTableColumns.page, @@ -219,6 +229,46 @@ const tableSettings: { }, }, }, + [ExploreTables.Locations]: { + columns: [ + { + field: ExploreTableColumns.location, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.location', + { defaultMessage: 'Location' } + ), + render: (euiTheme: UseEuiTheme['euiTheme']) => (value: string, data: LocationsTable) => + ( + + +

{getFlag(data.countryISOCode)}

+
+ +

{value}

+
+
+ ), + sortable: true, + truncateText: true, + }, + { + align: 'right', + field: ExploreTableColumns.sessions, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.session', + { defaultMessage: 'Session' } + ), + sortable: true, + truncateText: true, + }, + ], + sorting: { + sort: { + direction: 'desc', + field: ExploreTableColumns.sessions, + }, + }, + }, }; export const AnalyticsCollectionExplorerTable = () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx index e6e553dc51792c..60d50e28fa802e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx @@ -48,7 +48,7 @@ describe('AnalyticsCollectionOverviewTable', () => { topReferrersTab.simulate('click'); expect(mockActions.setSelectedTable).toHaveBeenCalledTimes(1); - expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.TopReferrers, { + expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.Locations, { direction: 'desc', field: 'sessions', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx index 8538b11f748fe3..3bad1189a0181a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx @@ -25,6 +25,7 @@ import { EuiTableSortingType, } from '@elastic/eui/src/components/basic_table/table_types'; import { UseEuiTheme } from '@elastic/eui/src/services/theme/hooks'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -32,6 +33,7 @@ import { generateEncodedPath } from '../../../../shared/encode_path_params'; import { KibanaLogic } from '../../../../shared/kibana'; import { COLLECTION_EXPLORER_PATH } from '../../../routes'; +import { getFlag } from '../../../utils/get_flag'; import { FilterBy } from '../../../utils/get_formula_by_filter'; import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic'; @@ -40,9 +42,10 @@ import { ExploreTableItem, ExploreTables, SearchTermsTable, - TopClickedTable, - TopReferrersTable, + ClickedTable, + ReferrersTable, WorsePerformersTable, + LocationsTable, } from '../analytics_collection_explore_table_types'; import { FetchAnalyticsCollectionLogic } from '../fetch_analytics_collection_logic'; @@ -67,7 +70,7 @@ const tabsByFilter: Record> ], [FilterBy.Clicks]: [ { - id: ExploreTables.TopClicked, + id: ExploreTables.Clicked, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topClicked', { defaultMessage: 'Top clicked results' } @@ -76,7 +79,14 @@ const tabsByFilter: Record> ], [FilterBy.Sessions]: [ { - id: ExploreTables.TopReferrers, + id: ExploreTables.Locations, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topLocations', + { defaultMessage: 'Top locations' } + ), + }, + { + id: ExploreTables.Referrers, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topReferrers', { defaultMessage: 'Top referrers' } @@ -95,9 +105,10 @@ interface TableSetting { } const tableSettings: { + [ExploreTables.Clicked]: TableSetting; + [ExploreTables.Locations]: TableSetting; + [ExploreTables.Referrers]: TableSetting; [ExploreTables.SearchTerms]: TableSetting; - [ExploreTables.TopClicked]: TableSetting; - [ExploreTables.TopReferrers]: TableSetting; [ExploreTables.WorsePerformers]: TableSetting; } = { [ExploreTables.SearchTerms]: { @@ -158,7 +169,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopClicked]: { + [ExploreTables.Clicked]: { columns: [ { field: ExploreTableColumns.page, @@ -193,7 +204,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopReferrers]: { + [ExploreTables.Referrers]: { columns: [ { field: ExploreTableColumns.page, @@ -228,6 +239,46 @@ const tableSettings: { }, }, }, + [ExploreTables.Locations]: { + columns: [ + { + field: ExploreTableColumns.location, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.location', + { defaultMessage: 'Location' } + ), + render: (euiTheme: UseEuiTheme['euiTheme']) => (value: string, data: LocationsTable) => + ( + + +

{getFlag(data.countryISOCode)}

+
+ +

{value}

+
+
+ ), + truncateText: true, + }, + { + align: 'right', + field: ExploreTableColumns.sessions, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.session', + { defaultMessage: 'Session' } + ), + sortable: true, + truncateText: true, + }, + ], + sorting: { + readOnly: true, + sort: { + direction: 'desc', + field: ExploreTableColumns.sessions, + }, + }, + }, }; interface AnalyticsCollectionOverviewTableProps { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.ts new file mode 100644 index 00000000000000..d82eeb27cfbd65 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getFlag = (countryCode: string): string | null => + countryCode && countryCode.length === 2 + ? countryCode + .toUpperCase() + .replace(/./g, (c) => String.fromCharCode(55356, 56741 + c.charCodeAt(0))) + : null; From 5f5aba3981b7ff4a8d7733e9d3515193193308ee Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 26 Apr 2023 15:33:49 +0200 Subject: [PATCH 5/8] [Synthetics] Apply status up/down and disabled filter to overview alerts/errors (#155824) --- .../synthetics_overview_status.ts | 1 + .../overview/overview/overview_alerts.tsx | 41 +++++++++++++++---- .../overview_errors/overview_errors.tsx | 13 +++--- .../status_rule/status_rule_executor.ts | 3 +- .../server/queries/query_monitor_status.ts | 1 + .../routes/overview_status/overview_status.ts | 2 + .../get_all_monitors.test.ts | 3 ++ .../synthetics_monitor/get_all_monitors.ts | 3 ++ 8 files changed, 52 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts index ca9c85fb1a481c..f89688b36fee4d 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts @@ -59,6 +59,7 @@ export const OverviewStatusCodec = t.interface({ downConfigs: t.record(t.string, OverviewStatusMetaDataCodec), pendingConfigs: t.record(t.string, OverviewPendingStatusMetaDataCodec), enabledMonitorQueryIds: t.array(t.string), + disabledMonitorQueryIds: t.array(t.string), allIds: t.array(t.string), }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx index 693cd0d9ed85b6..7d8f6268f48fbe 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -24,6 +24,33 @@ import { AlertsLink } from '../../../common/links/view_alerts'; import { useRefreshedRange, useGetUrlParams } from '../../../../hooks'; import { ClientPluginsStart } from '../../../../../../plugin'; +export const useMonitorQueryIds = () => { + const { status } = useSelector(selectOverviewStatus); + + const { statusFilter } = useGetUrlParams(); + return useMemo(() => { + let monitorIds = status?.enabledMonitorQueryIds ?? []; + switch (statusFilter) { + case 'up': + monitorIds = status + ? Object.entries(status.upConfigs).map(([id, config]) => config.monitorQueryId) + : []; + break; + case 'down': + monitorIds = status + ? Object.entries(status.downConfigs).map(([id, config]) => config.monitorQueryId) + : []; + break; + case 'disabled': + monitorIds = status?.disabledMonitorQueryIds ?? []; + break; + default: + break; + } + return monitorIds.length > 0 ? monitorIds : ['false-id']; + }, [status, statusFilter]); +}; + export const OverviewAlerts = () => { const { from, to } = useRefreshedRange(12, 'hours'); @@ -39,6 +66,8 @@ export const OverviewAlerts = () => { const loading = !status?.allIds || status?.allIds.length === 0; + const monitorIds = useMonitorQueryIds(); + return ( @@ -66,10 +95,7 @@ export const OverviewAlerts = () => { selectedMetricField: RECORDS_FIELD, reportDefinitions: { 'kibana.alert.rule.category': ['Synthetics monitor status'], - 'monitor.id': - status?.enabledMonitorQueryIds.length > 0 - ? status?.enabledMonitorQueryIds - : ['false-id'], + 'monitor.id': monitorIds, ...(locations?.length ? { 'observer.geo.name': locations } : {}), }, filters: [{ field: 'kibana.alert.status', values: ['active', 'recovered'] }], @@ -93,10 +119,7 @@ export const OverviewAlerts = () => { }, reportDefinitions: { 'kibana.alert.rule.category': ['Synthetics monitor status'], - 'monitor.id': - status?.enabledMonitorQueryIds.length > 0 - ? status?.enabledMonitorQueryIds - : ['false-id'], + 'monitor.id': monitorIds, ...(locations?.length ? { 'observer.geo.name': locations } : {}), }, dataType: 'alerts', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx index 8f85b8bd90d43f..ea4b0f8282ebf4 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx @@ -16,6 +16,7 @@ import { import React from 'react'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; +import { useMonitorQueryIds } from '../overview_alerts'; import { selectOverviewStatus } from '../../../../../state/overview_status'; import { OverviewErrorsSparklines } from './overview_errors_sparklines'; import { useRefreshedRange, useGetUrlParams } from '../../../../../hooks'; @@ -28,7 +29,9 @@ export function OverviewErrors() { const { from, to } = useRefreshedRange(6, 'hours'); - const params = useGetUrlParams(); + const { locations } = useGetUrlParams(); + + const monitorIds = useMonitorQueryIds(); return ( @@ -44,16 +47,16 @@ export function OverviewErrors() { diff --git a/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts b/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts index 923177dc6b377c..3b73245977dff9 100644 --- a/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts +++ b/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts @@ -34,7 +34,8 @@ export interface StaleDownConfig extends OverviewStatusMetaData { isLocationRemoved?: boolean; } -export interface AlertOverviewStatus extends Omit { +export interface AlertOverviewStatus + extends Omit { staleDownConfigs: Record; } diff --git a/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts b/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts index a737e80e08069e..dff791a6b535aa 100644 --- a/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts +++ b/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts @@ -46,6 +46,7 @@ export async function queryMonitorStatus( | 'allMonitorsCount' | 'disabledMonitorsCount' | 'projectMonitorsCount' + | 'disabledMonitorQueryIds' | 'allIds' > > { diff --git a/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts b/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts index f8c55ba4fae399..35672937c0ffda 100644 --- a/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts +++ b/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts @@ -70,6 +70,7 @@ export async function getStatus(context: RouteContext, params: OverviewStatusQue const { enabledMonitorQueryIds, + disabledMonitorQueryIds, allIds, disabledCount, maxPeriod, @@ -112,6 +113,7 @@ export async function getStatus(context: RouteContext, params: OverviewStatusQue disabledMonitorsCount, projectMonitorsCount, enabledMonitorQueryIds, + disabledMonitorQueryIds, disabledCount, up, down, diff --git a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts index 2c72ac660a5887..8850f3b32c8dff 100644 --- a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts +++ b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts @@ -59,6 +59,7 @@ describe('processMonitors', () => { 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', '7f796001-a795-4c0b-afdb-3ce74edea775', ], + disabledMonitorQueryIds: ['test-project-id-default'], listOfLocations: ['US Central QA', 'US Central Staging', 'North America - US Central'], maxPeriod: 600000, monitorLocationMap: { @@ -94,6 +95,7 @@ describe('processMonitors', () => { 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', '7f796001-a795-4c0b-afdb-3ce74edea775', ], + disabledMonitorQueryIds: ['test-project-id-default'], listOfLocations: [ 'US Central Staging', 'us_central_qa', @@ -172,6 +174,7 @@ describe('processMonitors', () => { 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', '7f796001-a795-4c0b-afdb-3ce74edea775', ], + disabledMonitorQueryIds: ['test-project-id-default'], listOfLocations: ['US Central Staging', 'US Central QA', 'North America - US Central'], maxPeriod: 600000, monitorLocationMap: { diff --git a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts index 493c3a2889bdf1..7670a742fa98a6 100644 --- a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts +++ b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts @@ -73,6 +73,7 @@ export const processMonitors = async ( * latest ping for all enabled monitors. */ const enabledMonitorQueryIds: string[] = []; + const disabledMonitorQueryIds: string[] = []; let disabledCount = 0; let disabledMonitorsCount = 0; let maxPeriod = 0; @@ -116,6 +117,7 @@ export const processMonitors = async ( ); disabledCount += intersectingLocations.length; disabledMonitorsCount += 1; + disabledMonitorQueryIds.push(attrs[ConfigKey.MONITOR_QUERY_ID]); } else { const missingLabels = new Set(); @@ -152,6 +154,7 @@ export const processMonitors = async ( maxPeriod, allIds, enabledMonitorQueryIds, + disabledMonitorQueryIds, disabledCount, monitorLocationMap, disabledMonitorsCount, From fda5ee96b37f186378d94a7b6a15b295d9616168 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Wed, 26 Apr 2023 15:34:06 +0200 Subject: [PATCH 6/8] [Defend Workflows] Osquery fixes (#155020) --- .../public/live_queries/form/index.tsx | 20 ++++++------------- x-pack/plugins/osquery/server/common/error.ts | 15 ++++++++++++++ x-pack/plugins/osquery/server/common/types.ts | 4 ++++ .../handlers/action/create_action_handler.ts | 3 ++- .../osquery/server/lib/fleet_integration.ts | 13 ++++++++++-- .../live_query/create_live_query_route.ts | 5 +++-- 6 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/osquery/server/common/error.ts diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 7868c1bb3a4716..d56b981b128c84 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -82,16 +82,8 @@ const LiveQueryFormComponent: React.FC = ({ ); const hooksForm = useHookForm(); - const { - handleSubmit, - watch, - setValue, - resetField, - clearErrors, - getFieldState, - register, - formState: { isSubmitting }, - } = hooksForm; + const { handleSubmit, watch, setValue, resetField, clearErrors, getFieldState, register } = + hooksForm; const canRunSingleQuery = useMemo( () => @@ -157,7 +149,7 @@ const LiveQueryFormComponent: React.FC = ({ saved_query_id: values.savedQueryId, query, alert_ids: values.alertIds, - pack_id: values?.packId?.length ? values?.packId[0] : undefined, + pack_id: queryType === 'pack' && values?.packId?.length ? values?.packId[0] : undefined, ecs_mapping: values.ecs_mapping, }, (value) => !isEmpty(value) @@ -165,7 +157,7 @@ const LiveQueryFormComponent: React.FC = ({ await mutateAsync(serializedData); }, - [alertAttachmentContext, mutateAsync] + [alertAttachmentContext, mutateAsync, queryType] ); const serializedData: SavedQuerySOFormData = useMemo( @@ -196,7 +188,7 @@ const LiveQueryFormComponent: React.FC = ({ = ({ resultsStatus, handleShowSaveQueryFlyout, enabled, - isSubmitting, + isLoading, handleSubmit, onSubmit, ] diff --git a/x-pack/plugins/osquery/server/common/error.ts b/x-pack/plugins/osquery/server/common/error.ts new file mode 100644 index 00000000000000..b48fd925dad623 --- /dev/null +++ b/x-pack/plugins/osquery/server/common/error.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class CustomHttpRequestError extends Error { + constructor(message: string, public readonly statusCode: number = 500) { + super(message); + // For debugging - capture name of subclasses + this.name = this.constructor.name; + this.message = message; + } +} diff --git a/x-pack/plugins/osquery/server/common/types.ts b/x-pack/plugins/osquery/server/common/types.ts index 522f1fa250ada5..51dc4f59ed5b4b 100644 --- a/x-pack/plugins/osquery/server/common/types.ts +++ b/x-pack/plugins/osquery/server/common/types.ts @@ -56,3 +56,7 @@ export interface SavedQuerySavedObjectAttributes { } export type SavedQuerySavedObject = SavedObject; + +export interface HTTPError extends Error { + statusCode: number; +} diff --git a/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts b/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts index b2f6ca09234eb0..3c776723a2da2c 100644 --- a/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts +++ b/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts @@ -21,6 +21,7 @@ import { convertSOQueriesToPack } from '../../routes/pack/utils'; import { ACTIONS_INDEX } from '../../../common/constants'; import { TELEMETRY_EBT_LIVE_QUERY_EVENT } from '../../lib/telemetry/constants'; import type { PackSavedObjectAttributes } from '../../common/types'; +import { CustomHttpRequestError } from '../../common/error'; interface Metadata { currentUser: string | undefined; @@ -55,7 +56,7 @@ export const createActionHandler = async ( }); if (!selectedAgents.length) { - throw new Error('No agents found for selection'); + throw new CustomHttpRequestError('No agents found for selection', 400); } let packSO; diff --git a/x-pack/plugins/osquery/server/lib/fleet_integration.ts b/x-pack/plugins/osquery/server/lib/fleet_integration.ts index f03afedc8628ad..684334c1488b42 100644 --- a/x-pack/plugins/osquery/server/lib/fleet_integration.ts +++ b/x-pack/plugins/osquery/server/lib/fleet_integration.ts @@ -34,11 +34,20 @@ export const getPackagePolicyDeleteCallback = await Promise.all( map( foundPacks.saved_objects, - (pack: { id: string; references: SavedObjectReference[] }) => + (pack: { + id: string; + references: SavedObjectReference[]; + attributes: { shards: Array<{ key: string; value: string }> }; + }) => packsClient.update( packSavedObjectType, pack.id, - {}, + { + shards: filter( + pack.attributes.shards, + (shard) => shard.key !== deletedOsqueryManagerPolicy.policy_id + ), + }, { references: filter( pack.references, diff --git a/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts b/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts index 9d7ad88da88b61..05f857e3200661 100644 --- a/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts @@ -113,8 +113,9 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp body: { data: osqueryAction }, }); } catch (error) { - // TODO validate for 400 (when agents are not found for selection) - // return response.badRequest({ body: new Error('No agents found for selection') }); + if (error.statusCode === 400) { + return response.badRequest({ body: error }); + } return response.customError({ statusCode: 500, From a3f66bdacf3ea2e72a44cbf0ef177e461e218b87 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Wed, 26 Apr 2023 09:40:27 -0400 Subject: [PATCH 7/8] [Response Ops][Alerting] Adding functional tests for managing alerting rules when authenticated with an API key (#155787) Resolves https://github.com/elastic/kibana/issues/154584 --- .../group3/tests/alerting/index.ts | 1 + .../tests/alerting/user_managed_api_key.ts | 636 ++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts index 8f6cbe1a60c899..dcefc0a9b0239b 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts @@ -28,6 +28,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get_flapping_settings')); loadTestFile(require.resolve('./run_soon')); loadTestFile(require.resolve('./update_flapping_settings')); + loadTestFile(require.resolve('./user_managed_api_key')); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts new file mode 100644 index 00000000000000..7a92b9e11d8592 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts @@ -0,0 +1,636 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { generateAPIKeyName } from '@kbn/alerting-plugin/server/rules_client/common'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import { + checkAAD, + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, +} from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { SuperuserAtSpace1 } from '../../../scenarios'; + +// eslint-disable-next-line import/no-default-export +export default function userManagedApiKeyTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const superTestWithoutAuth = getService('supertestWithoutAuth'); + const objectRemover = new ObjectRemover(supertest); + const retry = getService('retry'); + + describe('user managed api key', () => { + let apiKey: string; + + before(async () => { + // Create API key + const { body: createdApiKey } = await supertest + .post(`/internal/security/api_key`) + .set('kbn-xsrf', 'foo') + .send({ name: 'test user managed key' }) + .expect(200); + + apiKey = createdApiKey.encoded; + }); + + after(() => objectRemover.removeAll()); + + it('should successfully create rule using API key authorization', async () => { + const testRuleData = getTestRuleData({}); + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(testRuleData); + + expect(response.status).to.eql(200); + const ruleId = response.body.id; + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + + expect(response.body.api_key_created_by_user).to.eql(true); + expect(apiKeyExists(testRuleData.rule_type_id, testRuleData.name)).to.eql(false); + + // Make sure rule runs successfully + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([ + // make sure the counts of the # of events per type are as expected + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events.find( + (event: IValidatedEvent) => event?.event?.action === 'execute' + ); + expect(executeEvent?.event?.outcome).to.eql('success'); + }); + + describe('rule operations', () => { + it('should successfully update rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_update1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const updatedData = { + name: 'updated_rule_user_managed', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + notify_when: 'onThrottleInterval', + }; + + const response = await superTestWithoutAuth + .put(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(updatedData); + + expect(response.status).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: ruleId, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + created_by: 'elastic', + enabled: true, + updated_by: 'elastic', + api_key_owner: 'elastic', + api_key_created_by_user: true, + mute_all: false, + muted_alert_ids: [], + actions: [], + scheduled_task_id: ruleId, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + execution_status: response.body.execution_status, + revision: 1, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', updatedData.name)).to.eql(false); + }); + + it('should successfully update rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_update2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const updatedData = { + name: 'update_rule_regenerated', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + notify_when: 'onThrottleInterval', + }; + + const response = await supertest + .put(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .send(updatedData); + + expect(response.status).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: ruleId, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + created_by: 'elastic', + enabled: true, + updated_by: 'elastic', + api_key_owner: 'elastic', + api_key_created_by_user: false, + mute_all: false, + muted_alert_ids: [], + actions: [], + scheduled_task_id: ruleId, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + execution_status: response.body.execution_status, + revision: 1, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', updatedData.name)).to.eql(true); + }); + + it('should successfully clone rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_clone1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .post( + `${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rule/${ruleId}/_clone` + ) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(); + expect(response.status).to.eql(200); + objectRemover.add(SuperuserAtSpace1.space.id, response.body.id, 'rule', 'alerting'); + + expect(response.body).to.eql({ + id: response.body.id, + name: 'test_clone1 [Clone]', + tags: ['foo'], + actions: [], + enabled: true, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + params: {}, + created_by: 'elastic', + schedule: { interval: '1m' }, + scheduled_task_id: response.body.scheduled_task_id, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + throttle: '1m', + notify_when: 'onThrottleInterval', + updated_by: 'elastic', + api_key_created_by_user: true, + api_key_owner: 'elastic', + mute_all: false, + muted_alert_ids: [], + execution_status: response.body.execution_status, + revision: 0, + last_run: { + alerts_count: { + active: 0, + ignored: 0, + new: 0, + recovered: 0, + }, + outcome: 'succeeded', + outcome_msg: null, + outcome_order: 0, + warning: null, + }, + next_run: response.body.next_run, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: response.body.id, + }); + + // Ensure no API key was generated + expect(apiKeyExists(response.body.rule_type_id, response.body.name)).to.eql(false); + }); + + it('should successfully clone rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_clone2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .post( + `${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rule/${ruleId}/_clone` + ) + .set('kbn-xsrf', 'foo') + .send(); + expect(response.status).to.eql(200); + objectRemover.add(SuperuserAtSpace1.space.id, response.body.id, 'rule', 'alerting'); + + expect(response.body).to.eql({ + id: response.body.id, + name: 'test_clone2 [Clone]', + tags: ['foo'], + actions: [], + enabled: true, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + params: {}, + created_by: 'elastic', + schedule: { interval: '1m' }, + scheduled_task_id: response.body.scheduled_task_id, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + throttle: '1m', + notify_when: 'onThrottleInterval', + updated_by: 'elastic', + api_key_created_by_user: false, + api_key_owner: 'elastic', + mute_all: false, + muted_alert_ids: [], + execution_status: response.body.execution_status, + revision: 0, + last_run: { + alerts_count: { + active: 0, + ignored: 0, + new: 0, + recovered: 0, + }, + outcome: 'succeeded', + outcome_msg: null, + outcome_order: 0, + warning: null, + }, + next_run: response.body.next_run, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: response.body.id, + }); + + // Ensure an API key was generated + expect(apiKeyExists(response.body.rule_type_id, response.body.name)).to.eql(true); + }); + + it('should successfully bulk edit rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_edit1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const payload = { + ids: [ruleId], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['another-tag'], + }, + ], + }; + + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(payload); + + expect(response.status).to.eql(200); + expect(response.body.rules[0].tags).to.eql(['foo', 'another-tag']); + expect(response.body.rules[0].api_key_created_by_user).to.eql(true); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_edit1')).to.eql(false); + }); + + it('should successfully bulk edit rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_edit2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const payload = { + ids: [ruleId], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['another-tag'], + }, + ], + }; + + const response = await supertest + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload); + + expect(response.status).to.eql(200); + expect(response.body.rules[0].tags).to.eql(['foo', 'another-tag']); + expect(response.body.rules[0].api_key_created_by_user).to.eql(false); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_edit2')).to.eql(true); + }); + + it('should successfully update api key for rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_update_api_key1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .post( + `${getUrlPrefix( + SuperuserAtSpace1.space.id + )}/internal/alerting/rule/${ruleId}/_update_api_key` + ) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_update_api_key1')).to.eql(false); + }); + + it('should successfully update api key for rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_update_api_key2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .post( + `${getUrlPrefix( + SuperuserAtSpace1.space.id + )}/internal/alerting/rule/${ruleId}/_update_api_key` + ) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_update_api_key2')).to.eql(true); + }); + + it('should successfully enable rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_enable1', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}/_enable`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_enable1')).to.eql(false); + }); + + it('should successfully enable rule and generate API key', async () => { + const ruleId = await createRule(apiKey, 'test_enable2', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}/_enable`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_enable2')).to.eql(true); + }); + + it('should successfully bulk enable rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_enable1', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_enable`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send({ ids: [ruleId] }); + expect(response.status).to.eql(200); + expect(response.body.rules[0].enabled).to.eql(true); + expect(response.body.rules[0].apiKeyCreatedByUser).to.eql(true); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_enable1')).to.eql(false); + }); + + it('should successfully bulk enable rule and generate API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_enable2', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_enable`) + .set('kbn-xsrf', 'foo') + .send({ ids: [ruleId] }); + expect(response.status).to.eql(200); + expect(response.body.rules[0].enabled).to.eql(true); + expect(response.body.rules[0].apiKeyCreatedByUser).to.eql(false); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_enable2')).to.eql(true); + }); + + it('should successfully delete rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_delete1'); + const response = await superTestWithoutAuth + .delete(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`); + expect(response.statusCode).to.eql(204); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + + it('should successfully delete rule', async () => { + const ruleId = await createRule(apiKey, 'test_delete2'); + const response = await supertest + .delete(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + + it('should successfully bulk delete rule with user managed api key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_delete1'); + const response = await superTestWithoutAuth + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_delete`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send({ ids: [ruleId] }); + expect(response.statusCode).to.eql(200); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + + it('should successfully bulk delete rule', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_delete'); + const response = await supertest + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_delete`) + .set('kbn-xsrf', 'foo') + .send({ ids: [ruleId] }); + expect(response.status).to.eql(200); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + }); + }); + + async function apiKeyExists(ruleTypeId: string, ruleName: string) { + // Typically an API key is created using the rule type id and the name so check + // that this does not exist + const generatedApiKeyName = generateAPIKeyName(ruleTypeId, ruleName); + + const { body: allApiKeys } = await supertest + .get(`/internal/security/api_key?isAdmin=true`) + .set('kbn-xsrf', 'foo') + .expect(200); + + return !!allApiKeys.apiKeys.find((key: { name: string }) => key.name === generatedApiKeyName); + } + + async function createRule(apiKey: string, ruleName: string, enabled: boolean = true) { + const testRuleData = getTestRuleData({}); + // Create rule and make sure it runs once successfully + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send({ ...testRuleData, name: ruleName, enabled }); + + expect(response.status).to.eql(200); + const ruleId = response.body.id; + + if (enabled) { + // Make sure rule runs successfully + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([ + // make sure the counts of the # of events per type are as expected + ['execute', { gte: 1 }], + ]), + }); + }); + const executeEvent = events.find( + (event: IValidatedEvent) => event?.event?.action === 'execute' + ); + expect(executeEvent?.event?.outcome).to.eql('success'); + } + + return ruleId; + } +} From c0033a1b4b2223e6331c1797f34d75a1c964a54b Mon Sep 17 00:00:00 2001 From: Sean Story Date: Wed, 26 Apr 2023 08:44:20 -0500 Subject: [PATCH 8/8] Reset state of pipeline flyout on close or completion (#155760) ## Summary Fixes a bug where adding a second pipeline would pop you into the last page of the previously added pipeline flyout. ![can't create new pipeline](https://user-images.githubusercontent.com/5288246/234373486-fb2c6a6a-b2d4-4ed6-96ed-27a938675664.gif) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../ml_inference/ml_inference_logic.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts index 516d65df089b58..2cc9a7eabea356 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts @@ -66,6 +66,7 @@ import { sortModels, sortSourceFields, } from '../../../shared/ml_inference/utils'; +import { PipelinesLogic } from '../pipelines_logic'; import { AddInferencePipelineFormErrors, @@ -227,6 +228,8 @@ export const MLInferenceLogic = kea< 'apiSuccess as attachApiSuccess', 'makeRequest as makeAttachPipelineRequest', ], + PipelinesLogic, + ['closeAddMlInferencePipelineModal as closeAddMlInferencePipelineModal'], ], values: [ CachedFetchIndexApiLogic, @@ -348,6 +351,20 @@ export const MLInferenceLogic = kea< selectedSourceFields: [], }; }, + closeAddMlInferencePipelineModal: () => ({ + configuration: { + ...EMPTY_PIPELINE_CONFIGURATION, + }, + indexName: '', + step: AddInferencePipelineSteps.Configuration, + }), + createApiSuccess: () => ({ + configuration: { + ...EMPTY_PIPELINE_CONFIGURATION, + }, + indexName: '', + step: AddInferencePipelineSteps.Configuration, + }), removeFieldFromMapping: (modal, { fieldName }) => { const { configuration: { fieldMappings },