diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 355c5925702f7..e9dc741f39652 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 c229170b8007b..3db684628334e 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 519da4a60dec1..effdbeda041da 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 0000000000000..76b6daf60a324 --- /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 0000000000000..5550404529cf1 --- /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 0000000000000..c504b54a4f914 --- /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 0000000000000..8a828e00c917f --- /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 0000000000000..62a0b7a633975 --- /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 0000000000000..5bf327789a65c --- /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 88771d1a76fcb..8c7e6ffff192f 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 6cc0ccaa93a6d..ae809a6c7c615 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 e921c37dd21f8..98f6675f42a66 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 3b6ea0333f236..4d29974ceb75f 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 5a97f4a7d9a52..7e5a349cb1e01 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 2c114bb75d6e5..eb8777665895a 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 8a09aedef1b75..be7d1c3734157 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 8f3d52db7a6dd..90bb47d8a2d76 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 83ad61fc46c52..e86e44fc0ac05 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 abdfc2f8749e4..a9de3a426f14f 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 1a5f5adec136d..0cb9f815ef089 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 d2b583437d177..0f225e0546fd1 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 0000000000000..d14d8298d0d0f --- /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 0000000000000..948dd757e7e01 --- /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 0000000000000..a3b6f8b05f099 --- /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 0000000000000..fbae7790b04eb --- /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 0000000000000..b8e71a3c662d6 --- /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 0000000000000..e4a6165374422 --- /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 0000000000000..ebd8caef8e030 --- /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 1168b2003994e..15fe0eb970cc2 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 55a8df1024a6e..c32da344354b6 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 0000000000000..3cd3efd6c0f67 --- /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 0000000000000..82a8cba3f6427 --- /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 0000000000000..c903e9af360f8 --- /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 0000000000000..1ba34456d88a8 --- /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 0000000000000..3739930944571 --- /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 0000000000000..38c7ab4e1f925 --- /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 0000000000000..0e64aaa83d27e --- /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 c415103d2256d..49dbca9b276b2 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