From 588d85ce73c86e90f9160eebad9ea67e0302386e Mon Sep 17 00:00:00 2001 From: Mykola Harmash Date: Thu, 22 Feb 2024 11:56:33 +0100 Subject: [PATCH] [Infra] Add endpoints to manage Custom Dashboards (#176612) Closes https://github.com/elastic/kibana/issues/176069 ## Summary This adds the logic to register a new Saved Object type to store custom dashboards for Asset Details and adds endpoints to fetch and save custom dashboards. Changes highlights: * Renamed the `enableInfrastructureHostsCustomDashboards` to `enableInfrastructureAssetCustomDashboards` to make it more generic and support additional asset types in the future * Added a new Saved Object type * Moved initialization of all Infra endpoints to plugin's `start`. This one one of the points on [the BE tech debt ticket](https://github.com/elastic/kibana/issues/175975). Having endpoint initialization in `start` makes it more convenient to access start dependencies which almost all endpoints require. * Added `savedObjectClient` and `uiSettingsClient` to the custom request context (also one of the ideas for endpoints improvement). Right now infra endpoints use custom `libs` object with all dependencies required for routes, the idea is to rely on the request context instead because it automatically available for every route handler and by default includes some useful things like scoped service clients. * Added a wrapper `handleRouteErrors` to avoid error handling duplication which we now have in a few routes. In the future we could do something similar right within `registerRoutes` framework function, but this would require a bit of refactoring. ## Hot to Test 1. Toggle the UI setting off in Advanced Settings ![CleanShot 2024-02-13 at 16 01 36@2x](https://github.com/elastic/kibana/assets/793851/fc3772a1-a075-42bd-bdc3-2c7e83278844) 2. Go to the Dev Tools and try the endpoints, both should respond with 403 ``` GET kbn:api/infra/custom-dashboards/host POST kbn:api/infra/custom-dashboards { "assetType": "host", "dashboardIdList": ["0", "1"] } ``` 3. Toggle the UI setting on 4. Try the endpoints again, now they should work as expected --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/advanced-options.asciidoc | 4 +- .../current_fields.json | 5 + .../current_mappings.json | 13 ++ .../settings/setting_ids/index.ts | 4 +- .../settings/observability_project/index.ts | 2 +- .../check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 1 + .../server/collectors/management/schema.ts | 2 +- .../server/collectors/management/types.ts | 2 +- src/plugins/telemetry/schema/oss_plugins.json | 2 +- .../plugins/infra/common/custom_dashboards.ts | 16 ++ .../common/http_api/custom_dashboards_api.ts | 47 +++++ x-pack/plugins/infra/server/infra_server.ts | 10 +- .../framework/kibana_framework_adapter.ts | 23 ++- x-pack/plugins/infra/server/plugin.ts | 25 ++- .../custom_dashboards/custom_dashboards.ts | 15 ++ .../custom_dashboards/get_custom_dashboard.ts | 61 +++++++ .../lib/check_custom_dashboards_enabled.ts | 23 +++ .../lib/find_custom_dashboard.ts | 24 +++ .../save_custom_dashboard.ts | 68 +++++++ .../custom_dashboards_saved_object.ts | 49 +++++ .../infra/server/saved_objects/index.ts | 1 + x-pack/plugins/infra/server/types.ts | 17 +- .../infra/server/utils/handle_route_errors.ts | 34 ++++ x-pack/plugins/infra/tsconfig.json | 1 + x-pack/plugins/observability/common/index.ts | 2 +- .../observability/common/ui_settings_keys.ts | 4 +- .../observability/server/ui_settings.ts | 12 +- .../api_integration/apis/metrics_ui/index.js | 1 + .../metrics_ui/infra_custom_dashboards.ts | 167 ++++++++++++++++++ 31 files changed, 596 insertions(+), 41 deletions(-) create mode 100644 x-pack/plugins/infra/common/custom_dashboards.ts create mode 100644 x-pack/plugins/infra/common/http_api/custom_dashboards_api.ts create mode 100644 x-pack/plugins/infra/server/routes/custom_dashboards/custom_dashboards.ts create mode 100644 x-pack/plugins/infra/server/routes/custom_dashboards/get_custom_dashboard.ts create mode 100644 x-pack/plugins/infra/server/routes/custom_dashboards/lib/check_custom_dashboards_enabled.ts create mode 100644 x-pack/plugins/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts create mode 100644 x-pack/plugins/infra/server/routes/custom_dashboards/save_custom_dashboard.ts create mode 100644 x-pack/plugins/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts create mode 100644 x-pack/plugins/infra/server/utils/handle_route_errors.ts create mode 100644 x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 0550247a2918f..4ab341571668e 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -465,8 +465,8 @@ preview:[] Enable the APM Trace Explorer feature, that allows you to search and [[observability-infrastructure-profiling-integration]]`observability:enableInfrastructureProfilingIntegration`:: preview:[] Enables the Profiling view in Host details within Infrastructure. -[[observability-infrastructure-hosts-custom-dashboard]]`observability:enableInfrastructureHostsCustomDashboards`:: -preview:[] Enables option to link custom dashboards in the Host Details view. +[[observability-infrastructure-asset-custom-dashboard]]`observability:enableInfrastructureAssetCustomDashboards`:: +preview:[] Enables option to link custom dashboards in the Asset Details view. [[observability-profiling-per-vcpu-watt-x86]]`observability:profilingPervCPUWattX86`:: The average amortized per-core power consumption (based on 100% CPU utilization) for x86 architecture. diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index e43f7cab87278..fa70473b0b0a8 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -459,6 +459,11 @@ "title", "type" ], + "infra-custom-dashboards": [ + "assetType", + "dashboardIdList", + "kuery" + ], "infrastructure-monitoring-log-view": [ "name" ], diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 24012cccb9fcc..5bfe7f6f20de9 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1553,6 +1553,19 @@ } } }, + "infra-custom-dashboards": { + "properties": { + "assetType": { + "type": "keyword" + }, + "dashboardIdList": { + "type": "keyword" + }, + "kuery": { + "type": "text" + } + } + }, "infrastructure-monitoring-log-view": { "dynamic": false, "properties": { diff --git a/packages/kbn-management/settings/setting_ids/index.ts b/packages/kbn-management/settings/setting_ids/index.ts index 9a41ac77c82b4..57c19d5cf6cc3 100644 --- a/packages/kbn-management/settings/setting_ids/index.ts +++ b/packages/kbn-management/settings/setting_ids/index.ts @@ -123,8 +123,8 @@ export const OBSERVABILITY_ENABLE_COMPARISON_BY_DEFAULT_ID = 'observability:enableComparisonByDefault'; export const OBSERVABILITY_ENABLE_INFRASTRUCTURE_HOSTS_VIEW_ID = 'observability:enableInfrastructureHostsView'; -export const OBSERVABILITY_ENABLE_INFRASTRUCTURE_HOSTS_CUSTOM_DASHBOARDS_ID = - 'observability:enableInfrastructureHostsCustomDashboards'; +export const OBSERVABILITY_ENABLE_INFRASTRUCTURE_ASSET_CUSTOM_DASHBOARDS_ID = + 'observability:enableInfrastructureAssetCustomDashboards'; export const OBSERVABILITY_ENABLE_INSPECT_ES_QUERIES_ID = 'observability:enableInspectEsQueries'; export const OBSERVABILITY_MAX_SUGGESTIONS_ID = 'observability:maxSuggestions'; export const OBSERVABILITY_PROFILING_ELASTICSEARCH_PLUGIN_ID = diff --git a/packages/serverless/settings/observability_project/index.ts b/packages/serverless/settings/observability_project/index.ts index c95c044614e51..cab35bca0f142 100644 --- a/packages/serverless/settings/observability_project/index.ts +++ b/packages/serverless/settings/observability_project/index.ts @@ -28,5 +28,5 @@ export const OBSERVABILITY_PROJECT_SETTINGS = [ settings.OBSERVABILITY_ENABLE_AWS_LAMBDA_METRICS_ID, settings.OBSERVABILITY_APM_ENABLE_CRITICAL_PATH_ID, settings.OBSERVABILITY_ENABLE_INFRASTRUCTURE_HOSTS_VIEW_ID, - settings.OBSERVABILITY_ENABLE_INFRASTRUCTURE_HOSTS_CUSTOM_DASHBOARDS_ID, + settings.OBSERVABILITY_ENABLE_INFRASTRUCTURE_ASSET_CUSTOM_DASHBOARDS_ID, ]; diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 3dcfe8302ea28..4214797859380 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -103,6 +103,7 @@ describe('checking migration metadata changes on all registered SO types', () => "guided-onboarding-guide-state": "d338972ed887ac480c09a1a7fbf582d6a3827c91", "guided-onboarding-plugin-state": "bc109e5ef46ca594fdc179eda15f3095ca0a37a4", "index-pattern": "997108a9ea1e8076e22231e1c95517cdb192b9c5", + "infra-custom-dashboards": "b92b6db1c1f8998af6e2951a17b76cf886c6bee5", "infrastructure-monitoring-log-view": "5f86709d3c27aed7a8379153b08ee5d3d90d77f5", "infrastructure-ui-source": "113182d6895764378dfe7fa9fa027244f3a457c4", "ingest-agent-policies": "7633e578f60c074f8267bc50ec4763845e431437", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index 99e2692523f6d..7e41abff9b024 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -73,6 +73,7 @@ const previouslyRegisteredTypes = [ 'index-pattern', 'infrastructure-monitoring-log-view', 'infrastructure-ui-source', + 'infra-custom-dashboards', 'ingest-agent-policies', 'ingest-download-sources', 'ingest-outputs', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 9f9c3a3c7bd58..25c548d4e0d6b 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -223,6 +223,7 @@ describe('split .kibana index into multiple system indices', () => { "guided-onboarding-guide-state", "guided-onboarding-plugin-state", "index-pattern", + "infra-custom-dashboards", "infrastructure-monitoring-log-view", "infrastructure-ui-source", "ingest-agent-policies", diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 59cee0f8a689c..361b5f5d4b3ee 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -608,7 +608,7 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, - 'observability:enableInfrastructureHostsCustomDashboards': { + 'observability:enableInfrastructureAssetCustomDashboards': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 16a8dd493ab47..4583aaf922e7d 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -47,7 +47,7 @@ export interface UsageStats { 'observability:apmAWSLambdaRequestCostPerMillion': number; 'observability:enableInfrastructureHostsView': boolean; 'observability:enableInfrastructureProfilingIntegration': boolean; - 'observability:enableInfrastructureHostsCustomDashboards': boolean; + 'observability:enableInfrastructureAssetCustomDashboards': boolean; 'observability:apmAgentExplorerView': boolean; 'observability:apmEnableTableSearchBar': boolean; 'visualization:heatmap:maxBuckets': number; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 6cad0714a4119..16b7e362480f5 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -10228,7 +10228,7 @@ "description": "Non-default value of setting." } }, - "observability:enableInfrastructureHostsCustomDashboards": { + "observability:enableInfrastructureAssetCustomDashboards": { "type": "boolean", "_meta": { "description": "Non-default value of setting." diff --git a/x-pack/plugins/infra/common/custom_dashboards.ts b/x-pack/plugins/infra/common/custom_dashboards.ts new file mode 100644 index 0000000000000..ef32f2fb9cf60 --- /dev/null +++ b/x-pack/plugins/infra/common/custom_dashboards.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 { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; + +export type InfraCustomDashboardAssetType = InventoryItemType; + +export interface InfraCustomDashboard { + dashboardIdList: string[]; + assetType: InfraCustomDashboardAssetType; + kuery?: string; +} diff --git a/x-pack/plugins/infra/common/http_api/custom_dashboards_api.ts b/x-pack/plugins/infra/common/http_api/custom_dashboards_api.ts new file mode 100644 index 0000000000000..9b1bafaa19da1 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/custom_dashboards_api.ts @@ -0,0 +1,47 @@ +/* + * 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 { ItemTypeRT } from '@kbn/metrics-data-access-plugin/common'; +import * as rt from 'io-ts'; + +const AssetTypeRT = rt.type({ + assetType: ItemTypeRT, +}); + +const CustomDashboardRT = rt.intersection([ + AssetTypeRT, + rt.type({ + dashboardIdList: rt.array(rt.string), + }), + rt.partial({ + kuery: rt.string, + }), +]); + +/** + GET endpoint +*/ +export const InfraGetCustomDashboardsRequestParamsRT = AssetTypeRT; +export const InfraGetCustomDashboardsResponseBodyRT = CustomDashboardRT; +export type InfraGetCustomDashboardsRequestParams = rt.TypeOf< + typeof InfraGetCustomDashboardsRequestParamsRT +>; +export type InfraGetCustomDashboardsResponseBody = rt.TypeOf< + typeof InfraGetCustomDashboardsResponseBodyRT +>; + +/** + * POST endpoint + */ +export const InfraSaveCustomDashboardsRequestPayloadRT = CustomDashboardRT; +export const InfraSaveCustomDashboardsResponseBodyRT = CustomDashboardRT; +export type InfraSaveCustomDashboardsRequestPayload = rt.TypeOf< + typeof InfraSaveCustomDashboardsRequestPayloadRT +>; +export type InfraSaveCustomDashboardsResponseBody = rt.TypeOf< + typeof InfraSaveCustomDashboardsResponseBodyRT +>; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 5bba62e104b5c..5576253588486 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CoreStart } from '@kbn/core-lifecycle-server'; import { InfraBackendLibs } from './lib/infra_types'; import { initGetHostsAnomaliesRoute, initGetK8sAnomaliesRoute } from './routes/infra_ml'; import { initInventoryMetaRoute } from './routes/inventory_metadata'; @@ -34,8 +35,14 @@ import { initInfraMetricsRoute } from './routes/infra'; import { initMetricsExplorerViewRoutes } from './routes/metrics_explorer_views'; import { initProfilingRoutes } from './routes/profiling'; import { initServicesRoute } from './routes/services'; +import { initCustomDashboardsRoutes } from './routes/custom_dashboards/custom_dashboards'; +import { type InfraServerPluginStartDeps } from './lib/adapters/framework'; -export const initInfraServer = (libs: InfraBackendLibs) => { +export const initInfraServer = ( + libs: InfraBackendLibs, + coreStart: CoreStart, + infraPluginsStart: InfraServerPluginStartDeps +) => { initIpToHostName(libs); initGetLogEntryCategoriesRoute(libs); initGetLogEntryCategoryDatasetsRoute(libs); @@ -63,4 +70,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initInfraMetricsRoute(libs); initProfilingRoutes(libs); initServicesRoute(libs); + initCustomDashboardsRoutes(libs.framework); }; diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index 9a593af54a010..c16dad5445af4 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -7,7 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TransportRequestParams } from '@elastic/elasticsearch'; -import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; +import { ElasticsearchClient, RouteConfig, SavedObjectsClientContract } from '@kbn/core/server'; import { CoreSetup, IRouter, KibanaRequest, RequestHandler, RouteMethod } from '@kbn/core/server'; import { UI_SETTINGS } from '@kbn/data-plugin/server'; import { TimeseriesVisData } from '@kbn/vis-type-timeseries-plugin/server'; @@ -59,25 +59,30 @@ export class KibanaFramework { const routeConfig = { path: config.path, validate: config.validate, - // Currently we have no use of custom options beyond tags, this can be extended - // beyond defaultOptions if it's needed. - options: defaultOptions, + /** + * Supported `options` for each type of request method + * are a bit different and generic method like this cannot + * properly ensure type safety. Hence the need to cast + * using `as ...` below to ensure the route config has + * the correct options type. + */ + options: { ...config.options, ...defaultOptions }, }; switch (config.method) { case 'get': - this.router.get(routeConfig, handler); + this.router.get(routeConfig as RouteConfig, handler); break; case 'post': - this.router.post(routeConfig, handler); + this.router.post(routeConfig as RouteConfig, handler); break; case 'delete': - this.router.delete(routeConfig, handler); + this.router.delete(routeConfig as RouteConfig, handler); break; case 'put': - this.router.put(routeConfig, handler); + this.router.put(routeConfig as RouteConfig, handler); break; case 'patch': - this.router.patch(routeConfig, handler); + this.router.patch(routeConfig as RouteConfig, handler); break; } } diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 78501be3f0f26..a9401a064d931 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -38,7 +38,11 @@ import { InfraMetricsDomain } from './lib/domains/metrics_domain'; import { InfraBackendLibs, InfraDomainLibs } from './lib/infra_types'; import { infraSourceConfigurationSavedObjectType, InfraSources } from './lib/sources'; import { InfraSourceStatus } from './lib/source_status'; -import { inventoryViewSavedObjectType, metricsExplorerViewSavedObjectType } from './saved_objects'; +import { + infraCustomDashboardsSavedObjectType, + inventoryViewSavedObjectType, + metricsExplorerViewSavedObjectType, +} from './saved_objects'; import { InventoryViewsService } from './services/inventory_views'; import { MetricsExplorerViewsService } from './services/metrics_explorer_views'; import { RulesService } from './services/rules'; @@ -199,6 +203,7 @@ export class InfraServerPlugin // Register saved object types core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); core.savedObjects.registerType(inventoryViewSavedObjectType); + core.savedObjects.registerType(infraCustomDashboardsSavedObjectType); if (this.config.featureFlags.metricsExplorerEnabled) { core.savedObjects.registerType(metricsExplorerViewSavedObjectType); } @@ -259,21 +264,27 @@ export class InfraServerPlugin ]); } - initInfraServer(this.libs); registerRuleTypes(plugins.alerting, this.libs, this.config); core.http.registerRouteHandlerContext( 'infra', async (context, request) => { - const soClient = (await context.core).savedObjects.client; - const mlSystem = plugins.ml?.mlSystemProvider(request, soClient); - const mlAnomalyDetectors = plugins.ml?.anomalyDetectorsProvider(request, soClient); + const coreContext = await context.core; + const savedObjectsClient = coreContext.savedObjects.client; + const uiSettingsClient = coreContext.uiSettings.client; + const mlSystem = plugins.ml?.mlSystemProvider(request, savedObjectsClient); + const mlAnomalyDetectors = plugins.ml?.anomalyDetectorsProvider( + request, + savedObjectsClient + ); const spaceId = plugins.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID; return { mlAnomalyDetectors, mlSystem, spaceId, + savedObjectsClient, + uiSettingsClient, }; } ); @@ -287,7 +298,7 @@ export class InfraServerPlugin } as InfraPluginSetup; } - start(core: CoreStart) { + start(core: CoreStart, pluginsStart: InfraServerPluginStartDeps) { const inventoryViews = this.inventoryViews.start({ infraSources: this.libs.sources, savedObjects: core.savedObjects, @@ -298,6 +309,8 @@ export class InfraServerPlugin savedObjects: core.savedObjects, }); + initInfraServer(this.libs, core, pluginsStart); + return { inventoryViews, metricsExplorerViews, diff --git a/x-pack/plugins/infra/server/routes/custom_dashboards/custom_dashboards.ts b/x-pack/plugins/infra/server/routes/custom_dashboards/custom_dashboards.ts new file mode 100644 index 0000000000000..6ed41cb83db71 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/custom_dashboards/custom_dashboards.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. + */ + +import type { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; +import { initGetCustomDashboardRoute } from './get_custom_dashboard'; +import { initSaveCustomDashboardRoute } from './save_custom_dashboard'; + +export function initCustomDashboardsRoutes(framework: KibanaFramework) { + initGetCustomDashboardRoute(framework); + initSaveCustomDashboardRoute(framework); +} diff --git a/x-pack/plugins/infra/server/routes/custom_dashboards/get_custom_dashboard.ts b/x-pack/plugins/infra/server/routes/custom_dashboards/get_custom_dashboard.ts new file mode 100644 index 0000000000000..5b5136b104f9d --- /dev/null +++ b/x-pack/plugins/infra/server/routes/custom_dashboards/get_custom_dashboard.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createRouteValidationFunction } from '@kbn/io-ts-utils'; +import { + InfraGetCustomDashboardsRequestParamsRT, + InfraGetCustomDashboardsResponseBodyRT, +} from '../../../common/http_api/custom_dashboards_api'; +import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; +import { handleRouteErrors } from '../../utils/handle_route_errors'; +import { checkCustomDashboardsEnabled } from './lib/check_custom_dashboards_enabled'; +import { findCustomDashboard } from './lib/find_custom_dashboard'; + +export function initGetCustomDashboardRoute(framework: KibanaFramework) { + const validateParams = createRouteValidationFunction(InfraGetCustomDashboardsRequestParamsRT); + + framework.registerRoute( + { + method: 'get', + path: '/api/infra/custom-dashboards/{assetType}', + validate: { + params: validateParams, + }, + options: { + access: 'internal', + }, + }, + handleRouteErrors(async (context, request, response) => { + const { savedObjectsClient, uiSettingsClient } = await context.infra; + + await checkCustomDashboardsEnabled(uiSettingsClient); + + const params = request.params; + const customDashboards = await findCustomDashboard(params.assetType, savedObjectsClient); + + if (customDashboards.total === 0) { + return response.ok({ + body: InfraGetCustomDashboardsResponseBodyRT.encode({ + assetType: params.assetType, + dashboardIdList: [], + kuery: undefined, + }), + }); + } + + const attributes = customDashboards.saved_objects[0].attributes; + + return response.ok({ + body: InfraGetCustomDashboardsResponseBodyRT.encode({ + assetType: attributes.assetType, + dashboardIdList: attributes.dashboardIdList, + kuery: attributes.kuery, + }), + }); + }) + ); +} diff --git a/x-pack/plugins/infra/server/routes/custom_dashboards/lib/check_custom_dashboards_enabled.ts b/x-pack/plugins/infra/server/routes/custom_dashboards/lib/check_custom_dashboards_enabled.ts new file mode 100644 index 0000000000000..086e920726449 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/custom_dashboards/lib/check_custom_dashboards_enabled.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 Boom from '@hapi/boom'; +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from '@kbn/core/server'; +import { enableInfrastructureAssetCustomDashboards } from '@kbn/observability-plugin/common'; + +export async function checkCustomDashboardsEnabled(uiSettingsClient: IUiSettingsClient) { + const isEnabled = await uiSettingsClient.get(enableInfrastructureAssetCustomDashboards); + + if (!isEnabled) { + throw Boom.forbidden( + i18n.translate('xpack.infra.routes.customDashboards', { + defaultMessage: 'Custom dashboards are not enabled', + }) + ); + } +} diff --git a/x-pack/plugins/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts b/x-pack/plugins/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts new file mode 100644 index 0000000000000..5f5956d5c8fc6 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/custom_dashboards/lib/find_custom_dashboard.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../../saved_objects'; +import type { + InfraCustomDashboard, + InfraCustomDashboardAssetType, +} from '../../../../common/custom_dashboards'; + +export function findCustomDashboard( + assetType: InfraCustomDashboardAssetType, + savedObjectsClient: SavedObjectsClientContract +) { + return savedObjectsClient.find({ + type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + search: assetType, + searchFields: ['assetType'] as [keyof InfraCustomDashboard], + }); +} diff --git a/x-pack/plugins/infra/server/routes/custom_dashboards/save_custom_dashboard.ts b/x-pack/plugins/infra/server/routes/custom_dashboards/save_custom_dashboard.ts new file mode 100644 index 0000000000000..dc811b99b47de --- /dev/null +++ b/x-pack/plugins/infra/server/routes/custom_dashboards/save_custom_dashboard.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 { createRouteValidationFunction } from '@kbn/io-ts-utils'; +import { InfraCustomDashboard } from '../../../common/custom_dashboards'; +import { + InfraSaveCustomDashboardsRequestPayloadRT, + InfraSaveCustomDashboardsRequestPayload, + InfraSaveCustomDashboardsResponseBodyRT, +} from '../../../common/http_api/custom_dashboards_api'; +import { KibanaFramework } from '../../lib/adapters/framework/kibana_framework_adapter'; +import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { checkCustomDashboardsEnabled } from './lib/check_custom_dashboards_enabled'; +import { findCustomDashboard } from './lib/find_custom_dashboard'; +import { handleRouteErrors } from '../../utils/handle_route_errors'; + +export function initSaveCustomDashboardRoute(framework: KibanaFramework) { + const validatePayload = createRouteValidationFunction(InfraSaveCustomDashboardsRequestPayloadRT); + + framework.registerRoute( + { + method: 'post', + path: '/api/infra/custom-dashboards', + validate: { + body: validatePayload, + }, + options: { + access: 'internal', + }, + }, + handleRouteErrors(async (context, request, response) => { + const { savedObjectsClient, uiSettingsClient } = await context.infra; + + await checkCustomDashboardsEnabled(uiSettingsClient); + + const payload: InfraSaveCustomDashboardsRequestPayload = request.body; + const customDashboards = await findCustomDashboard(payload.assetType, savedObjectsClient); + + if (customDashboards.total === 0) { + const savedCustomDashboard = await savedObjectsClient.create( + INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + payload + ); + + return response.ok({ + body: InfraSaveCustomDashboardsResponseBodyRT.encode(savedCustomDashboard.attributes), + }); + } + + const savedCustomDashboard = await savedObjectsClient.update( + INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + customDashboards.saved_objects[0].id, + payload + ); + + return response.ok({ + body: InfraSaveCustomDashboardsResponseBodyRT.encode({ + ...payload, + ...savedCustomDashboard.attributes, + }), + }); + }) + ); +} diff --git a/x-pack/plugins/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts b/x-pack/plugins/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.ts new file mode 100644 index 0000000000000..8075a51336f36 --- /dev/null +++ b/x-pack/plugins/infra/server/saved_objects/custom_dashboards/custom_dashboards_saved_object.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 { SavedObjectsFieldMapping, SavedObjectsType } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import { schema, Type } from '@kbn/config-schema'; +import { InfraCustomDashboard } from '../../../common/custom_dashboards'; + +export const INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE = 'infra-custom-dashboards'; + +const properties: Record = { + dashboardIdList: { type: 'keyword' }, + assetType: { type: 'keyword' }, + kuery: { type: 'text' }, +}; +const createSchema: Record> = { + dashboardIdList: schema.arrayOf(schema.string()), + assetType: schema.string(), + kuery: schema.maybe(schema.string()), +}; + +export const infraCustomDashboardsSavedObjectType: SavedObjectsType = { + name: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'multiple', + mappings: { + properties, + }, + management: { + importableAndExportable: true, + icon: 'infraApp', + getTitle: () => + i18n.translate('xpack.infra.infraAssetCustomDashboards.', { + defaultMessage: 'Infrastructure Asset Custom Dashboards', + }), + }, + modelVersions: { + '1': { + changes: [], + schemas: { + create: schema.object(createSchema), + }, + }, + }, +}; diff --git a/x-pack/plugins/infra/server/saved_objects/index.ts b/x-pack/plugins/infra/server/saved_objects/index.ts index c64f4b46808c4..dfc18e6edfbaa 100644 --- a/x-pack/plugins/infra/server/saved_objects/index.ts +++ b/x-pack/plugins/infra/server/saved_objects/index.ts @@ -7,3 +7,4 @@ export * from './inventory_view'; export * from './metrics_explorer_view'; +export * from './custom_dashboards/custom_dashboards_saved_object'; diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts index c7fde8c4c0bd3..6b270f8dd1998 100644 --- a/x-pack/plugins/infra/server/types.ts +++ b/x-pack/plugins/infra/server/types.ts @@ -5,7 +5,12 @@ * 2.0. */ -import type { CoreSetup, CustomRequestHandlerContext } from '@kbn/core/server'; +import type { + CoreSetup, + CustomRequestHandlerContext, + IUiSettingsClient, + SavedObjectsClientContract, +} from '@kbn/core/server'; import type { SearchRequestHandlerContext } from '@kbn/data-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import { InfraServerPluginStartDeps } from './lib/adapters/framework'; @@ -33,18 +38,14 @@ export interface InfraPluginStart { export type MlSystem = ReturnType; export type MlAnomalyDetectors = ReturnType; -export interface InfraMlRequestHandlerContext { +export interface InfraRequestHandlerContext { mlAnomalyDetectors?: MlAnomalyDetectors; mlSystem?: MlSystem; -} - -export interface InfraSpacesRequestHandlerContext { spaceId: string; + savedObjectsClient: SavedObjectsClientContract; + uiSettingsClient: IUiSettingsClient; } -export type InfraRequestHandlerContext = InfraMlRequestHandlerContext & - InfraSpacesRequestHandlerContext; - /** * @internal */ diff --git a/x-pack/plugins/infra/server/utils/handle_route_errors.ts b/x-pack/plugins/infra/server/utils/handle_route_errors.ts new file mode 100644 index 0000000000000..3e76f2d4e8839 --- /dev/null +++ b/x-pack/plugins/infra/server/utils/handle_route_errors.ts @@ -0,0 +1,34 @@ +/* + * 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 Boom from '@hapi/boom'; +import { type RequestHandler } from '@kbn/core/server'; +import type { InfraPluginRequestHandlerContext } from '../types'; + +export function handleRouteErrors( + handler: RequestHandler +): RequestHandler { + return async (context, request, response) => { + try { + return await handler(context, request, response); + } catch (err) { + if (Boom.isBoom(err)) { + return response.customError({ + statusCode: err.output.statusCode, + body: { message: err.output.payload.message }, + }); + } + + return response.customError({ + statusCode: err.statusCode ?? 500, + body: { + message: err.message ?? 'An unexpected error occurred', + }, + }); + } + }; +} diff --git a/x-pack/plugins/infra/tsconfig.json b/x-pack/plugins/infra/tsconfig.json index 5a12a83b5fd58..acce31a2e36fe 100644 --- a/x-pack/plugins/infra/tsconfig.json +++ b/x-pack/plugins/infra/tsconfig.json @@ -86,6 +86,7 @@ "@kbn/core-ui-settings-browser", "@kbn/core-saved-objects-api-server", "@kbn/securitysolution-io-ts-utils", + "@kbn/core-lifecycle-server", "@kbn/elastic-agent-utils" ], "exclude": ["target/**/*"] diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 1ccebf7b00aac..2502639bc0d4d 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -33,7 +33,7 @@ export { apmLabsButton, enableInfrastructureHostsView, enableInfrastructureProfilingIntegration, - enableInfrastructureHostsCustomDashboards, + enableInfrastructureAssetCustomDashboards, enableAwsLambdaMetrics, enableAgentExplorerView, apmEnableTableSearchBar, diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index bd603b6c3bd3f..74fd6cd09bac4 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -19,8 +19,8 @@ export const apmLabsButton = 'observability:apmLabsButton'; export const enableInfrastructureHostsView = 'observability:enableInfrastructureHostsView'; export const enableInfrastructureProfilingIntegration = 'observability:enableInfrastructureProfilingIntegration'; -export const enableInfrastructureHostsCustomDashboards = - 'observability:enableInfrastructureHostsCustomDashboards'; +export const enableInfrastructureAssetCustomDashboards = + 'observability:enableInfrastructureAssetCustomDashboards'; export const enableAwsLambdaMetrics = 'observability:enableAwsLambdaMetrics'; export const enableAgentExplorerView = 'observability:apmAgentExplorerView'; export const apmEnableTableSearchBar = 'observability:apmEnableTableSearchBar'; diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index f908043d7a070..f7cdfada7ad85 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -41,7 +41,7 @@ import { profilingAzureCostDiscountRate, enableInfrastructureProfilingIntegration, apmEnableTransactionProfiling, - enableInfrastructureHostsCustomDashboards, + enableInfrastructureAssetCustomDashboards, } from '../common/ui_settings_keys'; const betaLabel = i18n.translate('xpack.observability.uiSettings.betaLabel', { @@ -255,17 +255,17 @@ export const uiSettings: Record = { ), schema: schema.boolean(), }, - [enableInfrastructureHostsCustomDashboards]: { + [enableInfrastructureAssetCustomDashboards]: { category: [observabilityFeatureId], - name: i18n.translate('xpack.observability.enableInfrastructureHostsCustomDashboards', { - defaultMessage: 'Custom dashboards for Host Details in Infrastructure', + name: i18n.translate('xpack.observability.enableInfrastructureAssetCustomDashboards', { + defaultMessage: 'Custom dashboards for asset details in Infrastructure', }), value: false, description: i18n.translate( - 'xpack.observability.enableInfrastructureHostsCustomDashboardsDescription', + 'xpack.observability.enableInfrastructureAssetCustomDashboardsDescription', { defaultMessage: - '{betaLabel} Enable option to link custom dashboards in the Host Details view.', + '{betaLabel} Enable option to link custom dashboards in the asset details view.', values: { betaLabel: `[${betaLabel}]`, }, diff --git a/x-pack/test/api_integration/apis/metrics_ui/index.js b/x-pack/test/api_integration/apis/metrics_ui/index.js index ddc444c0e2a70..9e203ac5a3ada 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/index.js +++ b/x-pack/test/api_integration/apis/metrics_ui/index.js @@ -25,5 +25,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./infra')); loadTestFile(require.resolve('./inventory_threshold_alert')); loadTestFile(require.resolve('./services')); + loadTestFile(require.resolve('./infra_custom_dashboards')); }); } diff --git a/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts b/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts new file mode 100644 index 0000000000000..86c69d8ccd413 --- /dev/null +++ b/x-pack/test/api_integration/apis/metrics_ui/infra_custom_dashboards.ts @@ -0,0 +1,167 @@ +/* + * 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 { InfraCustomDashboard } from '@kbn/infra-plugin/common/custom_dashboards'; +import { InfraSaveCustomDashboardsRequestPayload } from '@kbn/infra-plugin/common/http_api/custom_dashboards_api'; +import { INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE } from '@kbn/infra-plugin/server/saved_objects'; +import { enableInfrastructureAssetCustomDashboards } from '@kbn/observability-plugin/common'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const CUSTOM_DASHBOARDS_API_URL = '/api/infra/custom-dashboards'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('Infra Custom Dashboards API', () => { + beforeEach(async () => { + await kibanaServer.savedObjects.clean({ + types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], + }); + }); + + describe('GET endpoint for fetching custom dashboard', () => { + it('responds with an error if Custom Dashboards UI setting is not enabled', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: false, + }); + + await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(403); + }); + + it('responds with an error when trying to request a custom dashboard for unsupported asset type', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: false, + }); + + await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/unsupported-asset-type`).expect(400); + }); + + it('responds with an empty configuration if custom dashboard saved object does not exist', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + await kibanaServer.savedObjects.clean({ + types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], + }); + + const response = await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(200); + + expect(response.body).to.be.eql({ + assetType: 'host', + dashboardIdList: [], + }); + }); + + it('responds with the custom dashboard configuration for a given asset type when it exists', async () => { + const customDashboard: InfraCustomDashboard = { + assetType: 'host', + dashboardIdList: ['123'], + }; + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + await kibanaServer.savedObjects.create({ + type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + attributes: customDashboard, + overwrite: true, + }); + + const response = await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(200); + + expect(response.body).to.be.eql(customDashboard); + }); + }); + + describe('POST endpoint for saving (creating or updating) custom dashboard', () => { + it('responds with an error if Custom Dashboards UI setting is not enabled', async () => { + const payload: InfraSaveCustomDashboardsRequestPayload = { + assetType: 'host', + dashboardIdList: ['123'], + }; + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: false, + }); + + await supertest + .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(403); + }); + + it('responds with an error when trying to update a custom dashboard for unsupported asset type', async () => { + const payload = { + assetType: 'unsupported-asset-type', + dashboardIdList: ['123'], + }; + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + + await supertest + .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(400); + }); + + it('creates a new dashboard configuration when saving for the first time', async () => { + const payload: InfraSaveCustomDashboardsRequestPayload = { + assetType: 'host', + dashboardIdList: ['123'], + }; + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + await kibanaServer.savedObjects.clean({ + types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], + }); + + const response = await supertest + .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(200); + + expect(response.body).to.be.eql(payload); + }); + + it('updates existing dashboard configuration when for a given asset type', async () => { + await kibanaServer.uiSettings.update({ + [enableInfrastructureAssetCustomDashboards]: true, + }); + await kibanaServer.savedObjects.clean({ + types: [INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE], + }); + await kibanaServer.savedObjects.create({ + type: INFRA_CUSTOM_DASHBOARDS_SAVED_OBJECT_TYPE, + attributes: { + assetType: 'host', + dashboardIdList: ['123'], + }, + overwrite: true, + }); + + const payload: InfraSaveCustomDashboardsRequestPayload = { + assetType: 'host', + dashboardIdList: ['123', '456'], + }; + const updateResponse = await supertest + .post(`${CUSTOM_DASHBOARDS_API_URL}`) + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(200); + const getResponse = await supertest.get(`${CUSTOM_DASHBOARDS_API_URL}/host`).expect(200); + + expect(updateResponse.body).to.be.eql(payload); + expect(getResponse.body).to.be.eql(payload); + }); + }); + }); +}