diff --git a/.eslintrc.js b/.eslintrc.js index 19ba7cacc3c44..8a6ea7957927a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1377,6 +1377,10 @@ module.exports = { ['parent', 'sibling', 'index'], ], pathGroups: [ + { + pattern: '{**,.}/*.test.mocks', + group: 'unknown', + }, { pattern: '{@kbn/**,src/**,kibana{,/**}}', group: 'internal', @@ -1402,6 +1406,24 @@ module.exports = { }, }, + /** + * Do not allow `any` + */ + { + files: [ + 'packages/kbn-analytics/**', + // 'packages/kbn-telemetry-tools/**', + 'src/plugins/kibana_usage_collection/**', + 'src/plugins/usage_collection/**', + 'src/plugins/telemetry/**', + 'src/plugins/telemetry_collection_manager/**', + 'src/plugins/telemetry_management_section/**', + 'x-pack/plugins/telemetry_collection_xpack/**', + ], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, { files: [ // core-team owned code diff --git a/api_docs/telemetry.json b/api_docs/telemetry.json index bff65ce9c68dd..bfb19a79bdb1e 100644 --- a/api_docs/telemetry.json +++ b/api_docs/telemetry.json @@ -1,9 +1,611 @@ { "id": "telemetry", "client": { - "classes": [], + "classes": [ + { + "id": "def-public.TelemetryNotifications", + "type": "Class", + "tags": [], + "label": "TelemetryNotifications", + "description": [], + "children": [ + { + "id": "def-public.TelemetryNotifications.Unnamed", + "type": "Function", + "label": "Constructor", + "signature": [ + "any" + ], + "description": [], + "children": [ + { + "id": "def-public.TelemetryNotifications.Unnamed.$1", + "type": "Object", + "label": "{ http, overlays, telemetryService }", + "isRequired": true, + "signature": [ + "TelemetryNotificationsConstructor" + ], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 27 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 27 + } + }, + { + "id": "def-public.TelemetryNotifications.shouldShowOptedInNoticeBanner", + "type": "Function", + "children": [], + "signature": [ + "() => boolean" + ], + "description": [], + "label": "shouldShowOptedInNoticeBanner", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 33 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryNotifications.renderOptedInNoticeBanner", + "type": "Function", + "children": [], + "signature": [ + "() => void" + ], + "description": [], + "label": "renderOptedInNoticeBanner", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 39 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryNotifications.shouldShowOptInBanner", + "type": "Function", + "children": [], + "signature": [ + "() => boolean" + ], + "description": [], + "label": "shouldShowOptInBanner", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 49 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryNotifications.renderOptInBanner", + "type": "Function", + "children": [], + "signature": [ + "() => void" + ], + "description": [], + "label": "renderOptInBanner", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 55 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryNotifications.setOptedInNoticeSeen", + "type": "Function", + "children": [], + "signature": [ + "() => Promise" + ], + "description": [], + "label": "setOptedInNoticeSeen", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 73 + }, + "tags": [], + "returnComment": [] + } + ], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_notifications/telemetry_notifications.ts", + "lineNumber": 20 + }, + "initialIsOpen": false + }, + { + "id": "def-public.TelemetryService", + "type": "Class", + "tags": [], + "label": "TelemetryService", + "description": [], + "children": [ + { + "tags": [], + "id": "def-public.TelemetryService.currentKibanaVersion", + "type": "string", + "label": "currentKibanaVersion", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 28 + } + }, + { + "id": "def-public.TelemetryService.Unnamed", + "type": "Function", + "label": "Constructor", + "signature": [ + "any" + ], + "description": [], + "children": [ + { + "id": "def-public.TelemetryService.Unnamed.$1", + "type": "Object", + "label": "{\n config,\n http,\n notifications,\n currentKibanaVersion,\n reportOptInStatusChange = true,\n }", + "isRequired": true, + "signature": [ + "TelemetryServiceConstructor" + ], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 30 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 30 + } + }, + { + "id": "def-public.TelemetryService.config", + "type": "Object", + "label": "config", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 44 + }, + "signature": [ + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryPluginConfig", + "text": "TelemetryPluginConfig" + } + ] + }, + { + "id": "def-public.TelemetryService.config", + "type": "Object", + "label": "config", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 48 + }, + "signature": [ + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryPluginConfig", + "text": "TelemetryPluginConfig" + } + ] + }, + { + "id": "def-public.TelemetryService.isOptedIn", + "type": "CompoundType", + "label": "isOptedIn", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 52 + }, + "signature": [ + "boolean | null" + ] + }, + { + "id": "def-public.TelemetryService.isOptedIn", + "type": "CompoundType", + "label": "isOptedIn", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 56 + }, + "signature": [ + "boolean | null" + ] + }, + { + "id": "def-public.TelemetryService.userHasSeenOptedInNotice", + "type": "CompoundType", + "label": "userHasSeenOptedInNotice", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 60 + }, + "signature": [ + "boolean | undefined" + ] + }, + { + "id": "def-public.TelemetryService.userHasSeenOptedInNotice", + "type": "CompoundType", + "label": "userHasSeenOptedInNotice", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 64 + }, + "signature": [ + "boolean | undefined" + ] + }, + { + "id": "def-public.TelemetryService.getCanChangeOptInStatus", + "type": "Function", + "children": [], + "signature": [ + "() => boolean" + ], + "description": [], + "label": "getCanChangeOptInStatus", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 68 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.getOptInStatusUrl", + "type": "Function", + "children": [], + "signature": [ + "() => string" + ], + "description": [], + "label": "getOptInStatusUrl", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 73 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.getTelemetryUrl", + "type": "Function", + "children": [], + "signature": [ + "() => string" + ], + "description": [], + "label": "getTelemetryUrl", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 78 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.getUserShouldSeeOptInNotice", + "type": "Function", + "label": "getUserShouldSeeOptInNotice", + "signature": [ + "() => boolean" + ], + "description": [ + "\nReturns if an user should be shown the notice about Opt-In/Out telemetry.\nThe decision is made based on whether any user has already dismissed the message or\nthe user can't actually change the settings (in which case, there's no point on bothering them)" + ], + "children": [], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 88 + } + }, + { + "id": "def-public.TelemetryService.userCanChangeSettings", + "type": "boolean", + "label": "userCanChangeSettings", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 95 + } + }, + { + "id": "def-public.TelemetryService.userCanChangeSettings", + "type": "boolean", + "label": "userCanChangeSettings", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 99 + } + }, + { + "id": "def-public.TelemetryService.getIsOptedIn", + "type": "Function", + "children": [], + "signature": [ + "() => boolean | null" + ], + "description": [], + "label": "getIsOptedIn", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 103 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.fetchExample", + "type": "Function", + "children": [], + "signature": [ + "() => Promise" + ], + "description": [], + "label": "fetchExample", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 107 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.fetchTelemetry", + "type": "Function", + "children": [ + { + "id": "def-public.TelemetryService.fetchTelemetry.$1", + "type": "Object", + "label": "{ unencrypted = false }", + "isRequired": true, + "signature": [ + "{ unencrypted?: boolean | undefined; }" + ], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 111 + } + } + ], + "signature": [ + "({ unencrypted }?: { unencrypted?: boolean | undefined; }) => Promise" + ], + "description": [], + "label": "fetchTelemetry", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 111 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.setOptIn", + "type": "Function", + "children": [ + { + "id": "def-public.TelemetryService.setOptIn.$1", + "type": "boolean", + "label": "optedIn", + "isRequired": true, + "signature": [ + "boolean" + ], + "description": [], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 119 + } + } + ], + "signature": [ + "(optedIn: boolean) => Promise" + ], + "description": [], + "label": "setOptIn", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 119 + }, + "tags": [], + "returnComment": [] + }, + { + "id": "def-public.TelemetryService.setUserHasSeenNotice", + "type": "Function", + "children": [], + "signature": [ + "() => Promise" + ], + "description": [], + "label": "setUserHasSeenNotice", + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 153 + }, + "tags": [], + "returnComment": [] + } + ], + "source": { + "path": "src/plugins/telemetry/public/services/telemetry_service.ts", + "lineNumber": 21 + }, + "initialIsOpen": false + } + ], "functions": [], - "interfaces": [], + "interfaces": [ + { + "id": "def-public.TelemetryPluginConfig", + "type": "Interface", + "label": "TelemetryPluginConfig", + "description": [], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.enabled", + "type": "boolean", + "label": "enabled", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 46 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.url", + "type": "string", + "label": "url", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 47 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.banner", + "type": "boolean", + "label": "banner", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 48 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.allowChangingOptInStatus", + "type": "boolean", + "label": "allowChangingOptInStatus", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 49 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.optIn", + "type": "CompoundType", + "label": "optIn", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 50 + }, + "signature": [ + "boolean | null" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.optInStatusUrl", + "type": "string", + "label": "optInStatusUrl", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 51 + } + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.sendUsageFrom", + "type": "CompoundType", + "label": "sendUsageFrom", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 52 + }, + "signature": [ + "\"browser\" | \"server\"" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.telemetryNotifyUserAboutOptInDefault", + "type": "CompoundType", + "label": "telemetryNotifyUserAboutOptInDefault", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 53 + }, + "signature": [ + "boolean | undefined" + ] + }, + { + "tags": [], + "id": "def-public.TelemetryPluginConfig.userCanChangeSettings", + "type": "CompoundType", + "label": "userCanChangeSettings", + "description": [], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 54 + }, + "signature": [ + "boolean | undefined" + ] + } + ], + "source": { + "path": "src/plugins/telemetry/public/plugin.ts", + "lineNumber": 45 + }, + "initialIsOpen": false + } + ], "enums": [], "misc": [], "objects": [], @@ -25,7 +627,13 @@ "lineNumber": 38 }, "signature": [ - "TelemetryService" + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryService", + "text": "TelemetryService" + } ] }, { @@ -39,7 +647,13 @@ "lineNumber": 39 }, "signature": [ - "TelemetryNotifications" + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryNotifications", + "text": "TelemetryNotifications" + } ] }, { @@ -82,7 +696,13 @@ "lineNumber": 34 }, "signature": [ - "TelemetryService" + { + "pluginId": "telemetry", + "scope": "public", + "docId": "kibTelemetryPluginApi", + "section": "def-public.TelemetryService", + "text": "TelemetryService" + } ] } ], @@ -95,137 +715,7 @@ } }, "server": { - "classes": [ - { - "id": "def-server.FetcherTask", - "type": "Class", - "tags": [], - "label": "FetcherTask", - "description": [], - "children": [ - { - "id": "def-server.FetcherTask.Unnamed", - "type": "Function", - "label": "Constructor", - "signature": [ - "any" - ], - "description": [], - "children": [ - { - "id": "def-server.FetcherTask.Unnamed.$1", - "type": "Object", - "label": "initializerContext", - "isRequired": true, - "signature": [ - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.PluginInitializerContext", - "text": "PluginInitializerContext" - }, - ">" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 58 - } - } - ], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 58 - } - }, - { - "id": "def-server.FetcherTask.start", - "type": "Function", - "label": "start", - "signature": [ - "({ savedObjects, elasticsearch }: ", - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.CoreStart", - "text": "CoreStart" - }, - ", { telemetryCollectionManager }: ", - "FetcherTaskDepsStart", - ") => void" - ], - "description": [], - "children": [ - { - "id": "def-server.FetcherTask.start.$1", - "type": "Object", - "label": "{ savedObjects, elasticsearch }", - "isRequired": true, - "signature": [ - { - "pluginId": "core", - "scope": "server", - "docId": "kibCorePluginApi", - "section": "def-server.CoreStart", - "text": "CoreStart" - } - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 65 - } - }, - { - "id": "def-server.FetcherTask.start.$2", - "type": "Object", - "label": "{ telemetryCollectionManager }", - "isRequired": true, - "signature": [ - "FetcherTaskDepsStart" - ], - "description": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 66 - } - } - ], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 64 - } - }, - { - "id": "def-server.FetcherTask.stop", - "type": "Function", - "label": "stop", - "signature": [ - "() => void" - ], - "description": [], - "children": [], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 77 - } - } - ], - "source": { - "path": "src/plugins/telemetry/server/fetcher.ts", - "lineNumber": 45 - }, - "initialIsOpen": false - } - ], + "classes": [], "functions": [ { "id": "def-server.buildDataTelemetryPayload", @@ -420,15 +910,16 @@ "section": "def-server.StatsCollectionContext", "text": "StatsCollectionContext" }, - ") => Promise<{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: any; collection: string; stack_stats: { data: ", + ") => Promise<{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: Pick<{ nodes: { usage: { nodes: ", { "pluginId": "telemetry", "scope": "server", "docId": "kibTelemetryPluginApi", - "section": "def-server.DataTelemetryPayload", - "text": "DataTelemetryPayload" + "section": "def-server.NodeUsage", + "text": "NodeUsage" }, - " | undefined; kibana: { count: number; indices: number; os: {}; versions: { version: string; count: number; }[]; plugins: { [plugin: string]: any; }; } | undefined; }; }[]>" + "[] | {}[]; }; count: ", + "ClusterNodeCount" ], "description": [ "\nGet statistics for all products joined by Elasticsearch cluster." @@ -656,6 +1147,123 @@ "lineNumber": 38 }, "initialIsOpen": false + }, + { + "id": "def-server.NodeUsage", + "type": "Interface", + "label": "NodeUsage", + "description": [], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-server.NodeUsage.node_id", + "type": "string", + "label": "node_id", + "description": [], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 18 + }, + "signature": [ + "string | undefined" + ] + }, + { + "tags": [], + "id": "def-server.NodeUsage.timestamp", + "type": "CompoundType", + "label": "timestamp", + "description": [], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 19 + }, + "signature": [ + "React.ReactText" + ] + }, + { + "tags": [], + "id": "def-server.NodeUsage.since", + "type": "number", + "label": "since", + "description": [], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 20 + } + }, + { + "tags": [], + "id": "def-server.NodeUsage.rest_actions", + "type": "Object", + "label": "rest_actions", + "description": [], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 21 + }, + "signature": [ + "{ [key: string]: number; }" + ] + }, + { + "tags": [], + "id": "def-server.NodeUsage.aggregations", + "type": "Object", + "label": "aggregations", + "description": [], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 24 + }, + "signature": [ + "{ [key: string]: ", + { + "pluginId": "telemetry", + "scope": "server", + "docId": "kibTelemetryPluginApi", + "section": "def-server.NodeUsageAggregation", + "text": "NodeUsageAggregation" + }, + "; } | undefined" + ] + } + ], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 17 + }, + "initialIsOpen": false + }, + { + "id": "def-server.NodeUsageAggregation", + "type": "Interface", + "label": "NodeUsageAggregation", + "description": [], + "tags": [], + "children": [ + { + "id": "def-server.NodeUsageAggregation.Unnamed", + "type": "Any", + "label": "Unnamed", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 13 + }, + "signature": [ + "any" + ] + } + ], + "source": { + "path": "src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts", + "lineNumber": 12 + }, + "initialIsOpen": false } ], "enums": [], @@ -701,7 +1309,7 @@ "lineNumber": 51 }, "signature": [ - "{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: any; collection: string; stack_stats: { data: DataTelemetryPayload | undefined; kibana: { count: number; indices: number; os: {}; versions: { version: string; count: number; }[]; plugins: { [plugin: string]: any; }; } | undefined; }; }" + "{ timestamp: string; cluster_uuid: string; cluster_name: string; version: string; cluster_stats: Pick; collection: string; stack_stats: { data: DataTelemetryPayload | undefined; kibana: { count: number; indices: number; os: {}; versions: { version: string; count: number; }[]; plugins: { [plugin: string]: Record; }; } | undefined; }; }" ], "initialIsOpen": false } diff --git a/api_docs/telemetry.mdx b/api_docs/telemetry.mdx index bf91eb198f08e..f9a58d29ebd86 100644 --- a/api_docs/telemetry.mdx +++ b/api_docs/telemetry.mdx @@ -19,6 +19,12 @@ import telemetryObj from './telemetry.json'; ### Start +### Classes + + +### Interfaces + + ## Server ### Setup @@ -30,9 +36,6 @@ import telemetryObj from './telemetry.json'; ### Functions -### Classes - - ### Interfaces diff --git a/dev_docs/best_practices.mdx b/dev_docs/best_practices.mdx index 4d51263f93372..54aaaa6b9497a 100644 --- a/dev_docs/best_practices.mdx +++ b/dev_docs/best_practices.mdx @@ -65,6 +65,7 @@ Every publicly exposed function, class, interface, type, parameter and property - Use `@returns` tags for return types. - Use `@throws` when appropriate. - Use `@beta` or `@deprecated` when appropriate. +- Use `@removeBy {version}` on `@deprecated` APIs. The version should be the last version the API will work in. For example, `@removeBy 7.15` means the API will be removed in 7.16. This lets us avoid mid-release cycle coordination. The API can be removed as soon as the 7.15 branch is cut. - Use `@internal` to indicate this API item is intended for internal use only, which will also remove it from the docs. #### Interfaces vs inlined types diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index 554e84615d568..c9ea31c98cf19 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -73,6 +73,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "is_preconfigured": false + "is_preconfigured": false, + "is_missing_secrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc index 0d9af45c4ef0c..95336e7f55d30 100644 --- a/docs/api/actions-and-connectors/get.asciidoc +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -50,6 +50,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "is_preconfigured": false + "is_preconfigured": false, + "is_missing_secrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc index e4e67a9bbde73..8036b9fea7f95 100644 --- a/docs/api/actions-and-connectors/get_all.asciidoc +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -56,6 +56,7 @@ The API returns the following: "executionTimeField": null }, "is_preconfigured": false, + "is_missing_secrets": false, "referenced_by_count": 3 } ] diff --git a/docs/api/actions-and-connectors/legacy/create.asciidoc b/docs/api/actions-and-connectors/legacy/create.asciidoc index 0361c4222986b..e0d531a2befb9 100644 --- a/docs/api/actions-and-connectors/legacy/create.asciidoc +++ b/docs/api/actions-and-connectors/legacy/create.asciidoc @@ -75,6 +75,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/legacy/get.asciidoc b/docs/api/actions-and-connectors/legacy/get.asciidoc index 6413fce558f5b..dab462e3ae4fb 100644 --- a/docs/api/actions-and-connectors/legacy/get.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get.asciidoc @@ -52,6 +52,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/legacy/get_all.asciidoc b/docs/api/actions-and-connectors/legacy/get_all.asciidoc index 191eccb6f8d39..2180720ce6542 100644 --- a/docs/api/actions-and-connectors/legacy/get_all.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get_all.asciidoc @@ -56,7 +56,8 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } ] -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/legacy/update.asciidoc b/docs/api/actions-and-connectors/legacy/update.asciidoc index 6a33e765cf063..5202f8124e6a8 100644 --- a/docs/api/actions-and-connectors/legacy/update.asciidoc +++ b/docs/api/actions-and-connectors/legacy/update.asciidoc @@ -70,6 +70,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "isPreconfigured": false + "isPreconfigured": false, + "isMissingSecrets": false } -------------------------------------------------- diff --git a/docs/api/actions-and-connectors/update.asciidoc b/docs/api/actions-and-connectors/update.asciidoc index f522cb8d048e0..0b7dcc898a122 100644 --- a/docs/api/actions-and-connectors/update.asciidoc +++ b/docs/api/actions-and-connectors/update.asciidoc @@ -68,6 +68,7 @@ The API returns the following: "refresh": false, "executionTimeField": null }, - "is_preconfigured": false + "is_preconfigured": false, + "is_missing_secrets": false } -------------------------------------------------- diff --git a/docs/api/alerting/create_rule.asciidoc b/docs/api/alerting/create_rule.asciidoc index 01b6dfc40fcf6..59b17c5c3b5e1 100644 --- a/docs/api/alerting/create_rule.asciidoc +++ b/docs/api/alerting/create_rule.asciidoc @@ -6,6 +6,8 @@ Create {kib} rules. +WARNING: This API supports <> only. + [[create-rule-api-request]] ==== Request diff --git a/docs/api/alerting/enable_rule.asciidoc b/docs/api/alerting/enable_rule.asciidoc index 60f18b3510904..112d4bbf61faa 100644 --- a/docs/api/alerting/enable_rule.asciidoc +++ b/docs/api/alerting/enable_rule.asciidoc @@ -6,6 +6,8 @@ Enable a rule. +WARNING: This API supports <> only. + [[enable-rule-api-request]] ==== Request diff --git a/docs/api/alerting/update_rule.asciidoc b/docs/api/alerting/update_rule.asciidoc index 76c88a009be01..ec82e60a8e879 100644 --- a/docs/api/alerting/update_rule.asciidoc +++ b/docs/api/alerting/update_rule.asciidoc @@ -6,6 +6,8 @@ Update the attributes for an existing rule. +WARNING: This API supports <> only. + [[update-rule-api-request]] ==== Request diff --git a/docs/developer/contributing/development-tests.asciidoc b/docs/developer/contributing/development-tests.asciidoc index 7aabc480cdaa2..715b1a15ab5ed 100644 --- a/docs/developer/contributing/development-tests.asciidoc +++ b/docs/developer/contributing/development-tests.asciidoc @@ -19,7 +19,7 @@ root) |Functional |`test/**/config.js` `x-pack/test/**/config.js` -|`node scripts/functional_tests_server --config [directory]/config.js``node scripts/functional_test_runner_ --config [directory]/config.js --grep=regexp` +|`node scripts/functional_tests_server --config [directory]/config.js` `node scripts/functional_test_runner --config [directory]/config.js --grep=regexp` |=== Test runner arguments: - Where applicable, the optional arguments diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 86f9f7562434e..d2c24a9e4944f 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -62,9 +62,13 @@ yarn kbn watch-bazel === List of Already Migrated Packages to Bazel - @elastic/datemath +- @elastic/safer-lodash-set +- @kbn/apm-config-loader - @kbn/apm-utils +- @kbn/babel-code-parser - @kbn/babel-preset - @kbn/config-schema +- @kbn/expect - @kbn/std - @kbn/tinymath - @kbn/utility-types diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index ac84fe65895a7..7d7d2c1246872 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -343,6 +343,7 @@ Failure to have auth enabled in Kibana will make for a broken UI. UI-based error |{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud] |The cloud plugin adds cloud specific features to Kibana. +The client-side plugin configures following values: |{kib-repo}blob/{branch}/x-pack/plugins/console_extensions/README.md[consoleExtensions] diff --git a/docs/development/core/public/kibana-plugin-core-public.app_wrapper_class.md b/docs/development/core/public/kibana-plugin-core-public.app_wrapper_class.md new file mode 100644 index 0000000000000..577c7edbeef4a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.app_wrapper_class.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [APP\_WRAPPER\_CLASS](./kibana-plugin-core-public.app_wrapper_class.md) + +## APP\_WRAPPER\_CLASS variable + +The class name for top level \*and\* nested application wrappers to ensure proper layout + +Signature: + +```typescript +APP_WRAPPER_CLASS = "kbnAppWrapper" +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.getsaved_.md b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.getsaved_.md deleted file mode 100644 index 953bb75625c97..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.getsaved_.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) > [getSaved$](./kibana-plugin-core-public.iuisettingsclient.getsaved_.md) - -## IUiSettingsClient.getSaved$ property - -Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. - -Signature: - -```typescript -getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md index 87ef5784a6c6d..d6f3b3186b542 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.md @@ -19,14 +19,12 @@ export interface IUiSettingsClient | [get](./kibana-plugin-core-public.iuisettingsclient.get.md) | <T = any>(key: string, defaultOverride?: T) => T | Gets the value for a specific uiSetting. If this setting has no user-defined value then the defaultOverride parameter is returned (and parsed if setting is of type "json" or "number). If the parameter is not defined and the key is not registered by any plugin then an error is thrown, otherwise reads the default value defined by a plugin. | | [get$](./kibana-plugin-core-public.iuisettingsclient.get_.md) | <T = any>(key: string, defaultOverride?: T) => Observable<T> | Gets an observable of the current value for a config key, and all updates to that config key in the future. Providing a defaultOverride argument behaves the same as it does in \#get() | | [getAll](./kibana-plugin-core-public.iuisettingsclient.getall.md) | () => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> | Gets the metadata about all uiSettings, including the type, default value, and user value for each key. | -| [getSaved$](./kibana-plugin-core-public.iuisettingsclient.getsaved_.md) | <T = any>() => Observable<{
key: string;
newValue: T;
oldValue: T;
}> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. | | [getUpdate$](./kibana-plugin-core-public.iuisettingsclient.getupdate_.md) | <T = any>() => Observable<{
key: string;
newValue: T;
oldValue: T;
}> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. | | [getUpdateErrors$](./kibana-plugin-core-public.iuisettingsclient.getupdateerrors_.md) | () => Observable<Error> | Returns an Observable that notifies subscribers of each error while trying to update the settings, containing the actual Error class. | | [isCustom](./kibana-plugin-core-public.iuisettingsclient.iscustom.md) | (key: string) => boolean | Returns true if the setting wasn't registered by any plugin, but was either added directly via set(), or is an unknown setting found in the uiSettings saved object | | [isDeclared](./kibana-plugin-core-public.iuisettingsclient.isdeclared.md) | (key: string) => boolean | Returns true if the key is a "known" uiSetting, meaning it is either registered by any plugin or was previously added as a custom setting via the set() method. | | [isDefault](./kibana-plugin-core-public.iuisettingsclient.isdefault.md) | (key: string) => boolean | Returns true if the setting has no user-defined value or is unknown | | [isOverridden](./kibana-plugin-core-public.iuisettingsclient.isoverridden.md) | (key: string) => boolean | Shows whether the uiSettings value set by the user. | -| [overrideLocalDefault](./kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md) | (key: string, newDefault: any) => void | Overrides the default value for a setting in this specific browser tab. If the page is reloaded the default override is lost. | | [remove](./kibana-plugin-core-public.iuisettingsclient.remove.md) | (key: string) => Promise<boolean> | Removes the user-defined value for a setting, causing it to revert to the default. This method behaves the same as calling set(key, null), including the synchronization, custom setting, and error behavior of that method. | | [set](./kibana-plugin-core-public.iuisettingsclient.set.md) | (key: string, value: any) => Promise<boolean> | Sets the value for a uiSetting. If the setting is not registered by any plugin it will be stored as a custom setting. The new value will be synchronously available via the get() method and sent to the server in the background. If the request to the server fails then a updateErrors$ will be notified and the setting will be reverted to its value before set() was called. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md b/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md deleted file mode 100644 index 0ae52e4959e10..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) > [overrideLocalDefault](./kibana-plugin-core-public.iuisettingsclient.overridelocaldefault.md) - -## IUiSettingsClient.overrideLocalDefault property - -Overrides the default value for a setting in this specific browser tab. If the page is reloaded the default override is lost. - -Signature: - -```typescript -overrideLocalDefault: (key: string, newDefault: any) => void; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 39e554f5492ac..b868a7f8216df 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -138,6 +138,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Variable | Description | | --- | --- | +| [APP\_WRAPPER\_CLASS](./kibana-plugin-core-public.app_wrapper_class.md) | The class name for top level \*and\* nested application wrappers to ensure proper layout | | [URL\_MAX\_LENGTH](./kibana-plugin-core-public.url_max_length.md) | The max URL length allowed by the current browser. Should be used to display warnings to users when query parameters cause URL to exceed this limit. | ## Type Aliases diff --git a/docs/development/core/server/kibana-plugin-core-server.app_wrapper_class.md b/docs/development/core/server/kibana-plugin-core-server.app_wrapper_class.md new file mode 100644 index 0000000000000..cdb0b909bf79d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.app_wrapper_class.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [APP\_WRAPPER\_CLASS](./kibana-plugin-core-server.app_wrapper_class.md) + +## APP\_WRAPPER\_CLASS variable + +The class name for top level \*and\* nested application wrappers to ensure proper layout + +Signature: + +```typescript +APP_WRAPPER_CLASS = "kbnAppWrapper" +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md new file mode 100644 index 0000000000000..bbd97ab517d29 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CustomHttpResponseOptions](./kibana-plugin-core-server.customhttpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) + +## CustomHttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md index 67242bbd4e2ef..82089c831d718 100644 --- a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md @@ -17,6 +17,7 @@ export interface CustomHttpResponseOptionsT | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.customhttpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | | [statusCode](./kibana-plugin-core-server.customhttpresponseoptions.statuscode.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md new file mode 100644 index 0000000000000..98792c47d564f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResponseOptions](./kibana-plugin-core-server.httpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) + +## HttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md index 9f31e86175f79..497adc6a5ec5d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md @@ -17,5 +17,6 @@ export interface HttpResponseOptions | Property | Type | Description | | --- | --- | --- | | [body](./kibana-plugin-core-server.httpresponseoptions.body.md) | HttpResponsePayload | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.httpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index e33e9472d42a9..4df8d074ba9c8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -230,6 +230,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Variable | Description | | --- | --- | +| [APP\_WRAPPER\_CLASS](./kibana-plugin-core-server.app_wrapper_class.md) | The class name for top level \*and\* nested application wrappers to ensure proper layout | | [kibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) | Set of helpers used to create KibanaResponse to form HTTP response on an incoming request. Should be returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution. | | [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md) | The current "level" of availability of a service. | | [validBodyOutput](./kibana-plugin-core-server.validbodyoutput.md) | The set of valid body.output | diff --git a/docs/settings/url-drilldown-settings.asciidoc b/docs/settings/url-drilldown-settings.asciidoc new file mode 100644 index 0000000000000..8be3a21bfbffc --- /dev/null +++ b/docs/settings/url-drilldown-settings.asciidoc @@ -0,0 +1,31 @@ +[[url-drilldown-settings-kb]] +=== URL drilldown settings in {kib} +++++ +URL drilldown settings +++++ + +Configure the URL drilldown settings in your `kibana.yml` configuration file. + +[cols="2*<"] +|=== +| [[url-drilldown-enabled]] `url_drilldown.enabled` + | When `true`, enables URL drilldowns on your {kib} instance. + +| [[external-URL-policy]] `externalUrl.policy` + | Configures the external URL policies. URL drilldowns respect the global *External URL* service, which you can use to deny or allow external URLs. +By default all external URLs are allowed. +|=== + +For example, to allow external URLs only to the `example.com` domain with the `https` scheme, except for the `danger.example.com` sub-domain, +which is denied even when `https` scheme is used: + +["source","yml"] +----------- +externalUrl.policy: + - allow: false + host: danger.example.com + - allow: true + host: example.com + protocol: https +----------- + diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 1b027739169ad..0aab86fb5a9e2 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -756,3 +756,4 @@ include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] include::{kib-repo-dir}/settings/telemetry-settings.asciidoc[] +include::{kib-repo-dir}/settings/url-drilldown-settings.asciidoc[] diff --git a/docs/user/dashboard/dashboard.asciidoc b/docs/user/dashboard/dashboard.asciidoc index 89fa564b0ac71..070d511ed8073 100644 --- a/docs/user/dashboard/dashboard.asciidoc +++ b/docs/user/dashboard/dashboard.asciidoc @@ -290,6 +290,13 @@ To add a panel to another dashboard, copy the panel. View the underlying documents in a panel, or in a data series. +. In kibana.yml, add the following: ++ +["source","yml"] +----------- +xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled: true +----------- + TIP: *Explore underlying data* is supported only for visualization panels with a single index pattern. To view the underlying documents in the panel: diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index cbe47f23fcbaf..fc25f84030ee2 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -2,8 +2,8 @@ [[drilldowns]] == Create custom dashboard actions -Custom dashboard actions, also known as drilldowns, allow you to create -workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns. +Custom dashboard actions, or _drilldowns_, allow you to create workflows for analyzing and troubleshooting your data. +Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all panels. Each panel can have multiple drilldowns. Third-party developers can create drilldowns. To learn how to code drilldowns, refer to {kib-repo}blob/{branch}/x-pack/examples/ui_actions_enhanced_examples[this example plugin]. @@ -11,27 +11,23 @@ Third-party developers can create drilldowns. To learn how to code drilldowns, r [[supported-drilldowns]] === Supported drilldowns -{kib} supports two types of drilldowns. - -[NOTE] -===================================== -Some drilldowns are paid subscription features, while others are free. -For a comparison of the Elastic subscription levels, -refer https://www.elastic.co/subscriptions[the subscription page]. -===================================== +{kib} supports dashboard and URL drilldowns. [float] [[dashboard-drilldowns]] ==== Dashboard drilldowns Dashboard drilldowns enable you to open a dashboard from another dashboard, -taking the time range, filters, and other parameters with you, +taking the time range, filters, and other parameters with you so the context remains the same. Dashboard drilldowns help you to continue your analysis from a new perspective. For example, if you have a dashboard that shows the overall status of multiple data center, you can create a drilldown that navigates from the overall status dashboard to a dashboard that shows a single data center or server. +[role="screenshot"] +image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] + [float] [[url-drilldowns]] ==== URL drilldowns @@ -39,45 +35,25 @@ that shows a single data center or server. URL drilldowns enable you to navigate from a dashboard to internal or external URLs. Destination URLs can be dynamic, depending on the dashboard context or user interaction with a panel. For example, if you have a dashboard that shows data from a Github repository, you can create a URL drilldown -that opens Github from the dashboard. +that opens Github from the dashboard panel. + +[role="screenshot"] +image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] Some panels support multiple interactions, also known as triggers. The <> you use to create a <> depends on the trigger you choose. URL drilldowns support these types of triggers: -* *Single click* — A single data point in the visualization. +* *Single click* — A single data point in the panel. -* *Range selection* — A range of values in a visualization. +* *Range selection* — A range of values in a panel. For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`. -To disable URL drilldowns on your {kib} instance, add the following line to `kibana.yml` config file: - -["source","yml"] ------------ -url_drilldown.enabled: false ------------ - -URL drilldown also respects the global *External URL* service, which can be used to deny/allow external URLs. -By default all external URLs are allowed. To configure external URL policies you need to use `externalUrl.policy` setting in `kibana.yml`, for example: - -["source","yml"] ------------ -externalUrl.policy: - - allow: false - host: danger.example.com - - allow: true - host: example.com - protocol: https ------------ - -The above rules allow external URLs only to `example.com` domain with `https` scheme, except for `danger.example.com` sub-domain, -which is denied even when `https` scheme is used. - [float] [[dashboard-drilldown-supported-panels]] -=== Supported panels +=== Supported panel types -The following panels support dashboard and URL drilldowns. +The following panel types support drilldowns. [options="header"] |=== @@ -138,7 +114,7 @@ The following panels support dashboard and URL drilldowns. | TSVB ^| X -^| +^| X | Tag Cloud ^| X @@ -160,25 +136,23 @@ The following panels support dashboard and URL drilldowns. [float] [[drilldowns-example]] -=== Try it: Create a dashboard drilldown +=== Create a dashboard drilldown To create dashboard drilldowns, you create or locate the dashboards you want to connect, then configure the drilldown that allows you to easily open one dashboard from the other dashboard. -image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] - [float] ==== Create the dashboard . Add the *Sample web logs* data. -. Create a new dashboard, then add the following panels: +. Create a new dashboard, then add the following panels from the *Visualize Library*: * *[Logs] Heatmap* * *[Logs] Host, Visits, and Bytes Table* * *[Logs] Total Requests and Bytes* * *[Logs] Visitors by OS* + -If you don’t see data for a panel, try changing the <>. +If you don’t see the data on a panel, try changing the <>. . Save the dashboard. In the *Title* field, enter `Host Overview`. @@ -197,79 +171,82 @@ Filter: `geo.src: CN` . Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. -. Give the drilldown a name, then select *Go to dashboard*. +. Click *Go to dashboard*. -. From the *Choose a destination dashboard* dropdown, select *Host Overview*. +.. Give the drilldown a name. For example, `My Drilldown`. -. To carry over the filter, query, and date range, make sure that *Use filters and query from origin dashboard* and *Use date range from origin dashboard* are selected. -+ -[role="screenshot"] -image::images/drilldown_create.png[Create drilldown with entries for drilldown name and destination] +.. From the *Choose a destination dashboard* dropdown, select *Host Overview*. -. Click *Create drilldown*. -+ -The drilldown is stored as dashboard metadata. +.. To use the geo.src filter, KQL query, and time filter, select *Use filters and query from origin dashboard* and *Use date range from origin dashboard*. + +.. Click *Create drilldown*. . Save the dashboard. -+ -If you fail to save the dashboard, the drilldown is lost when you navigate away from the dashboard. -. In the *[Logs] Visitors by OS* panel, click *win 8*, then select the drilldown. +. In the *[Logs] Visitors by OS* panel, click *win 8*, then select `My Drilldown`. + [role="screenshot"] image::images/drilldown_on_panel.png[Drilldown on pie chart that navigates to another dashboard] -. On the *Host Overview* dashboard, verify that the search query, filters, -and date range are carried over. +. On the *Host Overview* dashboard, verify that the geo.src filter, KQL query, and time filter are applied. [float] [[create-a-url-drilldown]] -=== Try it: Create a URL drilldown +=== Create a URL drilldown To create URL drilldowns, you add <> to a URL template, which configures the behavior of the drilldown. -image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] - . Add the *Sample web logs* data. -. Open the *[Logs] Web traffic* dashboard. This isn’t data from Github, but works for demonstration purposes. +. Open the *[Logs] Web traffic* dashboard. . In the toolbar, click *Edit*. . Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. -.. In the *Name* field, enter `Show on Github`. +. Click *Go to URL*. + +.. Give the drilldown a name. For example, `Show on Github`. -.. Select *Go to URL*. +.. For the *Trigger*, select *Single click*. -.. Enter the URL template: +.. To navigate to the {kib} repository Github issues, enter the following in the *Enter URL* field: + [source, bash] ---- https://github.com/elastic/kibana/issues?q=is:issue+is:open+{{event.value}} ---- + -The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. -+ -[role="screenshot"] -image:images/url_drilldown_url_template.png[URL template input] +`{{event.value}}` is substituted with a value associated with a selected pie slice. .. Click *Create drilldown*. -+ -The drilldown is stored as dashboard metadata. . Save the dashboard. -+ -If you fail to save the dashboard, the drilldown is lost when you navigate away from the dashboard. . On the *[Logs] Visitors by OS* panel, click any chart slice, then select *Show on Github*. + [role="screenshot"] image:images/url_drilldown_popup.png[URL drilldown popup] -. On the page that lists the issues in the {kib} repository, verify the slice value appears in Github. +. In the list of {kib} repository issues, verify that the slice value appears. + [role="screenshot"] image:images/url_drilldown_github.png[Github] +[float] +[[manage-drilldowns]] +=== Manage drilldowns + +Make changes to your drilldowns, make a copy of your drilldowns for another panel, and delete drilldowns. + +. Open the panel menu that includes the drilldown, then click *Manage drilldowns*. + +. On the *Manage* tab, use the following options: + +* To change drilldowns, click *Edit* next to the drilldown you want to change, make your changes, then click *Save*. + +* To make a copy, click *Copy* next to the drilldown you want to change, enter the drilldown name, then click *Create drilldown*. + +* To delete a drilldown, select the drilldown you want to delete, then click *Delete*. + include::url-drilldown.asciidoc[] diff --git a/package.json b/package.json index b21ad0021656e..9d42daa63ede8 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "dependencies": { "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "28.2.0", + "@elastic/charts": "29.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", @@ -109,7 +109,7 @@ "@elastic/numeral": "^2.5.0", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", - "@elastic/safer-lodash-set": "link:packages/elastic-safer-lodash-set", + "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module", "@elastic/search-ui-app-search-connector": "^1.5.0", "@elastic/ui-ace": "0.2.3", "@hapi/boom": "^9.1.1", @@ -123,7 +123,7 @@ "@hapi/wreck": "^17.1.0", "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", - "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", + "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module", "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", @@ -380,7 +380,7 @@ "tar": "4.4.13", "tinycolor2": "1.4.1", "tinygradient": "0.4.3", - "topojson-client": "3.0.0", + "topojson-client": "3.1.0", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", @@ -437,7 +437,7 @@ "@elastic/makelogs": "^6.0.0", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.6.2", - "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", + "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", "@kbn/dev-utils": "link:packages/kbn-dev-utils", @@ -446,7 +446,7 @@ "@kbn/es-archiver": "link:packages/kbn-es-archiver", "@kbn/eslint-import-resolver-kibana": "link:packages/kbn-eslint-import-resolver-kibana", "@kbn/eslint-plugin-eslint": "link:packages/kbn-eslint-plugin-eslint", - "@kbn/expect": "link:packages/kbn-expect", + "@kbn/expect": "link:bazel-bin/packages/kbn-expect/npm_module", "@kbn/optimizer": "link:packages/kbn-optimizer", "@kbn/plugin-generator": "link:packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 5c3172a6c636a..a355d537f06a1 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -4,9 +4,13 @@ filegroup( name = "build", srcs = [ "//packages/elastic-datemath:build", + "//packages/elastic-safer-lodash-set:build", + "//packages/kbn-apm-config-loader:build", "//packages/kbn-apm-utils:build", + "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", + "//packages/kbn-expect:build", "//packages/kbn-std:build", "//packages/kbn-tinymath:build", "//packages/kbn-utility-types:build", diff --git a/packages/elastic-datemath/BUILD.bazel b/packages/elastic-datemath/BUILD.bazel index bc0c1412ef5f1..f3eb4548088cb 100644 --- a/packages/elastic-datemath/BUILD.bazel +++ b/packages/elastic-datemath/BUILD.bazel @@ -54,7 +54,7 @@ ts_project( js_library( name = PKG_BASE_NAME, - srcs = [], + srcs = NPM_MODULE_EXTRA_FILES, deps = [":tsc"] + DEPS, package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], @@ -62,7 +62,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/packages/elastic-safer-lodash-set/BUILD.bazel b/packages/elastic-safer-lodash-set/BUILD.bazel new file mode 100644 index 0000000000000..cba719ee4f0ef --- /dev/null +++ b/packages/elastic-safer-lodash-set/BUILD.bazel @@ -0,0 +1,65 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "elastic-safer-lodash-set" +PKG_REQUIRE_NAME = "@elastic/safer-lodash-set" + +SOURCE_FILES = glob( + [ + "fp/**/*", + "lodash/**/*", + "index.js", + "set.js", + "setWith.js", + ], + exclude = [ + "**/*.d.ts" + ], +) + +TYPE_FILES = glob([ + "fp/**/*.d.ts", + "index.d.ts", + "set.d.ts", + "setWith.d.ts", +]) + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "@npm//lodash", +] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/elastic-safer-lodash-set/tsconfig.json b/packages/elastic-safer-lodash-set/tsconfig.json index 6517e5c60ee01..5a29c6ff2dd88 100644 --- a/packages/elastic-safer-lodash-set/tsconfig.json +++ b/packages/elastic-safer-lodash-set/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/elastic-safer-lodash-set" + "incremental": false, }, "include": [ "**/*", diff --git a/packages/kbn-analytics/src/reporter.ts b/packages/kbn-analytics/src/reporter.ts index 44e6758eb4643..c7c9cf1541d21 100644 --- a/packages/kbn-analytics/src/reporter.ts +++ b/packages/kbn-analytics/src/reporter.ts @@ -68,7 +68,7 @@ export class Reporter { } }; - private log(message: any) { + private log(message: unknown) { if (this.debug) { // eslint-disable-next-line console.debug(message); diff --git a/packages/kbn-analytics/src/storage.ts b/packages/kbn-analytics/src/storage.ts index b080a53029724..ac1084e807fc7 100644 --- a/packages/kbn-analytics/src/storage.ts +++ b/packages/kbn-analytics/src/storage.ts @@ -8,10 +8,10 @@ import { Report } from './report'; -export interface Storage { - get: (key: string) => T | null; +export interface Storage { + get: (key: string) => T | undefined; set: (key: string, value: T) => S; - remove: (key: string) => T | null; + remove: (key: string) => T | undefined; clear: () => void; } diff --git a/packages/kbn-analytics/src/util.ts b/packages/kbn-analytics/src/util.ts index 96e18c43e104f..b3768b4df94b8 100644 --- a/packages/kbn-analytics/src/util.ts +++ b/packages/kbn-analytics/src/util.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export function wrapArray(subj: T | T[]): T[] { +export function wrapArray(subj: T | T[]): T[] { return Array.isArray(subj) ? subj : [subj]; } diff --git a/packages/kbn-apm-config-loader/BUILD.bazel b/packages/kbn-apm-config-loader/BUILD.bazel new file mode 100644 index 0000000000000..58a86ccfcf018 --- /dev/null +++ b/packages/kbn-apm-config-loader/BUILD.bazel @@ -0,0 +1,87 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-apm-config-loader" +PKG_REQUIRE_NAME = "@kbn/apm-config-loader" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/elastic-safer-lodash-set", + "//packages/kbn-utils", + "@npm//js-yaml", + "@npm//lodash", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/js-yaml", + "@npm//@types/lodash", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-apm-config-loader/package.json b/packages/kbn-apm-config-loader/package.json index d198ee57c619d..c096ed2efb92a 100644 --- a/packages/kbn-apm-config-loader/package.json +++ b/packages/kbn-apm-config-loader/package.json @@ -4,13 +4,5 @@ "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - }, - "dependencies": { - "@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set" - } + "private": true } \ No newline at end of file diff --git a/packages/kbn-apm-config-loader/tsconfig.json b/packages/kbn-apm-config-loader/tsconfig.json index 250195785b931..aa34b05061600 100644 --- a/packages/kbn-apm-config-loader/tsconfig.json +++ b/packages/kbn-apm-config-loader/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-apm-config-loader/src", "types": [ diff --git a/packages/kbn-apm-utils/BUILD.bazel b/packages/kbn-apm-utils/BUILD.bazel index 63adf2b77b516..335494bea45f0 100644 --- a/packages/kbn-apm-utils/BUILD.bazel +++ b/packages/kbn-apm-utils/BUILD.bazel @@ -53,7 +53,7 @@ ts_project( js_library( name = PKG_BASE_NAME, - srcs = [], + srcs = NPM_MODULE_EXTRA_FILES, deps = [":tsc"] + DEPS, package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], @@ -61,7 +61,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/packages/kbn-babel-code-parser/BUILD.bazel b/packages/kbn-babel-code-parser/BUILD.bazel new file mode 100644 index 0000000000000..3c811f0bd09f5 --- /dev/null +++ b/packages/kbn-babel-code-parser/BUILD.bazel @@ -0,0 +1,71 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("@npm//@babel/cli:index.bzl", "babel") + +PKG_BASE_NAME = "kbn-babel-code-parser" +PKG_REQUIRE_NAME = "@kbn/babel-code-parser" + +SOURCE_FILES = glob( + [ + "src/**/*", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "//packages/kbn-babel-preset", + "@npm//@babel/parser", + "@npm//@babel/traverse", + "@npm//lodash", +] + +babel( + name = "target", + data = [ + ":srcs", + ".babelrc", + ] + DEPS, + output_dir = True, + args = [ + "./%s/src" % package_name(), + "--out-dir", + "$(@D)", + "--quiet" + ], +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":target"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-babel-code-parser/package.json b/packages/kbn-babel-code-parser/package.json index a5e05da6f8ee4..da55565c6076c 100755 --- a/packages/kbn-babel-code-parser/package.json +++ b/packages/kbn-babel-code-parser/package.json @@ -8,10 +8,5 @@ "repository": { "type": "git", "url": "https://github.com/elastic/kibana/tree/master/packages/kbn-babel-code-parser" - }, - "scripts": { - "build": "../../node_modules/.bin/babel src --out-dir target", - "kbn:bootstrap": "yarn build --quiet", - "kbn:watch": "yarn build --watch" } } diff --git a/packages/kbn-babel-preset/BUILD.bazel b/packages/kbn-babel-preset/BUILD.bazel index 13542ed6e73ad..06b788010bdf5 100644 --- a/packages/kbn-babel-preset/BUILD.bazel +++ b/packages/kbn-babel-preset/BUILD.bazel @@ -38,7 +38,7 @@ DEPS = [ js_library( name = PKG_BASE_NAME, - srcs = [ + srcs = NPM_MODULE_EXTRA_FILES + [ ":srcs", ], deps = DEPS, @@ -48,7 +48,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts index ff25f2a7bf55e..2fd53dd83a1bd 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts @@ -42,21 +42,25 @@ it('produces the right watch and ignore list', () => { /\\\\\\.\\(md\\|sh\\|txt\\)\\$/, /debug\\\\\\.log\\$/, /src/plugins/*/test/**, + /src/plugins/*/integration_tests/**, /src/plugins/*/build/**, /src/plugins/*/target/**, /src/plugins/*/scripts/**, /src/plugins/*/docs/**, /test/plugin_functional/plugins/*/test/**, + /test/plugin_functional/plugins/*/integration_tests/**, /test/plugin_functional/plugins/*/build/**, /test/plugin_functional/plugins/*/target/**, /test/plugin_functional/plugins/*/scripts/**, /test/plugin_functional/plugins/*/docs/**, /x-pack/plugins/*/test/**, + /x-pack/plugins/*/integration_tests/**, /x-pack/plugins/*/build/**, /x-pack/plugins/*/target/**, /x-pack/plugins/*/scripts/**, /x-pack/plugins/*/docs/**, /x-pack/test/plugin_functional/plugins/resolver_test/test/**, + /x-pack/test/plugin_functional/plugins/resolver_test/integration_tests/**, /x-pack/test/plugin_functional/plugins/resolver_test/build/**, /x-pack/test/plugin_functional/plugins/resolver_test/target/**, /x-pack/test/plugin_functional/plugins/resolver_test/scripts/**, diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index 53aa53b5aa63a..4a9dae5c6fee2 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -28,6 +28,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { (acc: string[], path) => [ ...acc, Path.resolve(path, 'test/**'), + Path.resolve(path, 'integration_tests/**'), Path.resolve(path, 'build/**'), Path.resolve(path, 'target/**'), Path.resolve(path, 'scripts/**'), diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index 9bf491e300871..1611da9aa60d4 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -10,7 +10,6 @@ "kbn:bootstrap": "yarn build" }, "dependencies": { - "@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set", "@kbn/logging": "link:../kbn-logging" }, "devDependencies": { diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 236d5cf252136..c55e5d3513c44 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -246,7 +246,10 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const esArgs = ['action.destructive_requires_name=true'].concat(options.esArgs || []); + const esArgs = [ + 'action.destructive_requires_name=true', + 'ingest.geoip.downloader.enabled=false', + ].concat(options.esArgs || []); // Add to esArgs if ssl is enabled if (this._ssl) { @@ -272,7 +275,7 @@ exports.Cluster = class Cluster { // especially because we currently run many instances of ES on the same machine during CI options.esEnvVars.ES_JAVA_OPTS = (options.esEnvVars.ES_JAVA_OPTS ? `${options.esEnvVars.ES_JAVA_OPTS} ` : '') + - '-Xms2g -Xmx2g'; + '-Xms1g -Xmx1g'; this._process = execa(ES_BIN, args, { cwd: installPath, diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index 9a9ffb2afc331..6b4025840283f 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -266,6 +266,7 @@ describe('#start(installPath)', () => { Array [ Array [ "action.destructive_requires_name=true", + "ingest.geoip.downloader.enabled=false", ], undefined, Object { @@ -344,6 +345,7 @@ describe('#run()', () => { Array [ Array [ "action.destructive_requires_name=true", + "ingest.geoip.downloader.enabled=false", ], undefined, Object { diff --git a/packages/kbn-expect/BUILD.bazel b/packages/kbn-expect/BUILD.bazel new file mode 100644 index 0000000000000..82e6200e9688a --- /dev/null +++ b/packages/kbn-expect/BUILD.bazel @@ -0,0 +1,46 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-expect" +PKG_REQUIRE_NAME = "@kbn/expect" + +SOURCE_FILES = glob([ + "expect.js", + "expect.js.d.ts", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "LICENSE.txt", + "package.json", + "README.md", +] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index ae7e9ff090cc2..7baae093bc3a9 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-expect" + "incremental": false, }, "include": [ "expect.js.d.ts" diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 1d19387494136..95bf3f8f251b7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -9,7 +9,7 @@ pageLoadAssetSize: charts: 195358 cloud: 21076 console: 46091 - core: 413500 + core: 414000 crossClusterReplication: 65408 dashboard: 374194 dashboardEnhanced: 65646 @@ -92,7 +92,7 @@ pageLoadAssetSize: visTypeTable: 94934 visTypeTagcloud: 37575 visTypeTimelion: 68883 - visTypeTimeseries: 155203 + visTypeTimeseries: 55203 visTypeVega: 153573 visTypeVislib: 242838 visTypeXy: 113478 diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js index a43d3a09c7d70..f92d01d6454d5 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js @@ -38,7 +38,7 @@ export async function runKibanaServer({ procs, config, options }) { ...extendNodeOptions(installDir), }, cwd: installDir || KIBANA_ROOT, - wait: /http server running/, + wait: /\[Kibana\]\[http\] http server running/, }); } diff --git a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts index 31cd3a6899568..af75137d148e9 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts @@ -19,6 +19,10 @@ const isConcliftOnGetError = (error: any) => { ); }; +const isIgnorableError = (error: any, ignorableErrors: number[] = []) => { + return isAxiosResponseError(error) && ignorableErrors.includes(error.response.status); +}; + export const uriencode = ( strings: TemplateStringsArray, ...values: Array @@ -53,6 +57,7 @@ export interface ReqOptions { body?: any; retries?: number; headers?: Record; + ignoreErrors?: number[]; responseType?: ResponseType; } @@ -125,6 +130,10 @@ export class KbnClientRequester { const requestedRetries = options.retries !== undefined; const failedToGetResponse = isAxiosRequestError(error); + if (isIgnorableError(error, options.ignoreErrors)) { + return error.response; + } + let errorMessage; if (conflictOnGet) { errorMessage = `Conflict on GET (path=${options.path}, attempt=${attempt}/${maxAttempts})`; diff --git a/packages/kbn-test/src/kbn_client/kbn_client_status.ts b/packages/kbn-test/src/kbn_client/kbn_client_status.ts index 7e14e58309fa2..26c46917ae8dd 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_status.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_status.ts @@ -44,6 +44,8 @@ export class KbnClientStatus { const { data } = await this.requester.request({ method: 'GET', path: 'api/status', + // Status endpoint returns 503 if any services are in an unavailable state + ignoreErrors: [503], }); return data; } diff --git a/packages/kbn-tinymath/BUILD.bazel b/packages/kbn-tinymath/BUILD.bazel index ae029c88774e8..2596a30ea2efa 100644 --- a/packages/kbn-tinymath/BUILD.bazel +++ b/packages/kbn-tinymath/BUILD.bazel @@ -45,7 +45,7 @@ peggy( js_library( name = PKG_BASE_NAME, - srcs = [ + srcs = NPM_MODULE_EXTRA_FILES + [ ":srcs", ":grammar" ], @@ -56,7 +56,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/src/core/public/chrome/ui/header/_banner.scss b/src/core/public/chrome/ui/header/_banner.scss index 5bb70b8e53321..41ec7b08c6c04 100644 --- a/src/core/public/chrome/ui/header/_banner.scss +++ b/src/core/public/chrome/ui/header/_banner.scss @@ -1,6 +1,7 @@ .header__topBanner { position: fixed; top: 0; + left: 0; height: $kbnHeaderBannerHeight; width: 100%; z-index: $euiZHeader; diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index f2979d06338f1..1c4e78f0a5c2e 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -199,7 +199,7 @@ describe('#start()', () => { root.innerHTML = '

foo bar

'; await startCore(root); expect(root.innerHTML).toMatchInlineSnapshot( - `"
"` + `"
"` ); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index b68a7ced118d2..f0ea1e62fc33f 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -176,6 +176,7 @@ export class CoreSystem { const coreUiTargetDomElement = document.createElement('div'); coreUiTargetDomElement.id = 'kibana-body'; + coreUiTargetDomElement.dataset.testSubj = 'kibanaChrome'; const notificationsTargetDomElement = document.createElement('div'); const overlayTargetDomElement = document.createElement('div'); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ca432d6b8269f..17ba37d075b78 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -74,7 +74,7 @@ export type { DomainDeprecationDetails, } from '../server/types'; export type { CoreContext, CoreSystem } from './core_system'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_APP_CATEGORIES, APP_WRAPPER_CLASS } from '../utils'; export type { AppCategory, UiSettingsParams, diff --git a/src/core/public/overlays/banners/_banners_list.scss b/src/core/public/overlays/banners/_banners_list.scss index 9d4df065a0a4f..3d10a71c84a95 100644 --- a/src/core/public/overlays/banners/_banners_list.scss +++ b/src/core/public/overlays/banners/_banners_list.scss @@ -1,7 +1,3 @@ -.kbnGlobalBannerList { - padding: $euiSize; -} - .kbnGlobalBannerList__item + .kbnGlobalBannerList__item { margin-top: $euiSizeS; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b3ded52a98171..1f502007f51dd 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -73,6 +73,9 @@ export interface App { updater$?: Observable; } +// @public +export const APP_WRAPPER_CLASS = "kbnAppWrapper"; + // @public export interface AppCategory { ariaLabel?: string; @@ -908,11 +911,6 @@ export interface IUiSettingsClient { get$: (key: string, defaultOverride?: T) => Observable; get: (key: string, defaultOverride?: T) => T; getAll: () => Readonly>; - getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; getUpdate$: () => Observable<{ key: string; newValue: T; @@ -923,7 +921,6 @@ export interface IUiSettingsClient { isDeclared: (key: string) => boolean; isDefault: (key: string) => boolean; isOverridden: (key: string) => boolean; - overrideLocalDefault: (key: string, newDefault: any) => void; remove: (key: string) => Promise; set: (key: string, value: any) => Promise; } diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index ed2d9bc0b3917..936b41e7682bb 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -1,16 +1,20 @@ @import '../mixins'; /** - * stretch the root element of the Kibana application to set the base-size that + * Stretch the root element of the Kibana application to set the base-size that * flexed children should keep. Only works when paired with root styles applied * by core service from new platform */ -// SASSTODO: Naming here is too embedded and high up that changing them could cause major breaks + #kibana-body { - overflow-x: hidden; + // DO NOT ADD ANY OVERFLOW BEHAVIORS HERE + // It will break the sticky navigation min-height: 100%; + display: flex; + flex-direction: column; } +// Affixes a div to restrict the position of charts tooltip to the visible viewport minus the header #app-fixed-viewport { pointer-events: none; visibility: hidden; @@ -21,26 +25,17 @@ left: 0; } -.app-wrapper { +.kbnAppWrapper { + // DO NOT ADD ANY OTHER STYLES TO THIS SELECTOR + // This a very nested dependency happnening in "all" apps display: flex; flex-flow: column nowrap; - margin: 0 auto; - - @include kibanaFullBodyMinHeight(); -} - -.app-wrapper-panel { - display: flex; flex-grow: 1; - flex-shrink: 0; - flex-basis: auto; - flex-direction: column; - - > * { - flex-shrink: 0; - } + z-index: 0; // This effectively puts every high z-index inside the scope of this wrapper to it doesn't interfere with the header and/or overlay mask + position: relative; // This is temporary for apps that relied on this being present on `.application` } +// TODO: This is problematic because it doesn't stay in line with EUI: // adapted from euiHeaderAffordForFixed as we need to handle the top banner @mixin kbnAffordForHeader($headerHeight) { padding-top: $headerHeight; diff --git a/src/core/public/rendering/app_containers.test.tsx b/src/core/public/rendering/app_containers.test.tsx index 9ef01258509cb..193e393f268f0 100644 --- a/src/core/public/rendering/app_containers.test.tsx +++ b/src/core/public/rendering/app_containers.test.tsx @@ -6,21 +6,25 @@ * Side Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import React from 'react'; -import { AppWrapper, AppContainer } from './app_containers'; +import { AppWrapper } from './app_containers'; describe('AppWrapper', () => { it('toggles the `hidden-chrome` class depending on the chrome visibility state', () => { const chromeVisible$ = new BehaviorSubject(true); - const component = mount(app-content); + const component = mount( + + app-content + + ); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -30,7 +34,7 @@ describe('AppWrapper', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -40,22 +44,25 @@ describe('AppWrapper', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
`); }); -}); -describe('AppContainer', () => { it('adds classes supplied by chrome', () => { + const chromeVisible$ = new BehaviorSubject(true); const appClasses$ = new BehaviorSubject([]); - const component = mount(app-content); + const component = mount( + + app-content + + ); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -65,7 +72,7 @@ describe('AppContainer', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -75,7 +82,7 @@ describe('AppContainer', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -85,7 +92,7 @@ describe('AppContainer', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
diff --git a/src/core/public/rendering/app_containers.tsx b/src/core/public/rendering/app_containers.tsx index 0d715a6752694..64d64d2caad75 100644 --- a/src/core/public/rendering/app_containers.tsx +++ b/src/core/public/rendering/app_containers.tsx @@ -10,17 +10,23 @@ import React from 'react'; import { Observable } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import classNames from 'classnames'; +import { APP_WRAPPER_CLASS } from '../../utils'; export const AppWrapper: React.FunctionComponent<{ chromeVisible$: Observable; -}> = ({ chromeVisible$, children }) => { - const visible = useObservable(chromeVisible$); - return
{children}
; -}; - -export const AppContainer: React.FunctionComponent<{ classes$: Observable; -}> = ({ classes$, children }) => { - const classes = useObservable(classes$); - return
{children}
; +}> = ({ chromeVisible$, classes$, children }) => { + const visible = useObservable(chromeVisible$); + const classes = useObservable(classes$, ['']); + return ( +
+ {children} +
+ ); }; diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index d293e2d44ba6a..d9eb764fc9f0d 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -13,7 +13,7 @@ import { RenderingService } from './rendering_service'; import { applicationServiceMock } from '../application/application_service.mock'; import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; describe('RenderingService#start', () => { let application: ReturnType; @@ -28,6 +28,7 @@ describe('RenderingService#start', () => { chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); + chrome.getApplicationClasses$.mockReturnValue(of([])); overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); @@ -48,54 +49,58 @@ describe('RenderingService#start', () => { it('renders application service into provided DOM element', () => { startService(); - expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` -
-
- Hello application! -
-
- `); + expect(targetDomElement.querySelector('div.kbnAppWrapper')).toMatchInlineSnapshot(` +
+
+
+ Hello application! +
+
+ `); }); - it('adds the `chrome-hidden` class to the AppWrapper when chrome is hidden', () => { + it('adds the `kbnAppWrapper--hiddenChrome` class to the AppWrapper when chrome is hidden', () => { const isVisible$ = new BehaviorSubject(true); chrome.getIsVisible$.mockReturnValue(isVisible$); startService(); - const appWrapper = targetDomElement.querySelector('div.app-wrapper')!; - expect(appWrapper.className).toEqual('app-wrapper'); + const appWrapper = targetDomElement.querySelector('div.kbnAppWrapper')!; + expect(appWrapper.className).toEqual('kbnAppWrapper'); act(() => isVisible$.next(false)); - expect(appWrapper.className).toEqual('app-wrapper hidden-chrome'); + expect(appWrapper.className).toEqual('kbnAppWrapper kbnAppWrapper--hiddenChrome'); act(() => isVisible$.next(true)); - expect(appWrapper.className).toEqual('app-wrapper'); + expect(appWrapper.className).toEqual('kbnAppWrapper'); }); - it('adds the application classes to the AppContainer', () => { + it('adds the application classes to the AppWrapper', () => { const applicationClasses$ = new BehaviorSubject([]); + const isVisible$ = new BehaviorSubject(true); + chrome.getIsVisible$.mockReturnValue(isVisible$); chrome.getApplicationClasses$.mockReturnValue(applicationClasses$); startService(); - const appContainer = targetDomElement.querySelector('div.application')!; - expect(appContainer.className).toEqual('application'); + const appContainer = targetDomElement.querySelector('div.kbnAppWrapper')!; + expect(appContainer.className).toEqual('kbnAppWrapper'); act(() => applicationClasses$.next(['classA', 'classB'])); - expect(appContainer.className).toEqual('application classA classB'); + expect(appContainer.className).toEqual('kbnAppWrapper classA classB'); act(() => applicationClasses$.next(['classC'])); - expect(appContainer.className).toEqual('application classC'); + expect(appContainer.className).toEqual('kbnAppWrapper classC'); act(() => applicationClasses$.next([])); - expect(appContainer.className).toEqual('application'); + expect(appContainer.className).toEqual('kbnAppWrapper'); }); it('contains wrapper divs', () => { startService(); - expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); - expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); + expect(targetDomElement.querySelector('div.kbnAppWrapper')).toBeDefined(); }); it('renders the banner UI', () => { diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 787fa475c7d5f..1dfb4259d7d70 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -14,7 +14,7 @@ import { pairwise, startWith } from 'rxjs/operators'; import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; import { OverlayStart } from '../overlays'; -import { AppWrapper, AppContainer } from './app_containers'; +import { AppWrapper } from './app_containers'; interface StartDeps { application: InternalApplicationStart; @@ -48,16 +48,25 @@ export class RenderingService { ReactDOM.render( -
+ <> + {/* Fixed headers */} {chromeHeader} - -
-
-
{bannerComponent}
- {appComponent} -
+ + {/* banners$.subscribe() for things like the No data banner */} +
{bannerComponent}
+ + {/* The App Wrapper outside of the fixed headers that accepts custom class names from apps */} + + {/* Affixes a div to restrict the position of charts tooltip to the visible viewport minus the header */} +
+ + {/* The actual plugin/app */} + {appComponent} -
+ , targetDomElement ); diff --git a/src/core/public/styles/_ace_overrides.scss b/src/core/public/styles/_ace_overrides.scss index 30acdbbc80975..ca5230b46acd3 100644 --- a/src/core/public/styles/_ace_overrides.scss +++ b/src/core/public/styles/_ace_overrides.scss @@ -6,7 +6,7 @@ // In order to override the TM (Textmate) theme of Ace/Brace, everywhere, // it is being scoped by a known outer selector -.application { +.kbnBody { .ace-tm { $aceBackground: tintOrShade($euiColorLightShade, 50%, 0); diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index bfb07c1b51427..46f46b469783b 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -5,29 +5,6 @@ // Grab some nav-specific EUI vars @import '@elastic/eui/src/components/collapsible_nav/variables'; -// Application Layout - -.application, -.app-container { - > * { - position: relative; - } -} - -.application { - position: relative; - z-index: 0; - display: flex; - flex-grow: 1; - flex-shrink: 0; - flex-basis: auto; - flex-direction: column; - - > * { - flex-shrink: 0; - } -} - // We apply brute force focus states to anything not coming from Eui // which has focus states designed at the component level. // You can also use "kbn-resetFocusState" to not apply the default focus diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap index cd233704d2f54..b9526f26a0c1e 100644 --- a/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap @@ -44,81 +44,6 @@ Array [ ] `; -exports[`#overrideLocalDefault key has no user value calls subscriber with new and previous value: single subscriber call 1`] = ` -Array [ - Array [ - Object { - "key": "dateFormat", - "newValue": "bar", - "oldValue": "Browser", - }, - ], -] -`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the default value returned by get(): get after override 1`] = `"bar"`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the default value returned by get(): get before override 1`] = `"Browser"`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the value returned by getAll(): getAll after override 1`] = ` -Object { - "dateFormat": Object { - "value": "bar", - }, -} -`; - -exports[`#overrideLocalDefault key has no user value synchronously modifies the value returned by getAll(): getAll before override 1`] = ` -Object { - "dateFormat": Object { - "value": "Browser", - }, -} -`; - -exports[`#overrideLocalDefault key with user value does not modify the return value of get: get after override 1`] = `"foo"`; - -exports[`#overrideLocalDefault key with user value does not modify the return value of get: get before override 1`] = `"foo"`; - -exports[`#overrideLocalDefault key with user value is included in the return value of getAll: getAll after override 1`] = ` -Object { - "dateFormat": Object { - "userValue": "foo", - "value": "bar", - }, -} -`; - -exports[`#overrideLocalDefault key with user value is included in the return value of getAll: getAll before override 1`] = ` -Object { - "dateFormat": Object { - "userValue": "foo", - "value": "Browser", - }, -} -`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: get after override 1`] = `"bar"`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: get before override 1`] = `"foo"`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: getAll after override 1`] = ` -Object { - "dateFormat": Object { - "value": "bar", - }, -} -`; - -exports[`#overrideLocalDefault key with user value returns default override when setting removed: getAll before override 1`] = ` -Object { - "dateFormat": Object { - "userValue": "foo", - "value": "bar", - }, -} -`; - exports[`#remove throws an error if key is overridden 1`] = `"Unable to update \\"bar\\" because its value is overridden by the Kibana server"`; exports[`#set throws an error if key is overridden 1`] = `"Unable to update \\"foo\\" because its value is overridden by the Kibana server"`; diff --git a/src/core/public/ui_settings/types.ts b/src/core/public/ui_settings/types.ts index 4ae09094861e4..77c1feb25b6bc 100644 --- a/src/core/public/ui_settings/types.ts +++ b/src/core/public/ui_settings/types.ts @@ -53,12 +53,6 @@ export interface IUiSettingsClient { */ set: (key: string, value: any) => Promise; - /** - * Overrides the default value for a setting in this specific browser tab. If the page - * is reloaded the default override is lost. - */ - overrideLocalDefault: (key: string, newDefault: any) => void; - /** * Removes the user-defined value for a setting, causing it to revert to the default. This * method behaves the same as calling `set(key, null)`, including the synchronization, custom @@ -99,16 +93,6 @@ export interface IUiSettingsClient { oldValue: T; }>; - /** - * Returns an Observable that notifies subscribers of each update to the uiSettings, - * including the key, newValue, and oldValue of the setting that changed. - */ - getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; - /** * Returns an Observable that notifies subscribers of each error while trying to update * the settings, containing the actual Error class. diff --git a/src/core/public/ui_settings/ui_settings_client.test.ts b/src/core/public/ui_settings/ui_settings_client.test.ts index 40e04a46c0001..f8c5dbfc347dd 100644 --- a/src/core/public/ui_settings/ui_settings_client.test.ts +++ b/src/core/public/ui_settings/ui_settings_client.test.ts @@ -279,119 +279,3 @@ describe('#getUpdate$', () => { expect(onComplete).toHaveBeenCalled(); }); }); - -describe('#overrideLocalDefault', () => { - describe('key has no user value', () => { - it('synchronously modifies the default value returned by get()', () => { - const { client } = setup(); - - expect(client.get('dateFormat')).toMatchSnapshot('get before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.get('dateFormat')).toMatchSnapshot('get after override'); - }); - - it('synchronously modifies the value returned by getAll()', () => { - const { client } = setup(); - - expect(client.getAll()).toMatchSnapshot('getAll before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.getAll()).toMatchSnapshot('getAll after override'); - }); - - it('calls subscriber with new and previous value', () => { - const handler = jest.fn(); - const { client } = setup(); - - client.getUpdate$().subscribe(handler); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(handler.mock.calls).toMatchSnapshot('single subscriber call'); - }); - }); - - describe('key with user value', () => { - it('does not modify the return value of get', () => { - const { client } = setup(); - - client.set('dateFormat', 'foo'); - expect(client.get('dateFormat')).toMatchSnapshot('get before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.get('dateFormat')).toMatchSnapshot('get after override'); - }); - - it('is included in the return value of getAll', () => { - const { client } = setup(); - - client.set('dateFormat', 'foo'); - expect(client.getAll()).toMatchSnapshot('getAll before override'); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(client.getAll()).toMatchSnapshot('getAll after override'); - }); - - it('does not call subscriber', () => { - const handler = jest.fn(); - const { client } = setup(); - - client.set('dateFormat', 'foo'); - client.getUpdate$().subscribe(handler); - client.overrideLocalDefault('dateFormat', 'bar'); - expect(handler).not.toHaveBeenCalled(); - }); - - it('returns default override when setting removed', () => { - const { client } = setup(); - - client.set('dateFormat', 'foo'); - client.overrideLocalDefault('dateFormat', 'bar'); - - expect(client.get('dateFormat')).toMatchSnapshot('get before override'); - expect(client.getAll()).toMatchSnapshot('getAll before override'); - - client.remove('dateFormat'); - - expect(client.get('dateFormat')).toMatchSnapshot('get after override'); - expect(client.getAll()).toMatchSnapshot('getAll after override'); - }); - }); - - describe('#isOverridden()', () => { - it('returns false if key is unknown', () => { - const { client } = setup(); - expect(client.isOverridden('foo')).toBe(false); - }); - - it('returns false if key is no overridden', () => { - const { client } = setup({ - initialSettings: { - foo: { - userValue: 1, - }, - bar: { - isOverridden: true, - userValue: 2, - }, - }, - }); - expect(client.isOverridden('foo')).toBe(false); - }); - - it('returns true when key is overridden', () => { - const { client } = setup({ - initialSettings: { - foo: { - userValue: 1, - }, - bar: { - isOverridden: true, - userValue: 2, - }, - }, - }); - expect(client.isOverridden('bar')).toBe(true); - }); - - it('returns false for object prototype properties', () => { - const { client } = setup(); - expect(client.isOverridden('hasOwnProperty')).toBe(false); - }); - }); -}); diff --git a/src/core/public/ui_settings/ui_settings_client.ts b/src/core/public/ui_settings/ui_settings_client.ts index ab7c91803549b..ee5d5da8d29b9 100644 --- a/src/core/public/ui_settings/ui_settings_client.ts +++ b/src/core/public/ui_settings/ui_settings_client.ts @@ -24,7 +24,6 @@ interface UiSettingsClientParams { export class UiSettingsClient implements IUiSettingsClient { private readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>(); - private readonly saved$ = new Subject<{ key: string; newValue: any; oldValue: any }>(); private readonly updateErrors$ = new Subject(); private readonly api: UiSettingsApi; @@ -39,7 +38,6 @@ export class UiSettingsClient implements IUiSettingsClient { params.done$.subscribe({ complete: () => { this.update$.complete(); - this.saved$.complete(); this.updateErrors$.complete(); }, }); @@ -116,37 +114,10 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r return this.isDeclared(key) && Boolean(this.cache[key].isOverridden); } - overrideLocalDefault(key: string, newDefault: any) { - // capture the previous value - const prevDefault = this.defaults[key] ? this.defaults[key].value : undefined; - - // update defaults map - this.defaults[key] = { - ...(this.defaults[key] || {}), - value: newDefault, - }; - - // update cached default value - this.cache[key] = { - ...(this.cache[key] || {}), - value: newDefault, - }; - - // don't broadcast change if userValue was already overriding the default - if (this.cache[key].userValue == null) { - this.update$.next({ key, newValue: newDefault, oldValue: prevDefault }); - this.saved$.next({ key, newValue: newDefault, oldValue: prevDefault }); - } - } - getUpdate$() { return this.update$.asObservable(); } - getSaved$() { - return this.saved$.asObservable(); - } - getUpdateErrors$() { return this.updateErrors$.asObservable(); } @@ -178,7 +149,6 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r try { const { settings } = await this.api.batchSet(key, newVal); this.cache = defaultsDeep({}, defaults, settings); - this.saved$.next({ key, newValue: newVal, oldValue: initialVal }); return true; } catch (error) { this.setLocally(key, initialVal); diff --git a/src/core/public/ui_settings/ui_settings_service.mock.ts b/src/core/public/ui_settings/ui_settings_service.mock.ts index 1222fc2a685de..72f03be415475 100644 --- a/src/core/public/ui_settings/ui_settings_service.mock.ts +++ b/src/core/public/ui_settings/ui_settings_service.mock.ts @@ -22,14 +22,11 @@ const createSetupContractMock = () => { isDefault: jest.fn(), isCustom: jest.fn(), isOverridden: jest.fn(), - overrideLocalDefault: jest.fn(), getUpdate$: jest.fn(), - getSaved$: jest.fn(), getUpdateErrors$: jest.fn(), }; setupContract.get$.mockReturnValue(new Rx.Subject()); setupContract.getUpdate$.mockReturnValue(new Rx.Subject()); - setupContract.getSaved$.mockReturnValue(new Rx.Subject()); setupContract.getUpdateErrors$.mockReturnValue(new Rx.Subject()); setupContract.getAll.mockReturnValue({}); diff --git a/src/core/public/ui_settings/ui_settings_service.test.ts b/src/core/public/ui_settings/ui_settings_service.test.ts index 4e42c960bf914..9f0c6ac5fc937 100644 --- a/src/core/public/ui_settings/ui_settings_service.test.ts +++ b/src/core/public/ui_settings/ui_settings_service.test.ts @@ -34,12 +34,7 @@ describe('#stop', () => { service.stop(); await expect( - Rx.combineLatest( - client.getUpdate$(), - client.getSaved$(), - client.getUpdateErrors$(), - loadingCount$! - ).toPromise() + Rx.combineLatest(client.getUpdate$(), client.getUpdateErrors$(), loadingCount$!).toPromise() ).resolves.toBe(undefined); }); }); diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts index a2267635e86f2..18a5eceb1b2d3 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -46,22 +46,22 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot const root = new Root(rawConfigService, env, onRootShutdown); - process.on('SIGHUP', () => reloadLoggingConfig()); + process.on('SIGHUP', () => reloadConfiguration()); // This is only used by the LogRotator service // in order to be able to reload the log configuration // under the cluster mode process.on('message', (msg) => { - if (!msg || msg.reloadLoggingConfig !== true) { + if (!msg || msg.reloadConfiguration !== true) { return; } - reloadLoggingConfig(); + reloadConfiguration(); }); - function reloadLoggingConfig() { + function reloadConfiguration() { const cliLogger = root.logger.get('cli'); - cliLogger.info('Reloading logging configuration due to SIGHUP.', { tags: ['config'] }); + cliLogger.info('Reloading Kibana configuration due to SIGHUP.', { tags: ['config'] }); try { rawConfigService.reloadConfig(); @@ -69,7 +69,7 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot return shutdown(err); } - cliLogger.info('Reloaded logging configuration due to SIGHUP.', { tags: ['config'] }); + cliLogger.info('Reloaded Kibana configuration due to SIGHUP.', { tags: ['config'] }); } process.on('SIGINT', () => shutdown()); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 2a140388cc184..56095336d970b 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -280,6 +280,34 @@ test('accepts any type of objects for custom headers', () => { expect(() => httpSchema.validate(obj)).not.toThrow(); }); +test('forbids the "location" custom response header', () => { + const httpSchema = config.schema; + const obj = { + customResponseHeaders: { + location: 'string', + Location: 'string', + lOcAtIoN: 'string', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[customResponseHeaders]: The following custom response headers are not allowed to be set: location, Location, lOcAtIoN"` + ); +}); + +test('forbids the "refresh" custom response header', () => { + const httpSchema = config.schema; + const obj = { + customResponseHeaders: { + refresh: 'string', + Refresh: 'string', + rEfReSh: 'string', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[customResponseHeaders]: The following custom response headers are not allowed to be set: refresh, Refresh, rEfReSh"` + ); +}); + describe('with TLS', () => { test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { const httpSchema = config.schema; diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 9d0008e1c4011..1f8fd95d69051 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -26,6 +26,9 @@ const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); const match = (regex: RegExp, errorMsg: string) => (str: string) => regex.test(str) ? undefined : errorMsg; +// The lower-case set of response headers which are forbidden within `customResponseHeaders`. +const RESPONSE_HEADER_DENY_LIST = ['location', 'refresh']; + const configSchema = schema.object( { name: schema.string({ defaultValue: () => hostname() }), @@ -70,6 +73,16 @@ const configSchema = schema.object( securityResponseHeaders: securityResponseHeadersSchema, customResponseHeaders: schema.recordOf(schema.string(), schema.any(), { defaultValue: {}, + validate(value) { + const forbiddenKeys = Object.keys(value).filter((headerName) => + RESPONSE_HEADER_DENY_LIST.includes(headerName.toLowerCase()) + ); + if (forbiddenKeys.length > 0) { + return `The following custom response headers are not allowed to be set: ${forbiddenKeys.join( + ', ' + )}`; + } + }, }), host: schema.string({ defaultValue: 'localhost', diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 1a82907849cea..7624a11a6f03f 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -138,6 +138,40 @@ test('log listening address after started when configured with BasePath and rewr `); }); +test('does not allow router registration after server is listening', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouter } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouter(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouter(router2)).toThrowErrorMatchingInlineSnapshot( + `"Routers can be registered only when HTTP server is stopped."` + ); +}); + +test('allows router registration after server is listening via `registerRouterAfterListening`', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouterAfterListening } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router2)).not.toThrowError(); +}); + test('valid params', async () => { const router = new Router('/foo', logger, enhanceWithContext); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index d845ac1b639b6..8b4c3b9416152 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -33,6 +33,7 @@ import { KibanaRouteOptions, KibanaRequestState, isSafeMethod, + RouterRoute, } from './router'; import { SessionStorageCookieOptions, @@ -52,6 +53,13 @@ export interface HttpServerSetup { * @param router {@link IRouter} - a router with registered route handlers. */ registerRouter: (router: IRouter) => void; + /** + * Add all the routes registered with `router` to HTTP server request listeners. + * Unlike `registerRouter`, this function allows routes to be registered even after the server + * has started listening for requests. + * @param router {@link IRouter} - a router with registered route handlers. + */ + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; @@ -114,6 +122,17 @@ export class HttpServer { this.registeredRouters.add(router); } + private registerRouterAfterListening(router: IRouter) { + if (this.isListening()) { + for (const route of router.getRoutes()) { + this.configureRoute(route); + } + } else { + // Not listening yet, add to set of registeredRouters so that it can be added after listening has started. + this.registeredRouters.add(router); + } + } + public async setup(config: HttpConfig): Promise { const serverOptions = getServerOptions(config); const listenerOptions = getListenerOptions(config); @@ -130,6 +149,7 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), + registerRouterAfterListening: this.registerRouterAfterListening.bind(this), registerStaticDir: this.registerStaticDir.bind(this), registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), @@ -170,45 +190,7 @@ export class HttpServer { for (const router of this.registeredRouters) { for (const route of router.getRoutes()) { - this.log.debug(`registering route handler for [${route.path}]`); - // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {}, timeout } = route.options; - const { accepts: allow, maxBytes, output, parse } = body; - - const kibanaRouteOptions: KibanaRouteOptions = { - xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), - }; - - this.server.route({ - handler: route.handler, - method: route.method, - path: route.path, - options: { - auth: this.getAuthOption(authRequired), - app: kibanaRouteOptions, - tags: tags ? Array.from(tags) : undefined, - // TODO: This 'validate' section can be removed once the legacy platform is completely removed. - // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default - // validation applied in ./http_tools#getServerOptions - // (All NP routes are already required to specify their own validation in order to access the payload) - validate, - // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` - payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) - ? { - allow, - maxBytes, - output, - parse, - timeout: timeout?.payload, - multipart: true, - } - : undefined, - timeout: { - socket: timeout?.idleSocket ?? this.config!.socketTimeout, - }, - }, - }); + this.configureRoute(route); } } @@ -486,4 +468,46 @@ export class HttpServer { options: { auth: false }, }); } + + private configureRoute(route: RouterRoute) { + this.log.debug(`registering route handler for [${route.path}]`); + // Hapi does not allow payload validation to be specified for 'head' or 'get' requests + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; + const { authRequired, tags, body = {}, timeout } = route.options; + const { accepts: allow, maxBytes, output, parse } = body; + + const kibanaRouteOptions: KibanaRouteOptions = { + xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), + }; + + this.server!.route({ + handler: route.handler, + method: route.method, + path: route.path, + options: { + auth: this.getAuthOption(authRequired), + app: kibanaRouteOptions, + tags: tags ? Array.from(tags) : undefined, + // TODO: This 'validate' section can be removed once the legacy platform is completely removed. + // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default + // validation applied in ./http_tools#getServerOptions + // (All NP routes are already required to specify their own validation in order to access the payload) + validate, + // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` + payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) + ? { + allow, + maxBytes, + output, + parse, + timeout: timeout?.payload, + multipart: true, + } + : undefined, + timeout: { + socket: timeout?.idleSocket ?? this.config!.socketTimeout, + }, + }, + }); + } } diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 83279e99bc476..ebb9ad971b848 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -68,20 +68,32 @@ test('creates and sets up http server', async () => { start: jest.fn(), stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: jest.fn(), + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); expect(mockHttpServer.mock.instances.length).toBe(1); expect(httpServer.setup).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).not.toHaveBeenCalled(); await service.setup(setupDeps); expect(httpServer.setup).toHaveBeenCalled(); expect(httpServer.start).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).toHaveBeenCalled(); + expect(notReadyHttpServer.start).toHaveBeenCalled(); + await service.start(); expect(httpServer.start).toHaveBeenCalled(); + expect(notReadyHttpServer.stop).toHaveBeenCalled(); }); test('spins up notReady server until started if configured with `autoListen:true`', async () => { @@ -102,6 +114,8 @@ test('spins up notReady server until started if configured with `autoListen:true .mockImplementationOnce(() => httpServer) .mockImplementationOnce(() => ({ setup: () => ({ server: notReadyHapiServer }), + start: jest.fn(), + stop: jest.fn().mockImplementation(() => notReadyHapiServer.stop()), })); const service = new HttpService({ @@ -163,7 +177,14 @@ test('stops http server', async () => { start: noop, stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: noop, + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); @@ -171,6 +192,7 @@ test('stops http server', async () => { await service.start(); expect(httpServer.stop).toHaveBeenCalledTimes(0); + expect(notReadyHttpServer.stop).toHaveBeenCalledTimes(1); await service.stop(); @@ -188,7 +210,7 @@ test('stops not ready server if it is running', async () => { isListening: () => false, setup: jest.fn().mockReturnValue({ server: mockHapiServer }), start: noop, - stop: jest.fn(), + stop: jest.fn().mockImplementation(() => mockHapiServer.stop()), }; mockHttpServer.mockImplementation(() => httpServer); @@ -198,7 +220,7 @@ test('stops not ready server if it is running', async () => { await service.stop(); - expect(mockHapiServer.stop).toHaveBeenCalledTimes(1); + expect(mockHapiServer.stop).toHaveBeenCalledTimes(2); }); test('register route handler', async () => { @@ -231,6 +253,7 @@ test('returns http server contract on setup', async () => { mockHttpServer.mockImplementation(() => ({ isListening: () => false, setup: jest.fn().mockReturnValue(httpServer), + start: noop, stop: noop, })); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index fdf9b738a9833..0d28506607682 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -8,7 +8,6 @@ import { Observable, Subscription, combineLatest, of } from 'rxjs'; import { first, map } from 'rxjs/operators'; -import { Server } from '@hapi/hapi'; import { pick } from '@kbn/std'; import type { RequestHandlerContext } from 'src/core/server'; @@ -20,7 +19,7 @@ import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; -import { Router } from './router'; +import { IRouter, Router } from './router'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -30,6 +29,7 @@ import { RequestHandlerContextProvider, InternalHttpServiceSetup, InternalHttpServiceStart, + InternalNotReadyHttpServiceSetup, } from './types'; import { registerCoreHandlers } from './lifecycle_handlers'; @@ -54,7 +54,7 @@ export class HttpService private readonly logger: LoggerFactory; private readonly log: Logger; private readonly env: Env; - private notReadyServer?: Server; + private notReadyServer?: HttpServer; private internalSetup?: InternalHttpServiceSetup; private requestHandlerContext?: RequestHandlerContextContainer; @@ -88,9 +88,7 @@ export class HttpService const config = await this.config$.pipe(first()).toPromise(); - if (this.shouldListen(config)) { - await this.runNotReadyServer(config); - } + const notReadyServer = await this.setupNotReadyService({ config, context: deps.context }); const { registerRouter, ...serverContract } = await this.httpServer.setup(config); @@ -99,6 +97,8 @@ export class HttpService this.internalSetup = { ...serverContract, + notReadyServer, + externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: ( @@ -178,14 +178,51 @@ export class HttpService await this.httpsRedirectServer.stop(); } + private async setupNotReadyService({ + config, + context, + }: { + config: HttpConfig; + context: ContextSetup; + }): Promise { + if (!this.shouldListen(config)) { + return; + } + + const notReadySetup = await this.runNotReadyServer(config); + + // We cannot use the real context container since the core services may not yet be ready + const fakeContext: RequestHandlerContextContainer = new Proxy( + context.createContextContainer(), + { + get: (target, property, receiver) => { + if (property === 'createHandler') { + return Reflect.get(target, property, receiver); + } + throw new Error(`Unexpected access from fake context: ${String(property)}`); + }, + } + ); + + return { + registerRoutes: (path: string, registerCallback: (router: IRouter) => void) => { + const router = new Router( + path, + this.log, + fakeContext.createHandler.bind(null, this.coreContext.coreId) + ); + + registerCallback(router); + notReadySetup.registerRouterAfterListening(router); + }, + }; + } + private async runNotReadyServer(config: HttpConfig) { this.log.debug('starting NotReady server'); - const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); - const { server } = await httpServer.setup(config); - this.notReadyServer = server; - // use hapi server while KibanaResponseFactory doesn't allow specifying custom headers - // https://github.com/elastic/kibana/issues/33779 - this.notReadyServer.route({ + this.notReadyServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); + const notReadySetup = await this.notReadyServer.setup(config); + notReadySetup.server.route({ path: '/{p*}', method: '*', handler: (req, responseToolkit) => { @@ -201,5 +238,7 @@ export class HttpService }, }); await this.notReadyServer.start(); + + return notReadySetup; } } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 5b297ab44f8bb..354ab1c65d565 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -15,6 +15,8 @@ import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; import { HttpService } from '../http_service'; +import { Router } from '../router'; +import { loggerMock } from '@kbn/logging/target/mocks'; let server: HttpService; let logger: ReturnType; @@ -1836,3 +1838,57 @@ describe('ETag', () => { .expect(304, ''); }); }); + +describe('registerRouterAfterListening', () => { + it('allows a router to be registered before server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); + + it('allows a router to be registered after server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(404); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); +}); diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index a958d330bf24d..5ba8143936563 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -9,7 +9,13 @@ export { filterHeaders } from './headers'; export type { Headers, ResponseHeaders, KnownHeaders } from './headers'; export { Router } from './router'; -export type { RequestHandler, RequestHandlerWrapper, IRouter, RouteRegistrar } from './router'; +export type { + RequestHandler, + RequestHandlerWrapper, + IRouter, + RouteRegistrar, + RouterRoute, +} from './router'; export { isKibanaRequest, isRealRequest, ensureRawRequest, KibanaRequest } from './request'; export type { KibanaRequestEvents, diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts index e2babf719f67e..6cea7fcf4c949 100644 --- a/src/core/server/http/router/response.ts +++ b/src/core/server/http/router/response.ts @@ -62,6 +62,8 @@ export interface HttpResponseOptions { body?: HttpResponsePayload; /** HTTP Headers with additional information about response */ headers?: ResponseHeaders; + /** Bypass the default error formatting */ + bypassErrorFormat?: boolean; } /** @@ -79,6 +81,8 @@ export interface CustomHttpResponseOptions; diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index f007a77a2a21a..bbd296d6b1831 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -277,6 +277,11 @@ export interface HttpServiceSetup { getServerInfo: () => HttpServerInfo; } +/** @internal */ +export interface InternalNotReadyHttpServiceSetup { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} + /** @internal */ export interface InternalHttpServiceSetup extends Omit { @@ -287,6 +292,7 @@ export interface InternalHttpServiceSetup path: string, plugin?: PluginOpaqueId ) => IRouter; + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; registerRouteHandlerContext: < @@ -297,6 +303,7 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; + notReadyServer?: InternalNotReadyHttpServiceSetup; } /** @public */ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 9fccc4b8bc1f0..ca328f17b2ae1 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -397,7 +397,7 @@ export type { } from './deprecations'; export type { AppCategory } from '../types'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_APP_CATEGORIES, APP_WRAPPER_CLASS } from '../utils'; export type { SavedObject, diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index 9c4313bc0c49d..8eed2aecb21d6 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -470,3 +470,59 @@ test('subsequent calls to setContextConfig() for the same context name can disab }, }); }); + +test('buffers log records for already created appenders', async () => { + // a default config + await system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'info' }, + }) + ); + + const logger = system.get('test', 'context'); + + const bufferAppendSpy = jest.spyOn((system as any).bufferAppender, 'append'); + + const upgradePromise = system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'all' }, + }) + ); + + logger.trace('message to the known context'); + expect(bufferAppendSpy).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledTimes(0); + + await upgradePromise; + expect(JSON.parse(mockConsoleLog.mock.calls[0][0]).message).toBe('message to the known context'); +}); + +test('buffers log records for appenders created during config upgrade', async () => { + // a default config + await system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'info' }, + }) + ); + + const bufferAppendSpy = jest.spyOn((system as any).bufferAppender, 'append'); + + const upgradePromise = system.upgrade( + config.schema.validate({ + appenders: { default: { type: 'console', layout: { type: 'json' } } }, + root: { level: 'all' }, + }) + ); + + const logger = system.get('test', 'context'); + logger.trace('message to a new context'); + + expect(bufferAppendSpy).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledTimes(0); + + await upgradePromise; + expect(JSON.parse(mockConsoleLog.mock.calls[0][0]).message).toBe('message to a new context'); +}); diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts index d7c34b48c4101..45a687493c163 100644 --- a/src/core/server/logging/logging_system.ts +++ b/src/core/server/logging/logging_system.ts @@ -167,17 +167,13 @@ export class LoggingSystem implements LoggerFactory { } private async applyBaseConfig(newBaseConfig: LoggingConfig) { + this.enforceBufferAppendersUsage(); + const computedConfig = [...this.contextConfigs.values()].reduce( (baseConfig, contextConfig) => baseConfig.extend(contextConfig), newBaseConfig ); - // reconfigure all the loggers without configuration to have them use the buffer - // appender while we are awaiting for the appenders to be disposed. - for (const [loggerKey, loggerAdapter] of this.loggers) { - loggerAdapter.updateLogger(this.createLogger(loggerKey, undefined)); - } - // Appenders must be reset, so we first dispose of the current ones, then // build up a new set of appenders. await Promise.all([...this.appenders.values()].map((a) => a.dispose())); @@ -204,18 +200,32 @@ export class LoggingSystem implements LoggerFactory { } } - for (const [loggerKey, loggerAdapter] of this.loggers) { - loggerAdapter.updateLogger(this.createLogger(loggerKey, computedConfig)); - } - + this.enforceConfiguredAppendersUsage(computedConfig); // We keep a reference to the base config so we can properly extend it // on each config change. this.baseConfig = newBaseConfig; - this.computedConfig = computedConfig; // Re-log all buffered log records with newly configured appenders. for (const logRecord of this.bufferAppender.flush()) { this.get(logRecord.context).log(logRecord); } } + + // reconfigure all the loggers to have them use the buffer appender + // while we are awaiting for the appenders to be disposed. + private enforceBufferAppendersUsage() { + for (const [loggerKey, loggerAdapter] of this.loggers) { + loggerAdapter.updateLogger(this.createLogger(loggerKey, undefined)); + } + + // new loggers created during applyBaseConfig execution should use the buffer appender as well + this.computedConfig = undefined; + } + + private enforceConfiguredAppendersUsage(config: LoggingConfig) { + for (const [loggerKey, loggerAdapter] of this.loggers) { + loggerAdapter.updateLogger(this.createLogger(loggerKey, config)); + } + this.computedConfig = config; + } } diff --git a/src/core/server/rendering/views/styles.tsx b/src/core/server/rendering/views/styles.tsx index 018ee2d48d8c7..105f94df9218f 100644 --- a/src/core/server/rendering/views/styles.tsx +++ b/src/core/server/rendering/views/styles.tsx @@ -89,8 +89,7 @@ const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { } .kbnWelcomeText { - font-family: - display: inline-block; + display: block; font-size: 14px; font-family: sans-serif; line-height: 40px !important; @@ -103,7 +102,7 @@ const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { text-align: center; line-height: 1; text-align: center; - font-faimily: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial !important; + font-family: sans-serif; letter-spacing: -.005em; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index cccd38bf5cc9e..8e538f6e12384 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -850,7 +850,8 @@ function assertNoDowngrades( * that we can later regenerate any inbound object references to match. * * @note This is only intended to be used when single-namespace object types are converted into multi-namespace object types. + * @internal */ -function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { +export function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { return uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS); // the uuidv5 namespace constant (uuidv5.DNS) is arbitrary } diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index 460aabbc77415..44dd60097f1cd 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -14,7 +14,6 @@ import _ from 'lodash'; import { estypes } from '@elastic/elasticsearch'; import { MigrationEsClient } from './migration_es_client'; -import { CountResponse, SearchResponse } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsMigrationVersion } from '../../types'; import { AliasAction, RawDoc } from './call_cluster'; @@ -95,11 +94,11 @@ export async function fetchInfo(client: MigrationEsClient, index: string): Promi * Creates a reader function that serves up batches of documents from the index. We aren't using * an async generator, as that feature currently breaks Kibana's tooling. * - * @param {CallCluster} callCluster - The elastic search connection - * @param {string} - The index to be read from + * @param client - The elastic search connection + * @param index - The index to be read from * @param {opts} - * @prop {number} batchSize - The number of documents to read at a time - * @prop {string} scrollDuration - The scroll duration used for scrolling through the index + * @prop batchSize - The number of documents to read at a time + * @prop scrollDuration - The scroll duration used for scrolling through the index */ export function reader( client: MigrationEsClient, @@ -111,11 +110,11 @@ export function reader( const nextBatch = () => scrollId !== undefined - ? client.scroll>({ + ? client.scroll({ scroll, scroll_id: scrollId, }) - : client.search>({ + : client.search({ body: { size: batchSize, query: excludeUnusedTypesQuery, @@ -143,10 +142,6 @@ export function reader( /** * Writes the specified documents to the index, throws an exception * if any of the documents fail to save. - * - * @param {CallCluster} callCluster - * @param {string} index - * @param {RawDoc[]} docs */ export async function write(client: MigrationEsClient, index: string, docs: RawDoc[]) { const { body } = await client.bulk({ @@ -184,9 +179,9 @@ export async function write(client: MigrationEsClient, index: string, docs: RawD * it performs the check *each* time it is called, rather than memoizing itself, * as this is used to determine if migrations are complete. * - * @param {CallCluster} callCluster - * @param {string} index - * @param {SavedObjectsMigrationVersion} migrationVersion - The latest versions of the migrations + * @param client - The connection to ElasticSearch + * @param index + * @param migrationVersion - The latest versions of the migrations */ export async function migrationsUpToDate( client: MigrationEsClient, @@ -207,7 +202,7 @@ export async function migrationsUpToDate( return true; } - const { body } = await client.count({ + const { body } = await client.count({ body: { query: { bool: { @@ -271,9 +266,9 @@ export async function createIndex( * is a concrete index. This function will reindex `alias` into a new index, delete the `alias` * index, and then create an alias `alias` that points to the new index. * - * @param {CallCluster} callCluster - The connection to ElasticSearch - * @param {FullIndexInfo} info - Information about the mappings and name of the new index - * @param {string} alias - The name of the index being converted to an alias + * @param client - The ElasticSearch connection + * @param info - Information about the mappings and name of the new index + * @param alias - The name of the index being converted to an alias */ export async function convertToAlias( client: MigrationEsClient, @@ -297,7 +292,7 @@ export async function convertToAlias( * alias, meaning that it will only point to one index at a time, so we * remove any other indices from the alias. * - * @param {CallCluster} callCluster + * @param {CallCluster} client * @param {string} index * @param {string} alias * @param {AliasAction[]} aliasActions - Optional actions to be added to the updateAliases call @@ -377,7 +372,7 @@ async function reindex( ) { // We poll instead of having the request wait for completion, as for large indices, // the request times out on the Elasticsearch side of things. We have a relatively tight - // polling interval, as the request is fairly efficent, and we don't + // polling interval, as the request is fairly efficient, and we don't // want to block index migrations for too long on this. const pollInterval = 250; const { body: reindexBody } = await client.reindex({ diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index dd295efacf6b8..fcc03f363139b 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -27,6 +27,7 @@ describe('IndexMigrator', () => { index: '.kibana', kibanaVersion: '7.10.0', log: loggingSystemMock.create().get(), + setStatus: jest.fn(), mappingProperties: {}, pollInterval: 1, scrollDuration: '1m', diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 5bf5ae26f6a0a..14dba1db9b624 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -41,6 +41,8 @@ export class IndexMigrator { pollInterval: context.pollInterval, + setStatus: context.setStatus, + async isMigrated() { return !(await requiresMigration(context)); }, @@ -189,8 +191,7 @@ async function migrateSourceToDest(context: Context) { serializer, documentMigrator.migrateAndConvert, // @ts-expect-error @elastic/elasticsearch `Hit._id` may be a string | number in ES, but we always expect strings in the SO index. - docs, - log + docs ) ); } diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 66750a8abf1db..45e73f7dfae30 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -11,7 +11,6 @@ import _ from 'lodash'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsSerializer } from '../../serialization'; import { migrateRawDocs } from './migrate_raw_docs'; -import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { @@ -24,8 +23,7 @@ describe('migrateRawDocs', () => { [ { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, - ], - createSavedObjectsMigrationLoggerMock() + ] ); expect(result).toEqual([ @@ -59,7 +57,6 @@ describe('migrateRawDocs', () => { }); test('throws when encountering a corrupt saved object document', async () => { - const logger = createSavedObjectsMigrationLoggerMock(); const transform = jest.fn((doc: any) => [ set(_.cloneDeep(doc), 'attributes.name', 'TADA'), ]); @@ -69,8 +66,7 @@ describe('migrateRawDocs', () => { [ { _id: 'foo:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, - ], - logger + ] ); expect(result).rejects.toMatchInlineSnapshot( @@ -88,8 +84,7 @@ describe('migrateRawDocs', () => { const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, - [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], - createSavedObjectsMigrationLoggerMock() + [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }] ); expect(result).toEqual([ @@ -119,12 +114,9 @@ describe('migrateRawDocs', () => { throw new Error('error during transform'); }); await expect( - migrateRawDocs( - new SavedObjectsSerializer(new SavedObjectTypeRegistry()), - transform, - [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], - createSavedObjectsMigrationLoggerMock() - ) + migrateRawDocs(new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, [ + { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, + ]) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error during transform"`); }); }); diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index e75f29e54c876..102ec81646a92 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -16,7 +16,6 @@ import { SavedObjectUnsanitizedDoc, } from '../../serialization'; import { MigrateAndConvertFn } from './document_migrator'; -import { SavedObjectsMigrationLogger } from '.'; /** * Error thrown when saved object migrations encounter a corrupt saved object. @@ -46,8 +45,7 @@ export class CorruptSavedObjectError extends Error { export async function migrateRawDocs( serializer: SavedObjectsSerializer, migrateDoc: MigrateAndConvertFn, - rawDocs: SavedObjectsRawDoc[], - log: SavedObjectsMigrationLogger + rawDocs: SavedObjectsRawDoc[] ): Promise { const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc); const processedDocs = []; diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index 441c7efed049f..d7f7aff45a470 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -25,6 +25,7 @@ import { buildActiveMappings } from './build_active_mappings'; import { VersionedTransformer } from './document_migrator'; import * as Index from './elastic_index'; import { SavedObjectsMigrationLogger, MigrationLogger } from './migration_logger'; +import { KibanaMigratorStatus } from '../kibana'; export interface MigrationOpts { batchSize: number; @@ -34,6 +35,7 @@ export interface MigrationOpts { index: string; kibanaVersion: string; log: Logger; + setStatus: (status: KibanaMigratorStatus) => void; mappingProperties: SavedObjectsTypeMappingDefinitions; documentMigrator: VersionedTransformer; serializer: SavedObjectsSerializer; @@ -57,6 +59,7 @@ export interface Context { documentMigrator: VersionedTransformer; kibanaVersion: string; log: SavedObjectsMigrationLogger; + setStatus: (status: KibanaMigratorStatus) => void; batchSize: number; pollInterval: number; scrollDuration: string; @@ -70,7 +73,7 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, client } = opts; + const { log, client, setStatus } = opts; const alias = opts.index; const source = createSourceContext(await Index.fetchInfo(client, alias), alias); const dest = createDestContext(source, alias, opts.mappingProperties); @@ -82,6 +85,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { dest, kibanaVersion: opts.kibanaVersion, log: new MigrationLogger(log), + setStatus, batchSize: opts.batchSize, documentMigrator: opts.documentMigrator, pollInterval: opts.pollInterval, diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts index 9a045d0fbf7f9..63476a15d77cd 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts @@ -19,6 +19,7 @@ describe('coordinateMigration', () => { throw { body: { error: { index: '.foo', type: 'resource_already_exists_exception' } } }; }); const isMigrated = jest.fn(); + const setStatus = jest.fn(); isMigrated.mockResolvedValueOnce(false).mockResolvedValueOnce(true); @@ -27,6 +28,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }); expect(runMigration).toHaveBeenCalledTimes(1); @@ -39,12 +41,14 @@ describe('coordinateMigration', () => { const pollInterval = 1; const runMigration = jest.fn(() => Promise.resolve()); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await coordinateMigration({ log, runMigration, pollInterval, isMigrated, + setStatus, }); expect(isMigrated).not.toHaveBeenCalled(); }); @@ -55,6 +59,7 @@ describe('coordinateMigration', () => { throw new Error('Doh'); }); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await expect( coordinateMigration({ @@ -62,6 +67,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }) ).rejects.toThrow(/Doh/); expect(isMigrated).not.toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts index 3e66d37ce6964..5b99f050b0ece 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts @@ -24,11 +24,16 @@ */ import _ from 'lodash'; +import { KibanaMigratorStatus } from '../kibana'; import { SavedObjectsMigrationLogger } from './migration_logger'; const DEFAULT_POLL_INTERVAL = 15000; -export type MigrationStatus = 'waiting' | 'running' | 'completed'; +export type MigrationStatus = + | 'waiting_to_start' + | 'waiting_for_other_nodes' + | 'running' + | 'completed'; export type MigrationResult = | { status: 'skipped' } @@ -43,6 +48,7 @@ export type MigrationResult = interface Opts { runMigration: () => Promise; isMigrated: () => Promise; + setStatus: (status: KibanaMigratorStatus) => void; log: SavedObjectsMigrationLogger; pollInterval?: number; } @@ -64,7 +70,9 @@ export async function coordinateMigration(opts: Opts): Promise try { return await opts.runMigration(); } catch (error) { - if (handleIndexExists(error, opts.log)) { + const waitingIndex = handleIndexExists(error, opts.log); + if (waitingIndex) { + opts.setStatus({ status: 'waiting_for_other_nodes', waitingIndex }); await waitForMigration(opts.isMigrated, opts.pollInterval); return { status: 'skipped' }; } @@ -77,11 +85,11 @@ export async function coordinateMigration(opts: Opts): Promise * and is the cue for us to fall into a polling loop, waiting for some * other Kibana instance to complete the migration. */ -function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { +function handleIndexExists(error: any, log: SavedObjectsMigrationLogger): string | undefined { const isIndexExistsError = _.get(error, 'body.error.type') === 'resource_already_exists_exception'; if (!isIndexExistsError) { - return false; + return undefined; } const index = _.get(error, 'body.error.index'); @@ -93,7 +101,7 @@ function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { `restarting Kibana.` ); - return true; + return index; } /** diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 221e78e3e12e2..c6dfd2c2d1809 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -229,48 +229,6 @@ describe('KibanaMigrator', () => { jest.clearAllMocks(); }); - it('creates a V2 migrator that initializes a new index and migrates an existing index', async () => { - const options = mockV2MigrationOptions(); - const migrator = new KibanaMigrator(options); - const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise(); - migrator.prepareMigrations(); - await migrator.runMigrations(); - - // Basic assertions that we're creating and reindexing the expected indices - expect(options.client.indices.create).toHaveBeenCalledTimes(3); - expect(options.client.indices.create.mock.calls).toEqual( - expect.arrayContaining([ - // LEGACY_CREATE_REINDEX_TARGET - expect.arrayContaining([expect.objectContaining({ index: '.my-index_pre8.2.3_001' })]), - // CREATE_REINDEX_TEMP - expect.arrayContaining([ - expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }), - ]), - // CREATE_NEW_TARGET - expect.arrayContaining([expect.objectContaining({ index: 'other-index_8.2.3_001' })]), - ]) - ); - // LEGACY_REINDEX - expect(options.client.reindex.mock.calls[0][0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ - source: expect.objectContaining({ index: '.my-index' }), - dest: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }), - }), - }) - ); - // REINDEX_SOURCE_TO_TEMP - expect(options.client.reindex.mock.calls[1][0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ - source: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }), - dest: expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }), - }), - }) - ); - const { status } = await migratorStatus; - return expect(status).toEqual('completed'); - }); it('emits results on getMigratorResult$()', async () => { const options = mockV2MigrationOptions(); const migrator = new KibanaMigrator(options); @@ -378,6 +336,24 @@ const mockV2MigrationOptions = () => { } as estypes.GetTaskResponse) ); + options.client.search = jest + .fn() + .mockImplementation(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { hits: [] } }) + ); + + options.client.openPointInTime = jest + .fn() + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ id: 'pit_id' }) + ); + + options.client.closePointInTime = jest + .fn() + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ succeeded: true }) + ); + return options; }; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 29852f8ac6445..e09284b49c86e 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -36,7 +36,6 @@ import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; import { runResilientMigrator } from '../../migrationsv2'; import { migrateRawDocs } from '../core/migrate_raw_docs'; -import { MigrationLogger } from '../core/migration_logger'; export interface KibanaMigratorOptions { client: ElasticsearchClient; @@ -53,6 +52,7 @@ export type IKibanaMigrator = Pick; export interface KibanaMigratorStatus { status: MigrationStatus; result?: MigrationResult[]; + waitingIndex?: string; } /** @@ -68,7 +68,7 @@ export class KibanaMigrator { private readonly serializer: SavedObjectsSerializer; private migrationResult?: Promise; private readonly status$ = new BehaviorSubject({ - status: 'waiting', + status: 'waiting_to_start', }); private readonly activeMappings: IndexMapping; private migrationsRetryDelay?: number; @@ -185,12 +185,7 @@ export class KibanaMigrator { logger: this.log, preMigrationScript: indexMap[index].script, transformRawDocs: (rawDocs: SavedObjectsRawDoc[]) => - migrateRawDocs( - this.serializer, - this.documentMigrator.migrateAndConvert, - rawDocs, - new MigrationLogger(this.log) - ), + migrateRawDocs(this.serializer, this.documentMigrator.migrateAndConvert, rawDocs), migrationVersionPerType: this.documentMigrator.migrationVersion, indexPrefix: index, migrationsConfig: this.soMigrationsConfig, @@ -206,6 +201,7 @@ export class KibanaMigrator { kibanaVersion: this.kibanaVersion, log: this.log, mappingProperties: indexMap[index].typeMappings, + setStatus: (status) => this.status$.next(status), pollInterval: this.soMigrationsConfig.pollInterval, scrollDuration: this.soMigrationsConfig.scrollDuration, serializer: this.serializer, diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts index bee17f42d7bdb..b144905cf01ad 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts @@ -78,6 +78,54 @@ describe('actions', () => { }); }); + describe('openPit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.openPit(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('readWithPit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.readWithPit(client, 'pitId', Option.none, 10_000); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('closePit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.closePit(client, 'pitId'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('transformDocs', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.transformDocs(client, () => Promise.resolve([]), [], 'my_index', false); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + describe('reindex', () => { it('calls catchRetryableEsClientErrors when the promise rejects', async () => { const task = Actions.reindex( @@ -205,7 +253,7 @@ describe('actions', () => { describe('bulkOverwriteTransformedDocuments', () => { it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments(client, 'new_index', []); + const task = Actions.bulkOverwriteTransformedDocuments(client, 'new_index', [], 'wait_for'); try { await task(); } catch (e) { diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 02d3f8e21a510..049cdc41b7527 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -16,7 +16,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { flow } from 'fp-ts/lib/function'; import { ElasticsearchClient } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; -import { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { TransformRawDocs } from '../types'; import { catchRetryableEsClientErrors, RetryableEsClientError, @@ -419,6 +420,133 @@ export const pickupUpdatedMappings = ( .catch(catchRetryableEsClientErrors); }; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +// how long ES should keep PIT alive +const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; +} + +/* + * Requests documents from the index using PIT mechanism. + * Filter unusedTypesToExclude documents out to exclude them from being migrated. + * */ +export const readWithPit = ( + client: ElasticsearchClient, + pitId: string, + /* When reading we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: Option.Option, + batchSize: number, + searchAfter?: number[] +): TaskEither.TaskEither => () => { + return client + .search({ + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Exclude saved object types + query: Option.isSome(unusedTypesQuery) ? unusedTypesQuery.value : undefined, + }, + }) + .then((response) => { + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + }); + }) + .catch(catchRetryableEsClientErrors); +}; + +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ( + client: ElasticsearchClient, + pitId: string +): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; + +/* + * Transform outdated docs and write them to the index. + * */ +export const transformDocs = ( + client: ElasticsearchClient, + transformRawDocs: TransformRawDocs, + outdatedDocuments: SavedObjectsRawDoc[], + index: string, + refresh: estypes.Refresh +): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound | TargetIndexHadWriteBlock, + 'bulk_index_succeeded' +> => + pipe( + TaskEither.tryCatch( + () => transformRawDocs(outdatedDocuments), + (e) => { + throw e; + } + ), + TaskEither.chain((docs) => bulkOverwriteTransformedDocuments(client, index, docs, refresh)) + ); + +/** @internal */ export interface ReindexResponse { taskId: string; } @@ -489,10 +617,12 @@ interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } +/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } +/** @internal */ export interface IncompatibleMappingException { type: 'incompatible_mapping_exception'; } @@ -605,14 +735,17 @@ export const waitForPickupUpdatedMappingsTask = flow( ) ); +/** @internal */ export interface AliasNotFound { type: 'alias_not_found_exception'; } +/** @internal */ export interface RemoveIndexNotAConcreteIndex { type: 'remove_index_not_a_concrete_index'; } +/** @internal */ export type AliasAction = | { remove_index: { index: string } } | { remove: { index: string; alias: string; must_exist: boolean } } @@ -679,11 +812,19 @@ export const updateAliases = ( .catch(catchRetryableEsClientErrors); }; +/** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} /** * Creates an index with the given mappings * @@ -698,16 +839,13 @@ export const createIndex = ( client: ElasticsearchClient, indexName: string, mappings: IndexMapping, - aliases?: string[] + aliases: string[] = [] ): TaskEither.TaskEither => { const createIndexTask: TaskEither.TaskEither< RetryableEsClientError, AcknowledgeResponse > = () => { - const aliasesObject = (aliases ?? []).reduce((acc, alias) => { - acc[alias] = {}; - return acc; - }, {} as Record); + const aliasesObject = aliasArrayToRecord(aliases); return client.indices .create( @@ -792,6 +930,7 @@ export const createIndex = ( ); }; +/** @internal */ export interface UpdateAndPickupMappingsResponse { taskId: string; } @@ -842,6 +981,8 @@ export const updateAndPickupMappings = ( }) ); }; + +/** @internal */ export interface SearchResponse { outdatedDocuments: SavedObjectsRawDoc[]; } @@ -906,7 +1047,8 @@ export const searchForOutdatedDocuments = ( export const bulkOverwriteTransformedDocuments = ( client: ElasticsearchClient, index: string, - transformedDocs: SavedObjectsRawDoc[] + transformedDocs: SavedObjectsRawDoc[], + refresh: estypes.Refresh ): TaskEither.TaskEither => () => { return client .bulk({ @@ -919,15 +1061,7 @@ export const bulkOverwriteTransformedDocuments = ( // system indices puts in place a hard control. require_alias: false, wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait for a refresh to happen before returning. This ensures that when - // this Kibana instance searches for outdated documents, it won't find - // documents that were already transformed by itself or another Kibna - // instance. However, this causes each OUTDATED_DOCUMENTS_SEARCH -> - // OUTDATED_DOCUMENTS_TRANSFORM cycle to take 1s so when batches are - // small performance will become a lot worse. - // The alternative is to use a search_after with either a tie_breaker - // field or using a Point In Time as a cursor to go through all documents. - refresh: 'wait_for', + refresh, filter_path: ['items.*.error'], body: transformedDocs.flatMap((doc) => { return [ diff --git a/src/core/server/saved_objects/migrationsv2/index.ts b/src/core/server/saved_objects/migrationsv2/index.ts index 6e65a2e700fd3..25816c7fd14c6 100644 --- a/src/core/server/saved_objects/migrationsv2/index.ts +++ b/src/core/server/saved_objects/migrationsv2/index.ts @@ -9,9 +9,10 @@ import { ElasticsearchClient } from '../../elasticsearch'; import { IndexMapping } from '../mappings'; import { Logger } from '../../logging'; -import { SavedObjectsMigrationVersion } from '../types'; +import type { SavedObjectsMigrationVersion } from '../types'; +import type { TransformRawDocs } from './types'; import { MigrationResult } from '../migrations/core'; -import { next, TransformRawDocs } from './next'; +import { next } from './next'; import { createInitialState, model } from './model'; import { migrationStateActionMachine } from './migrations_state_action_machine'; import { SavedObjectsMigrationConfigType } from '../saved_objects_config'; @@ -55,5 +56,6 @@ export async function runResilientMigrator({ logger, next: next(client, transformRawDocs), model, + client, }); } diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore b/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore index 57208badcc680..397b4a7624e35 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore @@ -1 +1 @@ -migration_test_kibana.log +*.log diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 3905044f04e2f..b31f20950ae77 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -14,9 +14,14 @@ import { SavedObjectsRawDoc } from '../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, + closePit, createIndex, fetchIndices, + openPit, + OpenPitResponse, reindex, + readWithPit, + ReadWithPit, searchForOutdatedDocuments, SearchResponse, setWriteBlock, @@ -30,6 +35,7 @@ import { UpdateAndPickupMappingsResponse, verifyReindex, removeWriteBlock, + transformDocs, waitForIndexStatusYellow, } from '../actions'; import * as Either from 'fp-ts/lib/Either'; @@ -70,14 +76,20 @@ describe('migration actions', () => { { _source: { title: 'saved object 4', type: 'another_unused_type' } }, { _source: { title: 'f-agent-event 5', type: 'f_agent_event' } }, ] as unknown) as SavedObjectsRawDoc[]; - await bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', sourceDocs)(); + await bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + sourceDocs, + 'wait_for' + )(); await createIndex(client, 'existing_index_2', { properties: {} })(); await createIndex(client, 'existing_index_with_write_block', { properties: {} })(); await bulkOverwriteTransformedDocuments( client, 'existing_index_with_write_block', - sourceDocs + sourceDocs, + 'wait_for' )(); await setWriteBlock(client, 'existing_index_with_write_block')(); await updateAliases(client, [ @@ -155,7 +167,12 @@ describe('migration actions', () => { { _source: { title: 'doc 4' } }, ] as unknown) as SavedObjectsRawDoc[]; await expect( - bulkOverwriteTransformedDocuments(client, 'new_index_without_write_block', sourceDocs)() + bulkOverwriteTransformedDocuments( + client, + 'new_index_without_write_block', + sourceDocs, + 'wait_for' + )() ).rejects.toMatchObject(expect.anything()); }); it('resolves left index_not_found_exception when the index does not exist', async () => { @@ -265,14 +282,14 @@ describe('migration actions', () => { const task = cloneIndex(client, 'existing_index_with_write_block', 'clone_target_1'); expect.assertions(1); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); it('resolves right after waiting for index status to be yellow if clone target already existed', async () => { expect.assertions(2); @@ -331,14 +348,14 @@ describe('migration actions', () => { expect.assertions(1); const task = cloneIndex(client, 'no_such_index', 'clone_target_3'); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); it('resolves left with a retryable_es_client_error if clone target already exists but takes longer than the specified timeout before turning yellow', async () => { // Create a red index @@ -406,13 +423,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", "doc 3", - "saved object 4", "f-agent-event 5", + "saved object 4", ] `); }); @@ -433,18 +450,18 @@ describe('migration actions', () => { )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ((await searchForOutdatedDocuments(client, { batchSize: 1000, targetIndex: 'reindex_target_excluded_docs', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", @@ -474,13 +491,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_2', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1_updated", "doc 2_updated", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -526,13 +543,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_3', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1_updated", "doc 2_updated", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -551,7 +568,7 @@ describe('migration actions', () => { _id, _source, })); - await bulkOverwriteTransformedDocuments(client, 'reindex_target_4', sourceDocs)(); + await bulkOverwriteTransformedDocuments(client, 'reindex_target_4', sourceDocs, 'wait_for')(); // Now do a real reindex const res = (await reindex( @@ -576,13 +593,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_4', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -790,9 +807,169 @@ describe('migration actions', () => { ); task = verifyReindex(client, 'existing_index_2', 'no_such_index'); - await expect(task()).rejects.toMatchInlineSnapshot( - `[ResponseError: index_not_found_exception]` + await expect(task()).rejects.toThrow('index_not_found_exception'); + }); + }); + + describe('openPit', () => { + it('opens PointInTime for an index', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + expect(pitResponse.right.pitId).toEqual(expect.any(String)); + + const searchResponse = await client.search({ + body: { + pit: { id: pitResponse.right.pitId }, + }, + }); + + await expect(searchResponse.body.hits.hits.length).toBeGreaterThan(0); + }); + it('rejects if index does not exist', async () => { + const openPitTask = openPit(client, 'no_such_index'); + await expect(openPitTask()).rejects.toThrow('index_not_found_exception'); + }); + }); + + describe('readWithPit', () => { + it('requests documents from an index using given PIT', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.none, + 1000, + undefined + ); + const docsResponse = (await readWithPitTask()) as Either.Right; + + await expect(docsResponse.right.outdatedDocuments.length).toBe(5); + }); + + it('requests the batchSize of documents from an index', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.none, + 3, + undefined ); + const docsResponse = (await readWithPitTask()) as Either.Right; + + await expect(docsResponse.right.outdatedDocuments.length).toBe(3); + }); + + it('it excludes documents not matching the provided "unusedTypesQuery"', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.some({ + bool: { + must_not: [ + { + term: { + type: 'f_agent_event', + }, + }, + { + term: { + type: 'another_unused_type', + }, + }, + ], + }, + }), + 1000, + undefined + ); + + const docsResponse = (await readWithPitTask()) as Either.Right; + + expect(docsResponse.right.outdatedDocuments.map((doc) => doc._source.title).sort()) + .toMatchInlineSnapshot(` + Array [ + "doc 1", + "doc 2", + "doc 3", + ] + `); + }); + + it('rejects if PIT does not exist', async () => { + const readWithPitTask = readWithPit(client, 'no_such_pit', Option.none, 1000, undefined); + await expect(readWithPitTask()).rejects.toThrow('illegal_argument_exception'); + }); + }); + + describe('closePit', () => { + it('closes PointInTime', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const pitId = pitResponse.right.pitId; + await closePit(client, pitId)(); + + const searchTask = client.search({ + body: { + pit: { id: pitId }, + }, + }); + + await expect(searchTask).rejects.toThrow('search_phase_execution_exception'); + }); + + it('rejects if PIT does not exist', async () => { + const closePitTask = closePit(client, 'no_such_pit'); + await expect(closePitTask()).rejects.toThrow('illegal_argument_exception'); + }); + }); + + describe('transformDocs', () => { + it('applies "transformRawDocs" and writes result into an index', async () => { + const index = 'transform_docs_index'; + const originalDocs = [ + { _id: 'foo:1', _source: { type: 'dashboard', value: 1 } }, + { _id: 'foo:2', _source: { type: 'dashboard', value: 2 } }, + ]; + + const createIndexTask = createIndex(client, index, { + dynamic: true, + properties: {}, + }); + await createIndexTask(); + + async function tranformRawDocs(docs: SavedObjectsRawDoc[]): Promise { + for (const doc of docs) { + doc._source.value += 1; + } + return docs; + } + + const transformTask = transformDocs(client, tranformRawDocs, originalDocs, index, 'wait_for'); + + const result = (await transformTask()) as Either.Right<'bulk_index_succeeded'>; + + expect(result.right).toBe('bulk_index_succeeded'); + + const { body } = await client.search<{ value: number }>({ + index, + }); + const hits = body.hits.hits; + + const foo1 = hits.find((h) => h._id === 'foo:1'); + expect(foo1?._source?.value).toBe(2); + + const foo2 = hits.find((h) => h._id === 'foo:2'); + expect(foo2?._source?.value).toBe(3); }); }); @@ -919,7 +1096,8 @@ describe('migration actions', () => { await bulkOverwriteTransformedDocuments( client, 'existing_index_without_mappings', - sourceDocs + sourceDocs, + 'wait_for' )(); // Assert that we can't search over the unmapped fields of the document @@ -1147,7 +1325,13 @@ describe('migration actions', () => { { _source: { title: 'doc 6' } }, { _source: { title: 'doc 7' } }, ] as unknown) as SavedObjectsRawDoc[]; - const task = bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', newDocs); + const task = bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + newDocs, + 'wait_for' + ); + await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -1162,10 +1346,12 @@ describe('migration actions', () => { outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - const task = bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', [ - ...existingDocs, - ({ _source: { title: 'doc 8' } } as unknown) as SavedObjectsRawDoc, - ]); + const task = bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + [...existingDocs, ({ _source: { title: 'doc 8' } } as unknown) as SavedObjectsRawDoc], + 'wait_for' + ); await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -1180,7 +1366,12 @@ describe('migration actions', () => { { _source: { title: 'doc 7' } }, ] as unknown) as SavedObjectsRawDoc[]; await expect( - bulkOverwriteTransformedDocuments(client, 'existing_index_with_write_block', newDocs)() + bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_write_block', + newDocs, + 'wait_for' + )() ).rejects.toMatchObject(expect.anything()); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip new file mode 100644 index 0000000000000..a92211c16c559 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip new file mode 100644 index 0000000000000..c6c89ac2879b2 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts new file mode 100644 index 0000000000000..48bb282da18f6 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import JSON5 from 'json5'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { Root } from '../../../root'; + +const logFilePath = Path.join(__dirname, 'cleanup_test.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +const asyncReadFile = Util.promisify(Fs.readFile); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('clean ups if migration fails', async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + // original SO: + // { + // _index: '.kibana_7.13.0_001', + // _type: '_doc', + // _id: 'index-pattern:test_index*', + // _version: 1, + // result: 'created', + // _shards: { total: 2, successful: 1, failed: 0 }, + // _seq_no: 0, + // _primary_term: 1 + // } + dataArchive: Path.join(__dirname, 'archives', '7.13.0_with_corrupted_so.zip'), + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + await root.setup(); + + await expect(root.start()).rejects.toThrow( + /Unable to migrate the corrupt saved object document with _id: 'index-pattern:test_index\*'/ + ); + + const logFileContent = await asyncReadFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)); + + const logRecordWithPit = records.find( + (rec) => rec.message === '[.kibana] REINDEX_SOURCE_TO_TEMP_OPEN_PIT RESPONSE' + ); + + expect(logRecordWithPit).toBeTruthy(); + + const pitId = logRecordWithPit.right.pitId; + expect(pitId).toBeTruthy(); + + const client = esServer.es.getClient(); + await expect( + client.search({ + body: { + pit: { id: pitId }, + }, + }) + // throws an exception that cannot search with closed PIT + ).rejects.toThrow(/search_phase_execution_exception/); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 1f8c3a535a902..37dfe9bc717d0 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -51,6 +51,8 @@ describe('migration v2', () => { migrations: { skip: false, enableV2: true, + // There are 53 docs in fixtures. Batch size configured to enforce 3 migration steps. + batchSize: 20, }, logging: { appenders: { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts new file mode 100644 index 0000000000000..9f7e32c49ef15 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { ElasticsearchClient } from '../../../elasticsearch'; +import { Root } from '../../../root'; +import { deterministicallyRegenerateObjectId } from '../../migrations/core/document_migrator'; + +const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +function sortByTypeAndId(a: { type: string; id: string }, b: { type: string; id: string }) { + return a.type.localeCompare(b.type) || a.id.localeCompare(b.id); +} + +async function fetchDocs(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search({ + index, + body: { + query: { + bool: { + should: [ + { + term: { type: 'foo' }, + }, + { + term: { type: 'bar' }, + }, + { + term: { type: 'legacy-url-alias' }, + }, + ], + }, + }, + }, + }); + + return body.hits.hits + .map((h) => ({ + ...h._source, + id: h._id, + })) + .sort(sortByTypeAndId); +} + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('rewrites id deterministically for SO with namespaceType: "multiple" and "multiple-isolated"', async () => { + const migratedIndex = `.kibana_${pkg.version}_001`; + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + // original SO: + // [ + // { id: 'foo:1', type: 'foo', foo: { name: 'Foo 1 default' } }, + // { id: 'spacex:foo:1', type: 'foo', foo: { name: 'Foo 1 spacex' }, namespace: 'spacex' }, + // { + // id: 'bar:1', + // type: 'bar', + // bar: { nomnom: 1 }, + // references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + // }, + // { + // id: 'spacex:bar:1', + // type: 'bar', + // bar: { nomnom: 2 }, + // references: [{ type: 'foo', id: '1', name: 'Foo 1 spacex' }], + // namespace: 'spacex', + // }, + // ]; + dataArchive: Path.join(__dirname, 'archives', '7.13.0_so_with_multiple_namespaces.zip'), + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + const coreSetup = await root.setup(); + + coreSetup.savedObjects.registerType({ + name: 'foo', + hidden: false, + mappings: { properties: { name: { type: 'text' } } }, + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '8.0.0', + }); + + coreSetup.savedObjects.registerType({ + name: 'bar', + hidden: false, + mappings: { properties: { nomnom: { type: 'integer' } } }, + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', + }); + + const coreStart = await root.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + // each newly converted multi-namespace object in a non-default space has its ID deterministically regenerated, and a legacy-url-alias + // object is created which links the old ID to the new ID + const newFooId = deterministicallyRegenerateObjectId('spacex', 'foo', '1'); + const newBarId = deterministicallyRegenerateObjectId('spacex', 'bar', '1'); + + expect(migratedDocs).toEqual( + [ + { + id: 'foo:1', + type: 'foo', + foo: { name: 'Foo 1 default' }, + references: [], + namespaces: ['default'], + migrationVersion: { foo: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + id: `foo:${newFooId}`, + type: 'foo', + foo: { name: 'Foo 1 spacex' }, + references: [], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { foo: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + // new object for spacex:foo:1 + id: 'legacy-url-alias:spacex:foo:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newFooId, + targetNamespace: 'spacex', + targetType: 'foo', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: pkg.version, + }, + { + id: 'bar:1', + type: 'bar', + bar: { nomnom: 1 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + namespaces: ['default'], + migrationVersion: { bar: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + id: `bar:${newBarId}`, + type: 'bar', + bar: { nomnom: 2 }, + references: [{ type: 'foo', id: newFooId, name: 'Foo 1 spacex' }], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { bar: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + // new object for spacex:bar:1 + id: 'legacy-url-alias:spacex:bar:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newBarId, + targetNamespace: 'spacex', + targetType: 'bar', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: pkg.version, + }, + ].sort(sortByTypeAndId) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index a6617fc2fb7f4..bffe590a39432 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -5,16 +5,18 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { cleanupMock } from './migrations_state_machine_cleanup.mocks'; import { migrationStateActionMachine } from './migrations_state_action_machine'; -import { loggingSystemMock } from '../../mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../mocks'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; -import { AllControlStates, State } from './types'; -import { createInitialState } from './model'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; +import { LoggerAdapter } from '../../logging/logger_adapter'; +import { AllControlStates, State } from './types'; +import { createInitialState } from './model'; +const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('migrationsStateActionMachine', () => { beforeAll(() => { jest @@ -74,6 +76,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }); const logs = loggingSystemMock.collect(mockLogger); const doneLog = logs.info.splice(8, 1)[0][0]; @@ -144,6 +147,37 @@ describe('migrationsStateActionMachine', () => { } `); }); + + // see https://github.com/elastic/kibana/issues/98406 + it('correctly logs state transition when using a logger adapter', async () => { + const underlyingLogger = mockLogger.get(); + const logger = new LoggerAdapter(underlyingLogger); + + await expect( + migrationStateActionMachine({ + initialState, + logger, + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), + next, + client: esClient, + }) + ).resolves.toEqual(expect.anything()); + + const allLogs = loggingSystemMock.collect(mockLogger); + const stateTransitionLogs = allLogs.info + .map((call) => call[0]) + .filter((log) => log.match('control state')); + + expect(stateTransitionLogs).toMatchInlineSnapshot(` + Array [ + "[.my-so-index] Log from LEGACY_REINDEX control state", + "[.my-so-index] Log from LEGACY_DELETE control state", + "[.my-so-index] Log from LEGACY_DELETE control state", + "[.my-so-index] Log from DONE control state", + ] + `); + }); + it('resolves when reaching the DONE state', async () => { await expect( migrationStateActionMachine({ @@ -151,6 +185,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.anything()); }); @@ -161,6 +196,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.objectContaining({ status: 'migrated' })); }); @@ -171,6 +207,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.objectContaining({ status: 'patched' })); }); @@ -181,6 +218,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), next, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index: the fatal reason]` @@ -196,6 +234,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_DELETE', 'FATAL']), next, + client: esClient, }).catch((err) => err); // Ignore the first 4 log entries that come from our model const executionLogLogs = loggingSystemMock.collect(mockLogger).info.slice(4); @@ -418,6 +457,7 @@ describe('migrationsStateActionMachine', () => { }) ); }, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index. Please check the health of your Elasticsearch cluster and try again. Error: [snapshot_in_progress_exception]: Cannot delete indices that are being snapshotted]` @@ -450,6 +490,7 @@ describe('migrationsStateActionMachine', () => { next: () => { throw new Error('this action throws'); }, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index. Error: this action throws]` @@ -483,6 +524,7 @@ describe('migrationsStateActionMachine', () => { if (state.controlState === 'LEGACY_DELETE') throw new Error('this action throws'); return () => Promise.resolve('hello'); }, + client: esClient, }); } catch (e) { /** ignore */ @@ -680,4 +722,37 @@ describe('migrationsStateActionMachine', () => { ] `); }); + describe('cleanup', () => { + beforeEach(() => { + cleanupMock.mockClear(); + }); + it('calls cleanup function when an action throws', async () => { + await expect( + migrationStateActionMachine({ + initialState: { ...initialState, reason: 'the fatal reason' } as State, + logger: mockLogger.get(), + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), + next: () => { + throw new Error('this action throws'); + }, + client: esClient, + }) + ).rejects.toThrow(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + it('calls cleanup function when reaching the FATAL state', async () => { + await expect( + migrationStateActionMachine({ + initialState: { ...initialState, reason: 'the fatal reason' } as State, + logger: mockLogger.get(), + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), + next, + client: esClient, + }) + ).rejects.toThrow(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index 20177dda63b3b..85cc86fe0a468 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -9,8 +9,10 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import * as Option from 'fp-ts/lib/Option'; import { Logger, LogMeta } from '../../logging'; +import type { ElasticsearchClient } from '../../elasticsearch'; import { CorruptSavedObjectError } from '../migrations/core/migrate_raw_docs'; import { Model, Next, stateActionMachine } from './state_action_machine'; +import { cleanup } from './migrations_state_machine_cleanup'; import { State } from './types'; interface StateLogMeta extends LogMeta { @@ -19,7 +21,8 @@ interface StateLogMeta extends LogMeta { }; } -type ExecutionLog = Array< +/** @internal */ +export type ExecutionLog = Array< | { type: 'transition'; prevControlState: State['controlState']; @@ -31,6 +34,11 @@ type ExecutionLog = Array< controlState: State['controlState']; res: unknown; } + | { + type: 'cleanup'; + state: State; + message: string; + } >; const logStateTransition = ( @@ -41,14 +49,15 @@ const logStateTransition = ( tookMs: number ) => { if (newState.logs.length > oldState.logs.length) { - newState.logs.slice(oldState.logs.length).forEach((log) => { - const getLogger = (level: keyof Logger) => { - if (level === 'error') { - return logger[level] as Logger['error']; - } - return logger[level] as Logger['info']; - }; - getLogger(log.level)(logMessagePrefix + log.message); + newState.logs.slice(oldState.logs.length).forEach(({ message, level }) => { + switch (level) { + case 'error': + return logger.error(logMessagePrefix + message); + case 'info': + return logger.info(logMessagePrefix + message); + default: + throw new Error(`unexpected log level ${level}`); + } }); } @@ -99,11 +108,13 @@ export async function migrationStateActionMachine({ logger, next, model, + client, }: { initialState: State; logger: Logger; next: Next; model: Model; + client: ElasticsearchClient; }) { const executionLog: ExecutionLog = []; const startTime = Date.now(); @@ -112,11 +123,13 @@ export async function migrationStateActionMachine({ // indicate which messages come from which index upgrade. const logMessagePrefix = `[${initialState.indexPrefix}] `; let prevTimestamp = startTime; + let lastState: State | undefined; try { const finalState = await stateActionMachine( initialState, (state) => next(state), (state, res) => { + lastState = state; executionLog.push({ type: 'response', res, @@ -169,6 +182,7 @@ export async function migrationStateActionMachine({ }; } } else if (finalState.controlState === 'FATAL') { + await cleanup(client, executionLog, finalState); dumpExecutionLog(logger, logMessagePrefix, executionLog); return Promise.reject( new Error( @@ -180,6 +194,7 @@ export async function migrationStateActionMachine({ throw new Error('Invalid terminating control state'); } } catch (e) { + await cleanup(client, executionLog, lastState); if (e instanceof EsErrors.ResponseError) { logger.error( logMessagePrefix + `[${e.body?.error?.type}]: ${e.body?.error?.reason ?? e.message}` @@ -202,9 +217,13 @@ export async function migrationStateActionMachine({ ); } - throw new Error( + const newError = new Error( `Unable to complete saved object migrations for the [${initialState.indexPrefix}] index. ${e}` ); + + // restore error stack to point to a source of the problem. + newError.stack = `[${e.stack}]`; + throw newError; } } } diff --git a/src/plugins/vis_type_timeseries/public/application/index.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts similarity index 73% rename from src/plugins/vis_type_timeseries/public/application/index.ts rename to src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts index fcc0c592b1ef5..29967a1f75820 100644 --- a/src/plugins/vis_type_timeseries/public/application/index.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts @@ -6,5 +6,7 @@ * Side Public License, v 1. */ -export { EditorController, TSVB_EDITOR_NAME } from './editor_controller'; -export * from './lib'; +export const cleanupMock = jest.fn(); +jest.doMock('./migrations_state_machine_cleanup', () => ({ + cleanup: cleanupMock, +})); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts new file mode 100644 index 0000000000000..1881f9a712c29 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ElasticsearchClient } from '../../elasticsearch'; +import * as Actions from './actions'; +import type { State } from './types'; +import type { ExecutionLog } from './migrations_state_action_machine'; + +export async function cleanup( + client: ElasticsearchClient, + executionLog: ExecutionLog, + state?: State +) { + if (!state) return; + if ('sourceIndexPitId' in state) { + try { + await Actions.closePit(client, state.sourceIndexPitId)(); + } catch (e) { + executionLog.push({ + type: 'cleanup', + state, + message: e.message, + }); + } + } +} diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index 0267ae33dd157..57a7a7f2ea24a 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -17,7 +17,10 @@ import type { LegacyReindexState, LegacyReindexWaitForTaskState, LegacyDeleteState, - ReindexSourceToTempState, + ReindexSourceToTempOpenPit, + ReindexSourceToTempRead, + ReindexSourceToTempClosePit, + ReindexSourceToTempIndex, UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, OutdatedDocumentsSearch, @@ -25,7 +28,6 @@ import type { MarkVersionIndexReady, BaseState, CreateReindexTempState, - ReindexSourceToTempWaitForTaskState, MarkVersionIndexReadyConflict, CreateNewTargetState, CloneTempToSource, @@ -299,14 +301,12 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res) as FatalState; + const newState = model(initState, res) as WaitForYellowSourceState; - expect(newState.controlState).toEqual('WAIT_FOR_YELLOW_SOURCE'); - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_7.invalid.0_001', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.invalid.0_001'); }); + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { @@ -330,15 +330,14 @@ describe('migrations v2 model', () => { }, }, res - ); + ) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_7.11.0_001', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.11.0_001'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_3': { @@ -349,12 +348,10 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res); + const newState = model(initState, res) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_3', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_3'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -420,12 +417,10 @@ describe('migrations v2 model', () => { versionIndex: 'my-saved-objects_7.11.0_001', }, res - ); + ) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: 'my-saved-objects_3', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_3'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -449,12 +444,11 @@ describe('migrations v2 model', () => { versionIndex: 'my-saved-objects_7.12.0_001', }, res - ); + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_7.11.0'); - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: 'my-saved-objects_7.11.0', - }); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -662,7 +656,7 @@ describe('migrations v2 model', () => { const waitForYellowSourceState: WaitForYellowSourceState = { ...baseState, controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_3', + sourceIndex: Option.some('.kibana_3') as Option.Some, sourceIndexMappings: mappingsWithUnknownType, }; @@ -734,7 +728,7 @@ describe('migrations v2 model', () => { }); }); describe('CREATE_REINDEX_TEMP', () => { - const createReindexTargetState: CreateReindexTempState = { + const state: CreateReindexTempState = { ...baseState, controlState: 'CREATE_REINDEX_TEMP', versionIndexReadyActions: Option.none, @@ -742,80 +736,134 @@ describe('migrations v2 model', () => { targetIndex: '.kibana_7.11.0_001', tempIndexMappings: { properties: {} }, }; - it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP if action succeeds', () => { + it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP_OPEN_PIT if action succeeds', () => { const res: ResponseType<'CREATE_REINDEX_TEMP'> = Either.right('create_index_succeeded'); - const newState = model(createReindexTargetState, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP'); + const newState = model(state, res); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_OPEN_PIT'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); }); - describe('REINDEX_SOURCE_TO_TEMP', () => { - const reindexSourceToTargetState: ReindexSourceToTempState = { + + describe('REINDEX_SOURCE_TO_TEMP_OPEN_PIT', () => { + const state: ReindexSourceToTempOpenPit = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP', + controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT', versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, targetIndex: '.kibana_7.11.0_001', + tempIndexMappings: { properties: {} }, }; - test('REINDEX_SOURCE_TO_TEMP -> REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP'> = Either.right({ - taskId: 'reindex-task-id', + it('REINDEX_SOURCE_TO_TEMP_OPEN_PIT -> REINDEX_SOURCE_TO_TEMP_READ if action succeeds', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_OPEN_PIT'> = Either.right({ + pitId: 'pit_id', }); - const newState = model(reindexSourceToTargetState, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_READ'); + expect(newState.sourceIndexPitId).toBe('pit_id'); + expect(newState.lastHitSortValue).toBe(undefined); }); }); - describe('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', () => { - const state: ReindexSourceToTempWaitForTaskState = { + + describe('REINDEX_SOURCE_TO_TEMP_READ', () => { + const state: ReindexSourceToTempRead = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', targetIndex: '.kibana_7.11.0_001', - reindexSourceToTargetTaskId: 'reindex-task-id', + tempIndexMappings: { properties: {} }, + lastHitSortValue: undefined, }; - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is right', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.right( - 'reindex_succeeded' + + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_INDEX if the index has outdated documents to reindex', () => { + const outdatedDocuments = [{ _id: '1', _source: { type: 'vis' } }]; + const lastHitSortValue = [123456]; + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ + outdatedDocuments, + lastHitSortValue, + }); + const newState = model(state, res) as ReindexSourceToTempIndex; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_INDEX'); + expect(newState.outdatedDocuments).toBe(outdatedDocuments); + expect(newState.lastHitSortValue).toBe(lastHitSortValue); + }); + + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_CLOSE_PIT if no outdated documents to reindex', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + }); + const newState = model(state, res) as ReindexSourceToTempClosePit; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'); + expect(newState.sourceIndexPitId).toBe('pit_id'); + }); + }); + + describe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', () => { + const state: ReindexSourceToTempClosePit = { + ...baseState, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + versionIndexReadyActions: Option.none, + sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', + targetIndex: '.kibana_7.11.0_001', + tempIndexMappings: { properties: {} }, + }; + + it('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT -> SET_TEMP_WRITE_BLOCK if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'> = Either.right({}); + const newState = model(state, res) as ReindexSourceToTempIndex; + expect(newState.controlState).toBe('SET_TEMP_WRITE_BLOCK'); + expect(newState.sourceIndex).toEqual(state.sourceIndex); + }); + }); + + describe('REINDEX_SOURCE_TO_TEMP_INDEX', () => { + const state: ReindexSourceToTempIndex = { + ...baseState, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + outdatedDocuments: [], + versionIndexReadyActions: Option.none, + sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', + targetIndex: '.kibana_7.11.0_001', + lastHitSortValue: undefined, + }; + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right( + 'bulk_index_succeeded' ); const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is left target_index_had_write_block', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left target_index_had_write_block', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ type: 'target_index_had_write_block', }); - const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is left index_not_found_exception', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left index_not_found_exception for temp index', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ type: 'index_not_found_exception', - index: '.kibana_7.11.0_reindex_temp', + index: state.tempIndex, }); - const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK when response is left wait_for_task_completion_timeout', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ - message: '[timeout_exception] Timeout waiting for ...', - type: 'wait_for_task_completion_timeout', - }); - const newState = model(state, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'); - expect(newState.retryCount).toEqual(1); - expect(newState.retryDelay).toEqual(2000); - }); }); + describe('SET_TEMP_WRITE_BLOCK', () => { const state: SetTempWriteBlock = { ...baseState, diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index acf0f620136a2..2097b1de88aab 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -227,7 +227,7 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: source, + sourceIndex: Option.some(source) as Option.Some, sourceIndexMappings: indices[source].mappings, }; } else if (indices[stateP.legacyIndex] != null) { @@ -303,7 +303,7 @@ export const model = (currentState: State, resW: ResponseType): } } else if (stateP.controlState === 'LEGACY_SET_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; - // If the write block is sucessfully in place + // If the write block is successfully in place if (Either.isRight(res)) { return { ...stateP, controlState: 'LEGACY_CREATE_REINDEX_TARGET' }; } else if (Either.isLeft(res)) { @@ -431,14 +431,14 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some(source) as Option.Some, + sourceIndex: source, targetIndex: target, targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, stateP.sourceIndexMappings ), versionIndexReadyActions: Option.some([ - { remove: { index: source, alias: stateP.currentAlias, must_exist: true } }, + { remove: { index: source.value, alias: stateP.currentAlias, must_exist: true } }, { add: { index: target, alias: stateP.currentAlias } }, { add: { index: target, alias: stateP.versionAlias } }, { remove_index: { index: stateP.tempIndex } }, @@ -466,32 +466,61 @@ export const model = (currentState: State, resW: ResponseType): } else if (stateP.controlState === 'CREATE_REINDEX_TEMP') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { - return { ...stateP, controlState: 'REINDEX_SOURCE_TO_TEMP' }; + return { ...stateP, controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT' }; } else { // If the createIndex action receives an 'resource_already_exists_exception' // it will wait until the index status turns green so we don't have any // left responses to handle here. throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { return { ...stateP, - controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', - reindexSourceToTargetTaskId: res.right.taskId, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', + sourceIndexPitId: res.right.pitId, + lastHitSortValue: undefined, }; } else { - // Since this is a background task, the request should always succeed, - // errors only show up in the returned task. throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_READ') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { + if (res.right.outdatedDocuments.length > 0) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + outdatedDocuments: res.right.outdatedDocuments, + lastHitSortValue: res.right.lastHitSortValue, + }; + } return { ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + }; + } else { + throwBadResponse(stateP, res); + } + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + const { sourceIndexPitId, ...state } = stateP; + return { + ...state, controlState: 'SET_TEMP_WRITE_BLOCK', + sourceIndex: stateP.sourceIndex as Option.Some, + }; + } else { + throwBadResponse(stateP, res); + } + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_INDEX') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', }; } else { const left = res.left; @@ -510,28 +539,11 @@ export const model = (currentState: State, resW: ResponseType): // we know another instance already completed these. return { ...stateP, - controlState: 'SET_TEMP_WRITE_BLOCK', + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', }; - } else if (isLeftTypeof(left, 'wait_for_task_completion_timeout')) { - // After waiting for the specificed timeout, the task has not yet - // completed. Retry this step to see if the task has completed after an - // exponential delay. We will basically keep polling forever until the - // Elasticeasrch task succeeds or fails. - return delayRetryState(stateP, left.message, Number.MAX_SAFE_INTEGER); - } else if ( - isLeftTypeof(left, 'index_not_found_exception') || - isLeftTypeof(left, 'incompatible_mapping_exception') - ) { - // Don't handle the following errors as the migration algorithm should - // never cause them to occur: - // - incompatible_mapping_exception the temp index has `dynamic: false` - // mappings - // - index_not_found_exception for the source index, we will never - // delete the source index - throwBadResponse(stateP, left as never); - } else { - throwBadResponse(stateP, left); } + // should never happen + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'SET_TEMP_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; @@ -609,7 +621,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'OUTDATED_DOCUMENTS_SEARCH', }; } else { - throwBadResponse(stateP, res); + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') { const res = resW as ExcludeRetryableEsError>; @@ -647,10 +659,10 @@ export const model = (currentState: State, resW: ResponseType): } else { const left = res.left; if (isLeftTypeof(left, 'wait_for_task_completion_timeout')) { - // After waiting for the specificed timeout, the task has not yet + // After waiting for the specified timeout, the task has not yet // completed. Retry this step to see if the task has completed after an // exponential delay. We will basically keep polling forever until the - // Elasticeasrch task succeeds or fails. + // Elasticsearch task succeeds or fails. return delayRetryState(stateP, res.left.message, Number.MAX_SAFE_INTEGER); } else { throwBadResponse(stateP, left); diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index bb506cbca66fb..6d61634a6948e 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import { UnwrapPromise } from '@kbn/utility-types'; -import { pipe } from 'fp-ts/lib/pipeable'; +import type { UnwrapPromise } from '@kbn/utility-types'; import type { AllActionStates, - ReindexSourceToTempState, + ReindexSourceToTempOpenPit, + ReindexSourceToTempRead, + ReindexSourceToTempClosePit, + ReindexSourceToTempIndex, MarkVersionIndexReady, InitState, LegacyCreateReindexTargetState, @@ -27,18 +27,16 @@ import type { UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, CreateReindexTempState, - ReindexSourceToTempWaitForTaskState, MarkVersionIndexReadyConflict, CreateNewTargetState, CloneTempToSource, SetTempWriteBlock, WaitForYellowSourceState, + TransformRawDocs, } from './types'; import * as Actions from './actions'; import { ElasticsearchClient } from '../../elasticsearch'; -import { SavedObjectsRawDoc } from '..'; -export type TransformRawDocs = (rawDocs: SavedObjectsRawDoc[]) => Promise; type ActionMap = ReturnType; /** @@ -56,26 +54,43 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra INIT: (state: InitState) => Actions.fetchIndices(client, [state.currentAlias, state.versionAlias]), WAIT_FOR_YELLOW_SOURCE: (state: WaitForYellowSourceState) => - Actions.waitForIndexStatusYellow(client, state.sourceIndex), + Actions.waitForIndexStatusYellow(client, state.sourceIndex.value), SET_SOURCE_WRITE_BLOCK: (state: SetSourceWriteBlockState) => Actions.setWriteBlock(client, state.sourceIndex.value), CREATE_NEW_TARGET: (state: CreateNewTargetState) => Actions.createIndex(client, state.targetIndex, state.targetIndexMappings), CREATE_REINDEX_TEMP: (state: CreateReindexTempState) => Actions.createIndex(client, state.tempIndex, state.tempIndexMappings), - REINDEX_SOURCE_TO_TEMP: (state: ReindexSourceToTempState) => - Actions.reindex( + REINDEX_SOURCE_TO_TEMP_OPEN_PIT: (state: ReindexSourceToTempOpenPit) => + Actions.openPit(client, state.sourceIndex.value), + REINDEX_SOURCE_TO_TEMP_READ: (state: ReindexSourceToTempRead) => + Actions.readWithPit( client, - state.sourceIndex.value, + state.sourceIndexPitId, + state.unusedTypesQuery, + state.batchSize, + state.lastHitSortValue + ), + REINDEX_SOURCE_TO_TEMP_CLOSE_PIT: (state: ReindexSourceToTempClosePit) => + Actions.closePit(client, state.sourceIndexPitId), + REINDEX_SOURCE_TO_TEMP_INDEX: (state: ReindexSourceToTempIndex) => + Actions.transformDocs( + client, + transformRawDocs, + state.outdatedDocuments, state.tempIndex, - Option.none, - false, - state.unusedTypesQuery + /** + * Since we don't run a search against the target index, we disable "refresh" to speed up + * the migration process. + * Although any further step must run "refresh" for the target index + * before we reach out to the OUTDATED_DOCUMENTS_SEARCH step. + * Right now, we rely on UPDATE_TARGET_MAPPINGS + UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK + * to perform refresh. + */ + false ), SET_TEMP_WRITE_BLOCK: (state: SetTempWriteBlock) => Actions.setWriteBlock(client, state.tempIndex), - REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK: (state: ReindexSourceToTempWaitForTaskState) => - Actions.waitForReindexTask(client, state.reindexSourceToTargetTaskId, '60s'), CLONE_TEMP_TO_TARGET: (state: CloneTempToSource) => Actions.cloneIndex(client, state.tempIndex, state.targetIndex), UPDATE_TARGET_MAPPINGS: (state: UpdateTargetMappingsState) => @@ -89,16 +104,20 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra outdatedDocumentsQuery: state.outdatedDocumentsQuery, }), OUTDATED_DOCUMENTS_TRANSFORM: (state: OutdatedDocumentsTransform) => - pipe( - TaskEither.tryCatch( - () => transformRawDocs(state.outdatedDocuments), - (e) => { - throw e; - } - ), - TaskEither.chain((docs) => - Actions.bulkOverwriteTransformedDocuments(client, state.targetIndex, docs) - ) + // Wait for a refresh to happen before returning. This ensures that when + // this Kibana instance searches for outdated documents, it won't find + // documents that were already transformed by itself or another Kibana + // instance. However, this causes each OUTDATED_DOCUMENTS_SEARCH -> + // OUTDATED_DOCUMENTS_TRANSFORM cycle to take 1s so when batches are + // small performance will become a lot worse. + // The alternative is to use a search_after with either a tie_breaker + // field or using a Point In Time as a cursor to go through all documents. + Actions.transformDocs( + client, + transformRawDocs, + state.outdatedDocuments, + state.targetIndex, + 'wait_for' ), MARK_VERSION_INDEX_READY: (state: MarkVersionIndexReady) => Actions.updateAliases(client, state.versionIndexReadyActions.value), diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index 5e84bc23b1d16..50664bc9398fb 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -13,6 +13,13 @@ import { AliasAction } from './actions'; import { IndexMapping } from '../mappings'; import { SavedObjectsRawDoc } from '..'; +export type MigrationLogLevel = 'error' | 'info'; + +export interface MigrationLog { + level: MigrationLogLevel; + message: string; +} + export interface BaseState extends ControlState { /** The first part of the index name such as `.kibana` or `.kibana_task_manager` */ readonly indexPrefix: string; @@ -70,7 +77,7 @@ export interface BaseState extends ControlState { * In this case, you should set a smaller batchSize value and restart the migration process again. */ readonly batchSize: number; - readonly logs: Array<{ level: 'error' | 'info'; message: string }>; + readonly logs: MigrationLog[]; /** * The current alias e.g. `.kibana` which always points to the latest * version index @@ -132,7 +139,7 @@ export type FatalState = BaseState & { export interface WaitForYellowSourceState extends BaseState { /** Wait for the source index to be yellow before requesting it. */ readonly controlState: 'WAIT_FOR_YELLOW_SOURCE'; - readonly sourceIndex: string; + readonly sourceIndex: Option.Some; readonly sourceIndexMappings: IndexMapping; } @@ -158,21 +165,29 @@ export type CreateReindexTempState = PostInitState & { readonly sourceIndex: Option.Some; }; -export type ReindexSourceToTempState = PostInitState & { - /** Reindex documents from the source index into the target index */ - readonly controlState: 'REINDEX_SOURCE_TO_TEMP'; +export interface ReindexSourceToTempOpenPit extends PostInitState { + /** Open PIT to the source index */ + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT'; readonly sourceIndex: Option.Some; -}; +} -export type ReindexSourceToTempWaitForTaskState = PostInitState & { - /** - * Wait until reindexing documents from the source index into the target - * index has completed - */ - readonly controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'; - readonly sourceIndex: Option.Some; - readonly reindexSourceToTargetTaskId: string; -}; +export interface ReindexSourceToTempRead extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_READ'; + readonly sourceIndexPitId: string; + readonly lastHitSortValue: number[] | undefined; +} + +export interface ReindexSourceToTempClosePit extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'; + readonly sourceIndexPitId: string; +} + +export interface ReindexSourceToTempIndex extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX'; + readonly outdatedDocuments: SavedObjectsRawDoc[]; + readonly sourceIndexPitId: string; + readonly lastHitSortValue: number[] | undefined; +} export type SetTempWriteBlock = PostInitState & { /** @@ -302,8 +317,10 @@ export type State = | SetSourceWriteBlockState | CreateNewTargetState | CreateReindexTempState - | ReindexSourceToTempState - | ReindexSourceToTempWaitForTaskState + | ReindexSourceToTempOpenPit + | ReindexSourceToTempRead + | ReindexSourceToTempClosePit + | ReindexSourceToTempIndex | SetTempWriteBlock | CloneTempToSource | UpdateTargetMappingsState @@ -324,3 +341,5 @@ export type AllControlStates = State['controlState']; * 'FATAL' and 'DONE'). */ export type AllActionStates = Exclude; + +export type TransformRawDocs = (rawDocs: SavedObjectsRawDoc[]) => Promise; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index c0e2cdc333363..8faa476b77bfa 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1917,10 +1917,7 @@ export class SavedObjectsRepository { ...(preference ? { preference } : {}), }; - const { - body, - statusCode, - } = await this.client.openPointInTime( + const { body, statusCode } = await this.client.openPointInTime( // @ts-expect-error @elastic/elasticsearch OpenPointInTimeRequest.index expected to accept string[] esOptions, { diff --git a/src/core/server/saved_objects/status.ts b/src/core/server/saved_objects/status.ts index 24e87d2924543..95bf6ddd9ff52 100644 --- a/src/core/server/saved_objects/status.ts +++ b/src/core/server/saved_objects/status.ts @@ -18,11 +18,20 @@ export const calculateStatus$ = ( ): Observable> => { const migratorStatus$: Observable> = rawMigratorStatus$.pipe( map((migrationStatus) => { - if (migrationStatus.status === 'waiting') { + if (migrationStatus.status === 'waiting_to_start') { return { level: ServiceStatusLevels.unavailable, summary: `SavedObjects service is waiting to start migrations`, }; + } else if (migrationStatus.status === 'waiting_for_other_nodes') { + return { + level: ServiceStatusLevels.unavailable, + summary: `SavedObjects service is waiting for other nodes to complete the migration`, + detail: + `If no other Kibana instance is attempting ` + + `migrations, you can get past this message by deleting index ${migrationStatus.waitingIndex} and ` + + `restarting Kibana.`, + }; } else if (migrationStatus.status === 'running') { return { level: ServiceStatusLevels.unavailable, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b4c6ee323cbac..56759edbd6533 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -175,6 +175,9 @@ import { URL } from 'url'; export { AddConfigDeprecation } +// @public +export const APP_WRAPPER_CLASS = "kbnAppWrapper"; + // @public export interface AppCategory { ariaLabel?: string; @@ -788,6 +791,7 @@ export class CspConfig implements ICspConfig { // @public export interface CustomHttpResponseOptions { body?: T; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; // (undocumented) statusCode: number; @@ -1078,6 +1082,7 @@ export interface HttpResourcesServiceToolkit { // @public export interface HttpResponseOptions { body?: HttpResponsePayload; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; } @@ -3261,7 +3266,7 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/elasticsearch/client/types.ts:94:7 - (ae-forgotten-export) The symbol "Explanation" needs to be exported by the entry point index.d.ts -// src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts +// src/core/server/http/router/response.ts:301:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:329:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/status/legacy_status.ts b/src/core/server/status/legacy_status.ts index b7d0965e31f68..1b3d139b1345e 100644 --- a/src/core/server/status/legacy_status.ts +++ b/src/core/server/status/legacy_status.ts @@ -95,7 +95,7 @@ const serviceStatusToHttpComponent = ( since: string ): StatusComponentHttp => ({ id: serviceName, - message: status.summary, + message: [status.summary, status.detail].filter(Boolean).join(' '), since, ...serviceStatusAttrs(status), }); diff --git a/src/core/server/status/routes/status.ts b/src/core/server/status/routes/status.ts index c1782570ecfa0..72f639231996f 100644 --- a/src/core/server/status/routes/status.ts +++ b/src/core/server/status/routes/status.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { MetricsServiceSetup } from '../../metrics'; -import { ServiceStatus, CoreStatus } from '../types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from '../types'; import { PluginName } from '../../plugins'; import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status'; import { PackageInfo } from '../../config'; @@ -160,7 +160,8 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) = }, }; - return res.ok({ body }); + const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200; + return res.custom({ body, statusCode, bypassErrorFormat: true }); } ); }; diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 7724e7a5e44b4..cfd4d92d91d3f 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -88,9 +88,7 @@ export class StatusService implements CoreService { // Create an unused subscription to ensure all underlying lazy observables are started. this.overallSubscription = overall$.subscribe(); - const router = http.createRouter(''); - registerStatusRoute({ - router, + const commonRouteDeps = { config: { allowAnonymous: statusConfig.allowAnonymous, packageInfo: this.coreContext.env.packageInfo, @@ -103,8 +101,27 @@ export class StatusService implements CoreService { plugins$: this.pluginsStatus.getAll$(), core$, }, + }; + + const router = http.createRouter(''); + registerStatusRoute({ + router, + ...commonRouteDeps, }); + if (http.notReadyServer && commonRouteDeps.config.allowAnonymous) { + http.notReadyServer.registerRoutes('', (notReadyRouter) => { + registerStatusRoute({ + router: notReadyRouter, + ...commonRouteDeps, + config: { + ...commonRouteDeps.config, + allowAnonymous: true, + }, + }); + }); + } + return { core$, overall$, diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 950ab5f4392e1..dbf19f84825be 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from 'elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; import { // @ts-expect-error https://github.com/elastic/kibana/issues/95679 @@ -140,7 +140,7 @@ export interface TestElasticsearchServer { start: (esArgs: string[], esEnvVars: Record) => Promise; stop: () => Promise; cleanup: () => Promise; - getClient: () => Client; + getClient: () => KibanaClient; getCallCluster: () => LegacyAPICaller; getUrl: () => string; } diff --git a/src/core/utils/app_wrapper_class.ts b/src/core/utils/app_wrapper_class.ts new file mode 100644 index 0000000000000..51230cbbb6f78 --- /dev/null +++ b/src/core/utils/app_wrapper_class.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * The class name for top level *and* nested application wrappers to ensure proper layout + * @public + */ +export const APP_WRAPPER_CLASS = 'kbnAppWrapper'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index a76138399f0f8..73980983a12e1 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -7,3 +7,4 @@ */ export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; +export { APP_WRAPPER_CLASS } from './app_wrapper_class'; diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 2c9dfbe6fcc10..3aed49b5015bb 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -139,7 +139,6 @@ export const TEMPORARILY_IGNORED_PATHS = [ 'test/functional/apps/management/exports/_import_objects-conflicts.json', 'x-pack/legacy/plugins/index_management/public/lib/editSettings.js', 'x-pack/legacy/plugins/license_management/public/store/reducers/licenseManagement.js', - 'x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js', 'x-pack/plugins/monitoring/public/icons/health-gray.svg', 'x-pack/plugins/monitoring/public/icons/health-green.svg', 'x-pack/plugins/monitoring/public/icons/health-red.svg', @@ -150,28 +149,4 @@ export const TEMPORARILY_IGNORED_PATHS = [ 'x-pack/plugins/reporting/server/export_types/common/assets/fonts/roboto/Roboto-Medium.ttf', 'x-pack/plugins/reporting/server/export_types/common/assets/fonts/roboto/Roboto-Regular.ttf', 'x-pack/plugins/reporting/server/export_types/common/assets/img/logo-grey.png', - 'x-pack/test/functional/es_archives/monitoring/beats-with-restarted-instance/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/beats-with-restarted-instance/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/logstash-pipelines/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/logstash-pipelines/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/multi-basic/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/multi-basic/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-basic-beats/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-basic-beats/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-green-gold/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-green-gold/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-green-platinum/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-green-platinum/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-green-trial-two-nodes-one-cgrouped/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-green-trial-two-nodes-one-cgrouped/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-red-platinum/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-red-platinum/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-three-nodes-shard-relocation/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-three-nodes-shard-relocation/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-basic/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-basic/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum--with-10-alerts/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum--with-10-alerts/mappings.json', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum/data.json.gz', - 'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum/mappings.json', ]; diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx index fd11314bdbd66..b897c1c73b89b 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx @@ -59,7 +59,6 @@ function mockConfig() { isCustom: (key: string) => false, isOverridden: (key: string) => Boolean(config.getAll()[key].isOverridden), getRegistered: () => ({} as Readonly>), - overrideLocalDefault: (key: string, value: any) => {}, getUpdate$: () => new Observable<{ key: string; diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 50b39114d2143..854a70ae48a97 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { ReactElement } from 'react'; import { UiCounterMetricType } from '@kbn/analytics'; import { UiSettingsType, StringValidation, ImageValidation } from '../../../../core/public'; @@ -13,7 +14,7 @@ export interface FieldSetting { displayName: string; name: string; value: unknown; - description?: string; + description?: string | ReactElement; options?: string[]; optionLabels?: Record; requiresPageReload: boolean; diff --git a/src/plugins/console/public/application/components/console_menu.tsx b/src/plugins/console/public/application/components/console_menu.tsx index 6c5eb8646c58d..1abbcfc2fdeb6 100644 --- a/src/plugins/console/public/application/components/console_menu.tsx +++ b/src/plugins/console/public/application/components/console_menu.tsx @@ -8,6 +8,8 @@ import React, { Component } from 'react'; +import { NotificationsSetup } from 'src/core/public'; + import { EuiIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -17,7 +19,7 @@ interface Props { getCurl: () => Promise; getDocumentation: () => Promise; autoIndent: (ev: React.MouseEvent) => void; - addNotification?: (opts: { title: string }) => void; + notifications: NotificationsSetup; } interface State { @@ -42,25 +44,30 @@ export class ConsoleMenu extends Component { }); }; - copyAsCurl() { - this.copyText(this.state.curlCode); - const { addNotification } = this.props; - if (addNotification) { - addNotification({ + async copyAsCurl() { + const { notifications } = this.props; + try { + await this.copyText(this.state.curlCode); + notifications.toasts.add({ title: i18n.translate('console.consoleMenu.copyAsCurlMessage', { defaultMessage: 'Request copied as cURL', }), }); + } catch (e) { + notifications.toasts.addError(e, { + title: i18n.translate('console.consoleMenu.copyAsCurlFailedMessage', { + defaultMessage: 'Could not copy request as cURL', + }), + }); } } - copyText(text: string) { - const textField = document.createElement('textarea'); - textField.innerText = text; - document.body.appendChild(textField); - textField.select(); - document.execCommand('copy'); - textField.remove(); + async copyText(text: string) { + if (window.navigator?.clipboard) { + await window.navigator.clipboard.writeText(text); + return; + } + throw new Error('Could not copy to clipboard!'); } onButtonClick = () => { @@ -107,7 +114,7 @@ export class ConsoleMenu extends Component { { this.closePopover(); this.copyAsCurl(); diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index 541ad8b0563a4..c242435550606 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -232,7 +232,7 @@ function EditorUI({ initialTextValue }: EditorProps) { autoIndent={(event) => { autoIndent(editorInstanceRef.current!, event); }} - addNotification={({ title }) => notifications.toasts.add({ title })} + notifications={notifications} /> diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx index 3d05bc14a68d1..23c1c515cc9fc 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx @@ -9,6 +9,12 @@ import { EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useRef } from 'react'; + +// Ensure the modes we might switch to dynamically are available +import 'brace/mode/text'; +import 'brace/mode/json'; +import 'brace/mode/yaml'; + import { expandLiteralStrings } from '../../../../../shared_imports'; import { useEditorReadContext, @@ -19,11 +25,25 @@ import { createReadOnlyAceEditor, CustomAceEditor } from '../../../../models/leg import { subscribeResizeChecker } from '../subscribe_console_resize_checker'; import { applyCurrentSettings } from './apply_editor_settings'; +const isJSONContentType = (contentType?: string) => + Boolean(contentType && contentType.indexOf('application/json') >= 0); + +/** + * Best effort expand literal strings + */ +const safeExpandLiteralStrings = (data: string): string => { + try { + return expandLiteralStrings(data); + } catch (e) { + return data; + } +}; + function modeForContentType(contentType?: string) { if (!contentType) { return 'ace/mode/text'; } - if (contentType.indexOf('application/json') >= 0) { + if (isJSONContentType(contentType)) { return 'ace/mode/json'; } else if (contentType.indexOf('application/yaml') >= 0) { return 'ace/mode/yaml'; @@ -58,16 +78,21 @@ function EditorOutputUI() { const editor = editorInstanceRef.current!; if (data) { const mode = modeForContentType(data[0].response.contentType); - editor.session.setMode(mode); editor.update( data - .map((d) => d.response.value as string) - .map(readOnlySettings.tripleQuotes ? expandLiteralStrings : (a) => a) - .join('\n') + .map((result) => { + const { value, contentType } = result.response; + if (readOnlySettings.tripleQuotes && isJSONContentType(contentType)) { + return safeExpandLiteralStrings(value as string); + } + return value; + }) + .join('\n'), + mode ); } else if (error) { - editor.session.setMode(modeForContentType(error.response.contentType)); - editor.update(error.response.value as string); + const mode = modeForContentType(error.response.contentType); + editor.update(error.response.value as string, mode); } else { editor.update(''); } diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index f6525377cce70..435659b685280 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -1,9 +1,3 @@ -.dshAppContainer { - display: flex; - flex-direction: column; - flex: 1; -} - .dashboardViewport { flex: 1; display: flex; diff --git a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx index cda2f76930627..77b136de9d7c1 100644 --- a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx +++ b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx @@ -25,8 +25,13 @@ import { import { DashboardCopyToCapabilities } from './copy_to_dashboard_action'; import { LazyDashboardPicker, withSuspense } from '../../services/presentation_util'; import { dashboardCopyToDashboardAction } from '../../dashboard_strings'; -import { EmbeddableStateTransfer, IEmbeddable } from '../../services/embeddable'; -import { createDashboardEditUrl, DashboardConstants } from '../..'; +import { + EmbeddableStateTransfer, + IEmbeddable, + PanelNotFoundError, +} from '../../services/embeddable'; +import { createDashboardEditUrl, DashboardConstants, DashboardContainer } from '../..'; +import { DashboardPanelState } from '..'; interface CopyToDashboardModalProps { capabilities: DashboardCopyToCapabilities; @@ -53,9 +58,16 @@ export function CopyToDashboardModal({ ); const onSubmit = useCallback(() => { + const dashboard = embeddable.getRoot() as DashboardContainer; + const panelToCopy = dashboard.getInput().panels[embeddable.id] as DashboardPanelState; + if (!panelToCopy) { + throw new PanelNotFoundError(); + } const state = { - input: omit(embeddable.getInput(), 'id'), type: embeddable.type, + input: { + ...omit(panelToCopy.explicitInput, 'id'), + }, }; const path = diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index e7e2ccfd46b9c..fa86fb81bd407 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -303,7 +303,7 @@ export function DashboardApp({ }, [data.search.session]); return ( -
+ <> {savedDashboard && dashboardStateManager && dashboardContainer && viewMode && ( <> )} -
+ ); } diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 138d665866af0..44beed5e4a89b 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -160,14 +160,12 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = ` }, "get$": [MockFunction], "getAll": [MockFunction], - "getSaved$": [MockFunction], "getUpdate$": [MockFunction], "getUpdateErrors$": [MockFunction], "isCustom": [MockFunction], "isDeclared": [MockFunction], "isDefault": [MockFunction], "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], "remove": [MockFunction], "set": [MockFunction], } @@ -493,14 +491,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` }, "get$": [MockFunction], "getAll": [MockFunction], - "getSaved$": [MockFunction], "getUpdate$": [MockFunction], "getUpdateErrors$": [MockFunction], "isCustom": [MockFunction], "isDeclared": [MockFunction], "isDefault": [MockFunction], "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], "remove": [MockFunction], "set": [MockFunction], } @@ -840,14 +836,12 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` }, "get$": [MockFunction], "getAll": [MockFunction], - "getSaved$": [MockFunction], "getUpdate$": [MockFunction], "getUpdateErrors$": [MockFunction], "isCustom": [MockFunction], "isDeclared": [MockFunction], "isDefault": [MockFunction], "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], "remove": [MockFunction], "set": [MockFunction], } diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e23c249cc7e7a..02403999cd75c 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -620,36 +620,38 @@ export function DashboardTopNav({ return ( <> - {viewMode !== ViewMode.VIEW ? ( - - {{ - primaryActionButton: ( - - ), - quickButtonGroup: , - addFromLibraryButton: ( - - ), - extraButtons: [ - , - ], - }} - + <> + + + {{ + primaryActionButton: ( + + ), + quickButtonGroup: , + addFromLibraryButton: ( + + ), + extraButtons: [ + , + ], + }} + + ) : null} ); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 0fad1c51f433a..0c4ef8c58f949 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -12,6 +12,7 @@ import { filter, map } from 'rxjs/operators'; import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { APP_WRAPPER_CLASS } from '../../../core/public'; import { App, Plugin, @@ -292,7 +293,7 @@ export class DashboardPlugin category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { this.currentHistory = params.history; - params.element.classList.add('dshAppContainer'); + params.element.classList.add(APP_WRAPPER_CLASS); const { mountApp } = await import('./application/dashboard_router'); appMounted(); return mountApp({ diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 53ad922ece635..b19e68089fea5 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -32,7 +32,6 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; -import { ISavedObjectsRepository } from 'src/core/server'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; import { ISearchSource } from 'src/plugins/data/public'; diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 0df921dc99ad7..030d7be8ea7e1 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -272,19 +272,22 @@ export function Discover({ - setIsSidebarClosed(!isSidebarClosed)} - data-test-subj="collapseSideBarButton" - aria-controls="discover-sidebar" - aria-expanded={isSidebarClosed ? 'false' : 'true'} - aria-label={i18n.translate('discover.toggleSidebarAriaLabel', { - defaultMessage: 'Toggle sidebar', - })} - buttonRef={collapseIcon} - /> +
+ + setIsSidebarClosed(!isSidebarClosed)} + data-test-subj="collapseSideBarButton" + aria-controls="discover-sidebar" + aria-expanded={isSidebarClosed ? 'false' : 'true'} + aria-label={i18n.translate('discover.toggleSidebarAriaLabel', { + defaultMessage: 'Toggle sidebar', + })} + buttonRef={collapseIcon} + /> +
diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss b/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss index 5bb6c01da5ad6..cb1b9a8ea191e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss @@ -11,6 +11,7 @@ .euiDataGridRowCell.euiDataGridRowCell--firstColumn { border-left: none; + padding: 0; } .euiDataGridRowCell.euiDataGridRowCell--lastColumn { diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx index da91ec1c842a8..df7e2285a0754 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx @@ -20,7 +20,7 @@ export function getLeadControlColumns() { return [ { id: 'openDetails', - width: 32, + width: 24, headerCellRender: () => ( @@ -34,7 +34,7 @@ export function getLeadControlColumns() { }, { id: 'select', - width: 32, + width: 24, rowCellRender: SelectButton, headerCellRender: () => ( diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx index 73778d7453af4..115acb84b95d8 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx @@ -38,7 +38,7 @@ export const ExpandButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle return ( { return ( - + - + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { defaultMessage: 'Filter by type', })} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss index 4540a945d4884..139230fbdb66a 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -1,5 +1,5 @@ .dscSidebar { - margin: 0; + margin: 0 !important; flex-grow: 1; padding-left: $euiSize; width: $euiSize * 19; diff --git a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index f7ee1f3c741c4..9072c26576097 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -120,9 +120,10 @@ // EDITING MODE .embPanel--editing { - border-style: dashed; - border-color: $euiColorMediumShade; + border-style: dashed !important; + border-color: $euiColorMediumShade !important; transition: all $euiAnimSpeedFast $euiAnimSlightResistance; + border-width: $euiBorderWidthThin; &:hover, &:focus { diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/default_value.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/default_value.mdx new file mode 100644 index 0000000000000..5e03d6ad16528 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/default_value.mdx @@ -0,0 +1,96 @@ +--- +id: formLibCoreDefaultValue +slug: /form-lib/core/default-value +title: Default value +summary: Initiate a field with the correct value +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +There are multiple places where you can define the default value of a field. By "default value" we are saying "the initial value" of a field. Once the field is initiated it has its own internal state and can't be controlled. + +## Order of precedence + +1. As a prop on the `` component +2. In the **form** `defaultValue` config passed to `useForm({ defaultValue: { ... } })` +3. In the **field** `defaultValue` config parameter (either passed as prop to `` prop or declared inside a form schema) +4. If no default value is found above, it defaults to `""` (empty string) + +### As a prop on `` + +This takes over any other `defaultValue` defined elsewhere. What you provide as prop is what you will have as default value for the field. Remember that the `` **is not** a controlled component, so changing the `defaultValue` prop to another value does not have any effect. + +```js +// Here we manually set the default value + +``` + +### In the form `defaultValue` config passed to `useForm()` + +The above solution works well for very small forms, but with larger form it is not very convenient to manually add the default value of each field. + +```js +// Let's imagine some data coming from the server +const fetchedData = { + user: { + firstName: 'John', + lastName: 'Snow', + } +} + +// We need to manually write each connection, which is not convenient + + +``` + +It is much easier to provide the `defaultValue` object (probably some data that we have fetched from the server) at the form level + +```js +const { form } = useForm({ defaultValue: fetchedData }); + +// And the defaultValue for each field will be automatically mapped to its paths + + +``` + +### In the field `defaultValue` config parameter of the field config + +When you are creating a new resource, the form is empty and there is no data coming from the server to map. You still might want to define a default value for your fields. + +```js +interface Props { + fetchedData?: { foo: boolean } +} + +export const MyForm = ({ fetchedData }: Props) => { + // fetchedData can be "undefined" or an object. + // If it is undefined, then the config.defaultValue will be used + const { form } = useForm({ defaultValue: fetchedData }); + + return ( + + ); +} +``` + +Or the same but using a form schema + +```js +const schema = { + // Field config for the path "foo" declared below + foo: { + defaultValue: true, + }, +}; + +export const MyComponent = ({ fetchedData }: Props) => { + // 1. If "fetchedData" is not undefined **and** there is a value at the "foo" path, use it + // 2. otherwise, if there is a schema with a config at the "foo" path, read its "defaultValue" + // 3. otherwise use an "" (empty string) + const { form } = useForm({ schema, defaultValue: fetchedData }); + + return ( + + ); +} +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/field_hook.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/field_hook.mdx new file mode 100644 index 0000000000000..c7be88c4336a6 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/field_hook.mdx @@ -0,0 +1,188 @@ +--- +id: formLibCoreFieldHook +slug: /form-lib/core/field-hook +title: Field hook +summary: You don't manually create them but you'll get all the love from them +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +When you use the `` component you receive back a `field` hook object that you can connect to your React components. + +This hook has the following properties and handlers: + +## Properties + +### path + +**Type:** `string` + +The field `path`. + +### label + +**Type:** `string` + +The field `label` provided in the config. + +### labelAppend + +**Type:** `string | ReactNode` + +The field `labelAppend` provided in the config. + +### helpText + +**Type:** `string | ReactNode` + +The field `helpText` provided in the config. + +### type + +**Type:** `string` + +The field `type` provided in the config. + +### value + +**Type:** `T` + +The field state value. + +### errors + +**Type:** `ValidationError[]` + +An array of possible validation errors. Each error has a required `message` property and any other meta data returned by your validation(s). + +### isValid + +**Type:** `boolean` + +Flag that indicates if the field is valid. + +### isPristine + +**Type:** `boolean` + +Flag that indicates if the field is pristine (if it hasn't been modified by the user). + +### isValidating + +**Type:** `boolean` + +Flag that indicates if the field is being validated. It is set to `true` when the value changes, and back to `false` right after all the validations have executed. If all your validations are synchronous, this state is always `false`. + +### isValidated + +**Type:** `boolean` + +Flag that indicates if this field has run at least once its validation(s). The validations are run when the field values changes or, if the field value has not changed, when we call `form.submit()` or `form.validate()`. + +### isChangingValue + +**Type:** `boolean` + +Flag that indicates if the field value is changing. If you have set the [`valueChangeDebounceTime`](use_field.md#valuechangedebouncetime) to `0`, then this state is the same as the `isValidating` state. But if you have increased the `valueChangeDebounceTime` time, then you will have a minimum value changing time. This is useful if you want to display your validation errors after a certain amount of time has passed. + +## Handlers + +### setValue() + +**Arguments:** `value: T | (prevValue: T) => T` +**Returns:** `void` + +Handler to set the value of the field. +You can either pass the value directly or provide a callback that will receive the previous field value and you will have to return the next value. + +### onChange() + +**Arguments:** `event: React.ChangeEvent` +**Returns:** `void` + +Use the `onChange` helper to directly hook into the forms fields inputs `onChange` prop without having to extract the event value and call `setValue()` on the field. + +```js +// Instead of this + + {({ setValue }) => { + return setValue(e.target.value)} /> + }} + + +// You can use the "onChange" handler + + {({ onChange }) => { + return + }} + +``` + +### setErrors() + +**Arguments:** `ValidationError[]` +**Returns:** `void` + +Handler to set the errors of the field. + +### clearErrors() + +**Arguments:** `type?: string | string[]` +**Returns:** `void` + +Handler to clear the errors of the field. You can optionally provide the type of error to clear. +See an example of typed validation when . + +### getErrorsMessages() + +**Arguments:** `options?: { validationType?: string; errorCode?: string }` +**Returns:** `string | null` + +Returns a concatenated string with all the error messages if the field has errors, or `null` otherwise. + +You can optionally provide an error code or a validation type to narrow down the errors you want to receive back. + +**Note:** You can add error code to your errors by adding a `code` property to your validation errors. + +```js +const nameValidator = ({ value }) => { + if (value.startsWith('.')) => { + return { + message: "The name can't start with a dot (.)", + code: 'ERR_NAME_FORMAT', + }; + } +}; +``` + +### validate() + +**Arguments:** `options?: { formData?: any; value?: T; validationType?: string; }` +**Returns:** `FieldValidateResponse | Promise` + +Validate the field by calling all the validations declared in its config. Optionally you can provide an options object with the following properties: + +* `formData` - The form data +* `value` - The value to validate +* `validationType` - The validation type to run against the value + +You rarely need to manually call this method as it is automatically done for you whenever the field value changes. + +**Important:** Calling `validate()` **does not update** the form `isValid` state and is only meant to get the field validity at a point in time. + +#### Example where you might need this method: + +The user changes the value inside one of your components and you receive this value in an `onChange` handler. Before updating the field value with `setValue()`, you want to validate this value and maybe prevent the field `value` to be updated at all. + +### reset() + +**Arguments:** `options?: { resetValue?: boolean; defaultValue?: T }` +**Returns:** `T | undefined` + +Resets the field to its initial state. It accepts an optional configuration object: + +- `resetValue` (default: `true`). Flag to indicate if we want to not only reset the field state (`errors`, `isPristine`...) but also the field value. If set to `true`, it will put back the default value passed to the field, or to the form, or declared on the field config (in that order). + +- `defaultValue`. In some cases you might not want to reset the field to the default value initiallly provided. In this case you can provide a new `defaultValue` value when resetting. + +If you provided a new `defaultValue`, you will receive back this value after it has gone through any possible `deserializer(s)` defined for that field. If you didn't provide a default value `undefined` is returned. diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/form_component.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/form_component.mdx new file mode 100644 index 0000000000000..df479b5c72f37 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/form_component.mdx @@ -0,0 +1,79 @@ +--- +id: formLibCoreFormComponent +slug: /form-lib/core/form-component +title:
+summary: The boundary of your form +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +Once you have created , you can wrap your form with the `` component. + +This component accepts the following props. + +## Props + +### form (required) + +**Type:** `FormHook` + +The form hook you've created with `useForm()`. + +```js +const MyFormComponent = () => { + const { form } = useForm(); + + return ( + + ... + + ); +}; +``` + +### FormWrapper + +**Type:** `React.ComponentType` +**Default:**: `EuiForm` + +This is the component that will wrap your form fields. By default it renders the `` component. + +Any props that you pass to the `
` component, except the `form` hook, will be forwarded to that component. + +```js +const MyFormComponent = () => { + const { form } = useForm(); + + // "isInvalid" and "error" are 2 props from + return ( + + ... + + ); +}; +``` + +By default, `` wraps the form with a `
` element. In some cases semantic HTML is preferred: wrapping your form with the `
` element. This also allows the user to submit the form by hitting the "ENTER" key inside a field. + +**Important:** Make sure to **not** declare the FormWrapper inline on the prop but outside of your component. + +```js +// Create a wrapper component with the element +const FormWrapper = (props: any) => ; + +export const MyFormComponent = () => { + const { form } = useForm(); + + // Hitting the "ENTER" key in a textfield will submit the form. + const submitForm = async () => { + const { isValid, data } = await form.submit(); + ... + }; + + return ( + + ... +
+ ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/form_hook.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/form_hook.mdx new file mode 100644 index 0000000000000..d66c0d867c275 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/form_hook.mdx @@ -0,0 +1,214 @@ +--- +id: formLibCoreFormHook +slug: /form-lib/core/form-hook +title: Form hook +summary: The heart of the lib; It manages your fields so you don't have to +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +When you call `useForm()` you receive back a `form` hook object. +This object has the following properties and handlers + +## Properties + +### isSubmitted + +**Type:** `boolean` + +Flag that indicates if the form has been submitted at least once. It is set to `true` when we call . + +**Note:** If you have a dynamic form where fields are removed and added, the `isSubmitted` is set to `false` whenever a new field is added, as in such case the user has a new form in front of him. + +### isSubmitting + +**Type:** `boolean` + +Flag that indicates if the form is being submitted. When we submit the form, if you have provided an in the config, it might take some time to resolve (e.g. an HTTP request being made). This flag will be set to `true` until the Promise resolves. + +### isValid + +**Type:** `boolean | undefined` + +Flag that indicates if the form is valid. It can have three values: +* `true` +* `false` +* `undefined` + +When the form first renders, its validity is neither `true` nor `false`. It is `undefined`, we don't know its validity. It could be valid if none of the fields are required or invalid if some field is required. + +Each time a field value changes, it is validated. When **all** fields have changed (are dirty), then only the `isValid` is either `true` or `false`, as at this stage we know the form validity. Of course we will probably need to know the validity of the form without updating each field one by one. There are two ways of doing that: + +* calling `form.submit()` + +```js +export const MyComponent = () => { + const { form } = useForm(); + + const onClickSubmit = async () => { + // We validate all the form fields and get its "isValid" state (true|false) + const { isValid, data } = await form.submit(); + + if (isValid) { + // ... + } + }; + + return ( +
+ ... + + {form.isValid === false && ( +
Only show this message if the form validity is "false".
+ )} +
+ ); +} +``` + +* calling the `validate()` handler on the form. As you can see in the example below, as we don't use the `form.submit()`, we have to manually declare and update the `isSubmitting` and `isSubmitted` states. + +**Note:** It is usually better to use `form.submit()`, but you might need at some stage to know the form validity without updating its `isSubmitted` state, and that's what `validate()` is for. + +```js +export const MyComponent = ({ onFormUpdate }: Props) => { + const [isSubmitted, setIsSubmitted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { form } = useForm(); + + const onClickSubmit = async () => { + setIsSubmitted(true); + setIsSubmitting(true); + + // If the "isValid" state is "undefined" (=== not all the fields are dirty), + // call validate() to run validation on all the fields. + const isValid = form.isValid ?? (await form.validate()); + setIsSubmitting(false); + + if (isValid) { + console.log('Form data:', form.getFormData()); + } + }; + + const hasErrors = isSubmitted && form.isValid === false; + + return ( +
+ + + + + {hasErrors &&
Form is invalid.
} + + ); +}; +``` + +### id + +**Type:** `string` + +The form id. If none was provided, "default" will be returned. + +## Handlers + +### submit() + +**Returns:** `Promise<{ data: T | {}, isValid: boolean }>` + +This handler submits the form and returns its data and validity. If the form is not valid, the data will be `null` as only valid data is passed through the `serializer(s)` before being returned. + +```js +const { data, isValid } = await form.submit(); +``` + +### validate() + +**Returns:** `Promise` + +Use this handler to get the validity of the form. + +```js +const isFormValid = await form.validate(); +``` + +### getFields() + +**Returns:** `{ [path: string]: FieldHook }` + +Access any field on the form. + +```js +const { name: nameField } = form.getFields(); +``` + +### getFormData() + +**Arguments:** `options?: { unflatten?: boolean }` +**Returns:** `T | R` + +Return the form data. Accepts an optional `options` with an `unflatten` parameter (defaults to `true`). If you are only interested in the raw form data, pass `unflatten: false` to the handler. + +```js +const formData = form.getFormData(); + +const rawFormData = form.getFormData({ unflatten: false }); +``` + +### getErrors() + +**Returns:** `string[]` + +Returns an array of all errors in the form. + +```js +const errors = form.getErrors(); +``` + +### reset() + +**Arguments:** `options?: { resetValues?: boolean; defaultValue?: any }` + +Resets the form to its initial state. It accepts an optional configuration object: + +- `resetValues` (default: `true`). Flag to indicate if we want to not only reset the form state (`isValid`, `isSubmitted`...) but also the field values. If set to `true` all form values will be reset to their default value. + +- `defaultValue`. In some cases you might not want to reset the form to the default value initially provided to the form (probably because it is data that came from the server and you want a clean form). In this case you can provide a new `defaultValue` object when resetting. + +```js +// Reset to the defaultValue object passed to the form +// If none was provided, reset to the field config defaultValue. +form.reset(); + +// Reset to the default value declared on the **field config** defaultValue +form.reset({ defaultValue: {} }); + +// You can keep some current field value and the rest will come from the **field config** defaultValue. +form.reset({ defaultValue: { type: 'SomeValueToKeep' } }); +``` + +### setFieldValue() + +**Arguments:** `fieldName: string, value: unknown` + +Sets a field value imperatively. + +```js +form.setFieldValue('name', 'John'); +``` + +### setFieldErrors() + +**Arguments:** `fieldName: string, errors: ValidationError[]` + +Sets field errors imperatively. + +```js +form.setFieldErrors('name', [{ message: 'There is an error in the field' }]); +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/fundamentals.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/fundamentals.mdx new file mode 100644 index 0000000000000..4c16bed017b04 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/fundamentals.mdx @@ -0,0 +1,81 @@ +--- +id: formLibCoreFundamentals +slug: /form-lib/core/fundamentals +title: Fundamentals +summary: Let's understand the basics +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +The core exposes the main building blocks (hooks and components) needed to build your form. + +It is important to note that the core **is not** responsible for rendering UI. Its responsibility is to return form and fields **state and handlers** that you can connect to React components. The core of the form lib is agnostic of any UI rendering the form. + +In Kibana we work with [the EUI component library](https://elastic.github.io/eui) so we have created that wrap EUI form input components. With these components, connection with the form lib is already done for you. + +## Main building blocks + +The three required components to build a form are: + +- hook to declare a new +- component that will wrap your form and create a context for it +- component to declare a + +Let's see them in action before going into details + +```js +import { useForm, Form, UseField } from 'src/plugins/es_ui_shared/public'; + +export const UserForm = () => { + const { form } = useForm(); // 1 + + return ( +
// 2 + // 3 + + + + + ); +}; +``` + +1. We use the `useForm` hook to declare a new form. +2. We then wrap our form with the `
` component, providing the `form` that we have just created. +3. Finally, we declared two fields with the `` component, providing a unique `path` for each one of them. + +If you were to run this code in the browser and click on the "Submit" button nothing would happen as we haven't defined any handler to execute when submitting the form. Let's do that now along with providing a `UserFormData` interface to the form, which we will get back in our `onSubmit` handler. + +```js +import { useForm, Form, UseField, FormConfig } from 'src/plugins/es_ui_shared/public'; + +interface UserFormData { + name: string; + lastName: string; +} + +export const UserForm = () => { + const onFormSubmit: FormConfig['onSubmit'] = async (data, isValid) => { + console.log("Is form valid:", isValid); + if (!isValid) { + // Maybe show a callout? + return; + } + + console.log("Form data:", data); + }; + + const { form } = useForm({ onSubmit: onFormSubmit }); + + return ( + + ... + + + ); +}; +``` + +Great! We have our first working form. No state to worry about, just a simple declarative way to build our fields. + +Those of you who are attentive might have noticed that the above form _does_ render the fields in the UI although we said earlier that the core of the form lib is not responsible for any UI rendering. This is because the `` has a fallback mechanism to render an `` and hook to the field `value` and `onChange`. Unless you have styled your `input` elements and don't require other field types like `checkbox` or `select`, you will probably want to how the the `` renders. We will see that in a future section. diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_array.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_array.mdx new file mode 100644 index 0000000000000..b92880fdf806d --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_array.mdx @@ -0,0 +1,85 @@ +--- +id: formLibCoreUseArray +slug: /form-lib/core/use-array +title: +summary: The perfect companion to generate dynamic fields +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +Use the `` component whenever you want to let the user add or remove fields in your form. Those fields will always be part of an array. Either an array of _values_, or an array of _objects_. +If you need those dynamic fields to be returned differently, you can [use a `serializer`](use_field.md#serializer) to transform the array. +There are no limits to how nested arrays and fields can be. + +```js +// You can simply generate a list of string values +const myFormData = { + tags: ['value1', 'value2', 'value3']; +}; + +// Or you can generate more complex objects +const myFormData = { + book: { // path: "book" + title: 'My book', // path: "book.title" + tags: [ // path: "book.tags" + { + label: 'Tag 1', // path: "book.tags[0].label + value: 'tag_1', // path: "book.tags[0].value + colors: [ // path: "book.tags[0].colors + 'green', // path: "book.tags[0].colors[0] + 'yellow' // path: "book.tags[0].colors[1] + ] + } + ] + } +} +``` + +**Note:** Have a on how to use ``. + +This component accepts the following props (the only required prop is the `path`). + +## Props + +### path (required) + +**Type:** `string` + +The array path. It can be any valid [`lodash.set()` path](https://lodash.com/docs/#set). + +### initialNumberOfItems + +**Type:** `number` +**Default:** `1` + +Define the number of items you want to have by default in the array. It is only used when there are no `defaultValue` found for the array. If there is a default value found, the number of items will be the length of that array. + +Those items are not fields yet, they are objects that you will receive back in the child function. + +### validations + +**Type:** `FieldConfig['validations']` + +Array of validations to run whenever an item is added or removed. This is that you define on the field config. The `value` that you receive is the `items` passed down to the child function (see below). + +### readDefaultValueOnForm + +**Type:** `boolean` +**Default:** `true` + +Flag to indicate if you want to read the array value from . + +### children + +**Type:** `(formFieldArray: FormArrayField) => JSX.Element` + +The children of `` is a function child which receives the form array field. You are then responsible to return a JSX element from that function. + +The `FormArrayField` that you get back in the function has the following properties: + +* `items` - The array items you can iterate on +* `error` - A string with possible validation error messages concatenated. It is `null` if there are no errors +* `addItem()` - Handler to add a new item to the array +* `removeItem(id: number)` - Handler to remove an item from the array +* `moveItem(source: number, destination: number)` - Handler to reorder items +* `form` - The `FormHook` object diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx new file mode 100644 index 0000000000000..b1d70d05c8d27 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx @@ -0,0 +1,397 @@ +--- +id: formLibCoreUseField +slug: /form-lib/core/use-field +title: +summary: Drop it anywhere in your
and see the magic happen +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +To declare a field in the form you use the `` component. + +This component accepts the following props (the only required prop is the `path`). + +## Props + +### path (required) + +**Type:** `string` + +The field path. It can be any valid [`lodash.set()` path](https://lodash.com/docs/#set). + +```js + + + + +// The above 3 fields will output the following object + +{ + user: { + name: 'John', + email: 'john@elastic.co', + }, + city: 'Paris' +} +``` + +### defaultValue + +**Type:** `any` + +An optional default value for the field. This will be the initial value of the field. The component is not controlled so updating this prop does not have any effect on the field. + +**Note:** You can define the field `defaultValue` in different places (). + +### config + +**Type:** `FieldConfig` + +The field configuration. + +**Note**: In some cases it makes more sense to declare all your form fields configuration inside a that you pass to the form. This will unclutter your JSX. + +```js +// It is a good habit to keep the configuration outside the component +// as in most case it is static and so this will avoid unnecessary re-renders. +const nameConfig: FieldConfig = { + label: 'Name', + validations: [ ... ], +}; + +export const MyFormComponent = { + const { form } = useForm(;) + return ( + + + + ); +}; +``` + +This configuration has the following parameters. + +#### label + +**Type:** `string` + +A label for the field. + +#### labelAppend + +**Type:** `string | ReactNode` + +A second label for the field. + +When `` is paired with one of that wrap the EUI form fields, this prop is forwarded to the `` `labelAppend` prop. As per [the EUI docs](https://elastic.github.io/eui/#/forms/form-layouts): _it adds an extra node to the right of the form label without being contained inside the form label. Good for things like documentation links._ + +#### helpText + +**Type:** `string | ReactNode` + +A help text for the field. + +#### type + +**Type:** `string` + +Specify a type for your field. It can be any string, but if you decide to use the `` helper component, then defining one of the `FIELD_TYPES` will automatically render the correct field for you. + +```js +import { Form, UseField, Field, FIELD_TYPES } from ''; + +const nameConfig = { + label: 'Name', + type: FIELD_TYPES.TEXT, +}; + +const showSettingsConfig = { + label: 'Show advanced settings', + type: FIELD_TYPES.TOGGLE, +}; + +export const MyFormComponent = () => { + const { form } = useForm(); + + // We use the same "Field" component to render both fields + // but as their "type" differs, they will render different UI fields. + return ( +
+ + + + ); +}; +``` + +The above example could be written a bit simpler with a form schema and . + +```js +import { Form, getUseField, Field, FIELD_TYPES } from ''; + +const schema = { + name: { + label: 'Name', + type: FIELD_TYPES.TEXT, + }, + showSettings: { + label: 'Show advanced settings', + type: FIELD_TYPES.TOGGLE, + } +}; + +const UseField = getUseField({ component: Field }); + +export const MyFormComponent = () => { + const { form } = useForm({ schema }); + + return ( +
+ + + + ); +}; +``` + +#### validations + +**Type:** `ValidationConfig[]` + +An array of validation to run against the field value. Although it would be possible to have a single validation that does multiple checks, it often makes the code clearer to have single purpose validation that return a single error if there is one. + +If any of the validation fails, the other validations don't run unless (`false` by default) is set to `true`. + +**Note:** There are already many . Check if there isn't already one for your use case before writing your own. + +The `ValidationConfig` accepts the following parameters: + +##### validator (Required) + +**Type:** `ValidationFunc` +**Arguments:** `data: ValidationFuncArg` +**Returns:** `ValidationError | void | Promise | Promise` + +A validator function to execute. It can be synchronous or asynchronous. + +**Note:** Have a look a for different use cases. + +This function receives a data argument with the following properties: + +* `value` - The field value +* `path` - The field path being validated +* `form.getFormData` - A handler to build the form data +* `form.getFields` - A handler to access the form fields +* `formData` - The raw form data +* `errors` - An array of any previous validation errors + +##### type + +**Type:** `string` + +A specific type for the validation. . + +##### isBlocking + +**Type:** `boolean` +**Default:** `true` + +By default all validation are blockers, which means that if they fail, the field `isValid` state is set to `false`. There might be some cases where you don't want the form to be invalid when a fied validation fails. + +For example: when we add an item to the ComboBox array, we don't want to block the UI and set the field (array) as invalid if the item is invalid. We won't add the item to the array but the field is still valid. For that we will pass `isBlocking: false` to the validation on the array item. + +##### exitOnFail + +**Type:** `boolean` +**Default:** `true` + +By default, when any of the validation fails, the following validation are not executed. If you still want to execute the following validation(s), set the `exitOnFail` to `false`. + +#### deserializer + +**Type:** `SerializerFunc` + +If the type of a field value differs from the type provided as `defaultValue` you can use a `deserializer` to transform the value. This handler is executedo once to initialize the field `value` state. + +```js +// The country field select options +const countries = [{ + value: 'us', + label: 'USA', +}, { + value: 'es', + label: 'Spain', +}]; + +const countryConfig = { + label: 'Country', + deserializer: (defaultValue: string) => { + // We return the object our field expects. + return countries.find(country => country.value === defaultValue); + } +}; + +export const MyFormComponent = () => { + const fetchedData = { + // The server returns a string, but our field expects + // an object with a "value" and "label" property. + country: 'es', + }; + + const { form } = useForm({ defaultValue: fetchedData }); + + return ( +
+ + + ) +} +``` + +#### serializer + +**Type:** `SerializerFunc` + +This is the reverse process of the `deserializer`. It is only executed when getting the form data (with `form.submit()` or `form.getFormData()`). + +```js +// Continuing the example above + +const countryConfig = { + label: 'Country', + deserializer: (defaultValue: string) => { + return countries.find(country => country.value === defaultValue); + }, + serializer: (fieldValue: { value: string; label: string }) => { + return fieldValue.value; + }, +}; +``` + +#### formatters + +**Type:** `FormatterFunc[]` + +If you need to format the field value each time it changes you can use a formatter for that. You can provide as many formatters as needed. + +**Note:** Only use formatters when you need to change visually how the field value appears in the UI. If you only need the transformed value when submitting the form, it is better to use a `serializer` for that. + +Each `FormatterFunc` receives 2 arguments: + +* `value` - The field value +* `formData` - The form data + +```js +const nameConfig = { + formatters: [(value: string) => { + // Capitalize the field value on each key stroke + return value.toUppercase(); + }], +}; +``` + +#### fieldsToValidateOnChange + +**Type:** `string[]` - An array of field paths +**Default:** `[]` + +By default when a field value changes, it is the only field that is validated. In some cases you might also want to run the validation on another field that is linked. + +Don't forget to include the current field path if you update this settings, unless you specifically do not want to run the validations on the current field. + +```js +const field1Config = { + fieldsToValidateOnChange: ['field1', 'field2'], +}; + +const field2Config = { + fieldsToValidateOnChange: ['field2', 'field1'], +}; +``` + +#### valueChangeDebounceTime + +**Type:** `number` + +The minimum time to update the `isChanging` field state. . + +### component + +**Type:** `FunctionComponent` + +The component to render. This component will receive the `field` hook object as props plus any other props that you pass in `componentProps` (see below). + +**Note:** You can see examples on how this prop is used in . + +### componentProps + +**Type:** `{ [prop: string]: any }` + +If you provide a `component` you can pass here any prop you want to forward to this component. + +### readDefaultValueOnForm + +**Type:** `boolean` +**Default:** true + +By default if you don't provide a `defaultValue` prop to ``, it will try to read the default value on . If you want to prevent this behaviour you can set `readDefaultValueOnForm` to false. This can be usefull for dynamic fields, as . + +### onChange + +**Type:** `(value:T) => void` + +With this handler you can listen to the field value changes. in the "Listening to changes" page. + +### onError + +**Type:** `(errors: string[] | null) => void` + +Callback that will be called whenever the field validity changes. When `null` is returned it means that the field is valid. + +### children + +**Type:** `(field: FieldHook) => JSX.Element` + +The (optional) children of `` is a function child which receives the . You are then responsible to return a JSX element from that function. + + +## Helpers + +### `getUseField()` + +**Arguments:** `props: UseFieldProps` + +In some cases you might find yourself declaring the exact same prop on `` for all your fields. (e.g. using the [the `Field` component](../helpers/components#field) everywhere). + +You can use the `getUseField` helper to get a `` component with predefined props values. + +```js +const UseField = getUseField({ component: Field }); + +const MyFormComponent = () => { + ... + return ( +
+ {/*You now can use it in your JSX without specifying the component anymore */} + + + ); +}; +``` + + +## Typescript value type + +You can provide the value type (`unknown` by default) on the component. + +```js + path="name" defaultValue="mustBeAString" /> +``` + +This has implication on the field config provided that has to have the same type. + +```js +const nameConfig:FieldConfig = { ... }; + + path="name" config={nameConfig} /> +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx new file mode 100644 index 0000000000000..17276f41b3dac --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx @@ -0,0 +1,65 @@ +--- +id: formLibCoreUseFormData +slug: /form-lib/core/use-form-data +title: useFormData() +summary: Get fields value updates from anywhere +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +**Returns:** `[rawFormData, () => T]` + +Use the `useFormData` hook to access and react to form field value changes. The hook accepts an optional options object. + +Have a look at the examples on how to use this hook in . + +## Options + +### form + +**Type:** `FormHook` + +The form hook object. It is only required to provide the form hook object in your **root form component**. + +```js +const RootFormComponent = () => { + // root form component, where the form object is declared + const { form } = useForm(); + const [formData] = useFormData({ form }); + + return ( +
+ + + ); +}; + +const ChildComponent = () => { + const [formData] = useFormData(); // no need to provide the form object + return ( +
...
+ ); +}; +``` + +### watch + +**Type:** `string | string[]` + +This option lets you define which field(s) to get updates from. If you don't specify a `watch` option, you will get updates when any form field changes. This will trigger a re-render of your component. If you want to only get update when a specific field changes you can pass it in the `watch`. + +```js +// Only get update whenever the "type" field changes +const [{ type }] = useFormData({ watch: 'type' }); + +// Only get update whenever either the "type" or the "subType" field changes +const [{ type, subType }] = useFormData({ watch: ['type', 'subType'] }); +``` + +## Return + +As you have noticed, you get back an array from the hook. The first element of the array is form data and the second argument is a handler to get the **serialized** form data if needed. + +```js +const [formData, getSerializedData] = useFormData(); +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_hook.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_hook.mdx new file mode 100644 index 0000000000000..21c77afd6dbce --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_hook.mdx @@ -0,0 +1,263 @@ +--- +id: formLibCoreUseForm +slug: /form-lib/core/use-form +title: useForm() +summary: The only hook you'll need to declare a new form +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +**Returns:** [`FormHook`](form_hook.md) + +Use the `useForm` hook to declare a new form object. As we have seen in the , you can use it without any additional configuration. It does accept an optional `config` object with the following configuration (all parameters are optional). + +## Configuration + +### onSubmit(data, isValid) + +**Arguments:** `data: T, isValid: boolean` +**Returns:** `Promise` + +The `onSubmit` handler is executed when calling `form.submit()`. It receives the form data and a boolean for the validity of the form. +When the form is submitted its `isSubmitting` state will be set to `true` and then back to `false` after the `onSubmit` handler has finished running. This can be useful to update the state of the submit button while saving the form to the server for example. + +```js +interface MyFormData { + name: string; +} + +const onFormSubmit = async (data: MyFormData, isValid: boolean): Promise => { + // "form.isSubmitting" is set to "true" + + if (!isValid) { + // Maybe show a callout + return; + } + // Do anything with the data + await myApiService.createResource(data); + + // "form.isSubmitting" is set to "false". +} +const { form } = useForm({ onSubmit: onFormSubmit }); + +// JSX + +``` + +### defaultValue + +**Type:** `Record` + +The `defaultValue` is an object that you provide to give the initial value for your fields. + +**Note:** There are multiple places where you can define the default value of a field, . + +```js +const fetchedData = { firstName: 'John' }; +const { form } = useForm({ defaultValue: fetchedData }); +``` + +### schema + +**Type:** `Record` + +Instead of manually providing a `config` object to each ``, in some cases it is more convenient to provide a schema to the form with the fields configuration at the desired paths. + +```js +interface MyForm { + user: { + firstName: string; + lastName: string; + } +} + +const schema: Schema { + user: { + firstName: { + defaultValue: '', + ... // other config + }, + lastName: { + defaultValue: '', + ... + }, + isAdmin: { + defaultValue: false, + } + } +}; + +export const MyComponent = () => { + const { form } = useForm({ schema }); + + // No need to provide the "config" prop on each field, + // it will be read from the schema + return ( +
+ + + + + ); +} +``` + +### deserializer + +When you provide a `defaultValue` to the form, you might want to parse the object and modify it (e.g. add an extra field just for the UI). You would use a `deserializer` to do that. This handler receives the `defaultValue` provided and return a new object with updated fields default values. +**Note:** It is recommended to keep this pure function _outside_ your component and not declare it inline on the hook. + +```js +import { Form, useForm, useFormData, Field, FIELD_TYPES, FormDataProvider } from ''; + +// Data coming from the server +const fetchedData = { + name: 'John', + address: { + street: 'El Camino Real #350' + } +} + +// We want to have a toggle in the UI to display the address _if_ there is one. +// Otherwise the toggle value is "false" and no address is displayed. +const deserializer = (defaultValue) => { + return { + ...defaultValue, + // We add an extra toggle field + showAddress: defaultValue.hasOwnProperty('address'), + }; +} + +export const MyComponent = ({ fetchedData }: Props) => { + const { form } = useForm({ + defaultValue: fetchedData, + deserializer + }); + const [{ showAddress }] = useFormData({ form, watch: 'showAddress' }); + + // We can now use our "showAddress" internal field in the UI + return ( +
+ + + {/* Show the street address when the toggle is "true" */} + {showAddress ? : null} + + + + ) +} +``` + +### serializer + +Serializer is the inverse process of the deserializer. It is executed when we build the form data (when calling `form.submit()` for example). +**Note:** As with the `deserializer`, it is recommended to keep this pure function _outside_ your component and not declare it inline on the hook. + +If we run the example above for the `deserializer`, and we click on the "Submit" button, we would get this in the console + +``` +Form data: { + address: { + street: 'El Camino Real #350' + }, + name: 'John', + showAddress: true +} +``` + +We don't want to surface the internal `showAddress` field. Let's use a `serializer` to remove it. + +```js + +const deserializer = (value) => { + ... +}; + + // Remove the showAddress field from the outputted data +const serializer = (value) => { + const { showAddress, ...rest } = value; + return rest; +} + +export const MyComponent = ({ fetchedData }: Props) => { + const { form } = useForm({ + defaultValue: fetchedData, + deserializer, + serializer, + }); + + ... + +}; +``` + +Much better, now when we submit the form, the internal UI fields are not leaked outside when building the form object. + +### id + +**Type:** `string` + +You can optionally give an id to the form, that will be attached to the `form` object you receive. This can be useful for debugging purpose when you have multiple forms on the page. + +### options + +**Type:** `{ valueChangeDebounceTime?: number; stripEmptyFields?: boolean }` + +#### valueChangeDebounceTime + +**Type:** `number` (ms) +**Default:** 500 + +When a field value changes, for example when we hit a key inside a text field, its `isChangingValue` state is set to `true`. Then, after all the validations have run for the field, the `isChangingValue` state is back to `false`. The time it take between those two state changes depends on the time it takes to run the validations. If the validations are all synchronous, the time will be `0`. If there are some asynchronous validations, (e.g. making an HTTP request to validate the value on the server), the "value change" duration will be the time it takes to run all the async validations. + +With this option, you can define the minimum time you'd like to have between the two state change, so the `isChangingValue` state will stay `true` for at least the amount of milliseconds defined here. This is useful for example if you want to display possible errors on the field after a minimum of time has passed since the last value change. + +This setting **can be overriden** on a per-field basis, providing a `valueChangeDebounceTime` in its config object. + +```js +const { form } = useForm({ options: { valueChangeDebounceTime: 300 } }); + +return ( + path="name"> + {(field) => { + let isInvalid = false; + let errorMessage = null; + + if (!field.isChangingValue) { + // Only update this derived state after 300ms of the last key stroke + isInvalid = field.errors.length > 0; + errorMessage = isInvalid ? field.errors[0].message : null; + } + + return ( +
+ + {isInvalid &&
{errorMessage}
} +
+ ); + }} +
+); +``` + +#### stripEmptyFields + +**Type:** `boolean` +**Default:** `true` + +With this option you can decide if you want empty string value to be returned by the form. + +```js +// stripEmptyFields: true (default) +{ + "firstName": "John" +} + +// stripEmptyFields: false +{ + "firstName": "John", + "lastName": "", + "role": "" +} +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_multi_fields.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_multi_fields.mdx new file mode 100644 index 0000000000000..2a16b8e878be8 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_multi_fields.mdx @@ -0,0 +1,97 @@ +--- +id: formLibCoreUseMultiFields +slug: /form-lib/core/use-multi-fields +title: +summary: Because sometimes you need more than one field +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +You might find yourself at some point wanting to hook multiple fields to a component because that component accepts multiple values. In that case you will have to nest multiple `` with their child function, which is not very elegant. + +```js + + {maxValueField => { + return ( + + {minValueField => { + return ( + { + minValueField.setValue(minValue); + maxValueField.setValue(maxValue); + }} + /> + ) + }} + + ) + }} + +``` + +You can use `` to provide any number of fields and you will get them back in a single child function. + +```js +const fields = { + min: { + // Any prop you would normally pass to + path: 'minValue', + config: { + ... + } + }, + max: { + path: 'maxValue', + }, +}; + + + {({ min, max }) => { + return ( + { + min.setValue(minValue); + max.setValue(maxValue); + }} + /> + ); + }} + +``` + +## Props + +### fields (required) + +**Type:** `{ [fieldId: string]: UseFieldProps }` + +A map of field id to `` props. The id does not have to match the field path, it will simply help you identify the fields that you get back in the child function. + +### children + +**Type:** `(fields: { fieldId: string: FieldHook }) => JSX.Element` + +The children of `` is a function child which receives a map of field id to FieldHook. You are then responsible to return a JSX element from that function. + +## Typescript value type + +You can provide the field value type for each field (`unknown` by default) on the component. + +```js +interface Fields { + min: number; + max: number; +} + +// You are then required to provide those exact 2 fields in the "fields" prop + fields={{ min: { ... }, max: { ... } }}> + ... + +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/dynamic_fields.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/dynamic_fields.mdx new file mode 100644 index 0000000000000..f2525d5a16fba --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/dynamic_fields.mdx @@ -0,0 +1,276 @@ +--- +id: formLibExampleDynamicFields +slug: /form-lib/examples/dynamic-fields +title: Dynamic fields +summary: Let the user add any number of fields on the fly +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +## Basic + +Dynamic fields are fields that the user can add or remove. Those fields will end up in an array of _values_ or an array of _objects_, it's up to you. To work with dynamic fields in your form you use the component. + +Let's imagine a form that lets a user enter dynamic items to a list. + +```js +export const DynamicFields = () => { + const todoList = { + items: [ + { + title: 'Title 1', + subTitle: 'Subtitle 1', + }, + { + title: 'Title 2', + subTitle: 'Subtitle 2', + }, + ], + }; + const { form } = useForm({ defaultValue: todoList }); + + const submitForm = () => { + console.log(form.getFormData()); + }; + + return ( +
+ + {({ items, addItem, removeItem }) => { + return ( + <> + {items.map((item) => ( + + + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label="Remove item" + style={{ marginTop: '28px' }} + /> + + + ))} + + Add item + + + + ); + }} + + + + + Submit + + + ); +}; +``` + +## Validation + +If you need to validate the number of items in the array, you can provide a `validations` prop to the ``. If, for example, we require at least one item to be added to the list, we can either: + +* Hide the "Remove" button when there is only one item +* Add a `validations` prop + +The first one is easy, let's look at the second option: + +```js +const itemsValidations = [ + { + validator: ({ value }: { value: Array<{ title: string; subtitle: string }> }) => { + if (value.length === 0) { + return { + message: 'You need to add at least one item', + }; + } + }, + }, +]; + +const { emptyField } = fieldValidators; +const textFieldValidations = [{ validator: emptyField("The field can't be empty.") }]; + +export const DynamicFieldsValidation = () => { + const { form } = useForm(); + + const submitForm = async () => { + const { isValid, data } = await form.submit(); + + if (isValid) { + console.log(data); + } + }; + + return ( +
+ + {({ items, addItem, removeItem, error, form: { isSubmitted } }) => { + const isInvalid = error !== null && isSubmitted; + return ( + <> + + <> + {items.map((item) => ( + + + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label="Remove item" + style={{ marginTop: '28px' }} + /> + + + ))} + + + + Add item + + + + ); + }} + + + + + Submit + + + ); +}; +``` + +## Reorder array items + +```js +export const DynamicFieldsReorder = () => { + const { form } = useForm(); + + const submitForm = async () => { + const { data } = await form.submit(); + console.log(data); + }; + + return ( +
+ + {({ items, addItem, removeItem, moveItem }) => { + const onDragEnd = ({ source, destination }: DropResult) => { + if (source && destination) { + moveItem(source.index, destination.index); + } + }; + + return ( + <> + + + + {items.map((item, idx) => { + return ( + + {(provided) => { + return ( + + +
+ +
+
+ + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label="Remove item" + style={{ marginTop: '28px' }} + /> + +
+ ); + }} +
+ ); + })} +
+
+
+ + Add item + + + + ); + }} +
+ + + + Submit + + + ); +}; +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/fields_composition.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/fields_composition.mdx new file mode 100644 index 0000000000000..260908f94a790 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/fields_composition.mdx @@ -0,0 +1,167 @@ +--- +id: formLibExampleFieldsComposition +slug: /form-lib/examples/fields-composition +title: Fields composition +summary: Be DRY and compose your form +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +If your form does not have a fix set of fields (single interface) and you need to add/remove fields dynamically, you can leverage the power of field composition with the form lib. It let's you swap fields in your form whenever needed. Any field that **is not in the DOM** is automatically cleared when unmounting and its value won't be returned in the form data. +If you _do_ need to keep a field value, but hide the field in the UI, then you need to use CSS (`
...
`) + +Imagine you're building an app that lets people buy a car online. You want to build a form that lets the user select the model of the car (`sedan`, `golf cart`, `clown mobile`), and based on their selection you'll show a different form for configuring the selected model's options. + +Those are the 3 car configurations that the form can output: + +```js +// sedan +{ + model: 'sedan', + used: true, + plate: 'UIES2021', // unique config for this car +}; + +// golf cart +{ + model: 'golf_cart', + used: false, + forRent: true, // unique config for this car +}; + +// clown mobile +{ + model: 'clown_mobile', + used: true, + miles: 1.0, // unique config for this car +} +``` + +Let's create one component for each car that will expose its unique parameter(s). Those components won't have to render the `model` and the `used` form fields as they are common to all three cars and we will put them at the root level of the form. + +```js +// sedan_car.tsx + +const plateConfig = { + label: 'Plate number', +}; + +export const SedanCar = () => { + return ( + <> + + + ); +}; +``` + +```js +// golf_cart_car.tsx + +const forRentConfig = { + label: 'The cart is for rent', + defaultValue: true, +}; + +export const GolfCartCar = () => { + return ( + <> + + + ); +}; +``` + +```js +// clown_mobile_car.tsx + +const milesConfig = { + label: 'Current miles', + defaultValue: 1.0, + serializer: parseFloat, +}; + +export const ClownMobileCar = () => { + return ( + <> + + + ); +}; +``` + +And finally, let's build our form which will swap those components according to the selected car `model`. + +```js +import { UsedParameter } from './used_parameter'; +import { SedanCar } from './sedan_car'; +import { GolfCartCar } from './golf_cart_car'; +import { ClownMobileCar } from './clown_mobile_car'; + +const modelToComponentMap: { [key: string]: React.FunctionComponent } = { + sedan: SedanCar, + golfCart: GolfCartCar, + clownMobile: ClownMobileCar, +}; + +// We create a schema so we don't need to manually add the config +// to the component through props +const formSchema = { + model: { + label: 'Car model', + defaultValue: 'sedan', + }, + used: { + label: 'Car has been used', + defaultValue: false, + } +}; + +const modelOptions = [ + { + text: 'sedan', + }, + { + text: 'golfCart', + }, + { + text: 'clownMobile', + }, +]; + +export const CarConfigurator = () => { + const { form } = useForm({ schema: formSchema }); + const [{ model }] = useFormData<{ model: string }>({ form, watch: 'model' }); + + const renderCarConfiguration = () => { + // Select the car configuration according to the chosen model. + const CarConfiguration = modelToComponentMap[model]; + return ; + }; + + const submitForm = () => { + console.log(form.getFormData()); + }; + + return ( +
+ + + + {model !== undefined ? renderCarConfiguration() : null} + + + + + Submit + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/listening_to_changes.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/listening_to_changes.mdx new file mode 100644 index 0000000000000..c99184f5a5c0e --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/listening_to_changes.mdx @@ -0,0 +1,214 @@ +--- +id: formLibExampleListeningToChanges +slug: /form-lib/examples/listening-to-changes +title: Listening to changes +summary: React to changes deep down the tree +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +## Basic + +### Access the form data from the root component + +You can access the form data by using the hook. This hook has an optional `form` option that you only have to provide if you need to access the data in the **root** component. + +```js +// From the root component (where the "form" is declared) +export const MyComponent = () => { + const { form } = useForm(); + + const [formData] = useFormData({ form }); + + return ( +
+ + + {JSON.stringify(formData)} + + ); +}; +``` +### Access the form data from a child component + +To access the form data from inside a child component you also use the `useFormData()` hook, but this time you don't need to provide the `form` as it is read from context. + +```js +const FormFields = () => { + const [formData] = useFormData(); + + return ( + <> + + + {JSON.stringify(formData)} + + ) +}; + +export const MyComponent = () => { + const { form } = useForm(); + + return ( +
+ + + ); +}; +``` + +## Listen to specific form fields changes + +In some cases you only want to listen to some field change and don't want to trigger a re-render of your component for every field value change in your form. You can specify a **watch** (`string | string[]`) parameter for that. + +```js +export const ReactToSpecificFields = () => { + const { form } = useForm(); + + // Only listen for changes from the "showAddress" toggle + const [{ showAddress }] = useFormData({ form, watch: 'showAddress' }); + + return ( +
+ {/* Changing the "name" field won't trigger a re-render */} + + + + {showAddress && ( + <> +

800 W El Camino Real #350

+ + )} + + ); +}; +``` + +## Using the `onChange` handler + +Sometimes the good old `onChange` handler is all you need to react to a form field value change (instead of reading the form data and adding a `useEffect` to react to it). + +```js +export const OnChangeHandler = () => { + const { form } = useForm(); + + const onNameChange = (value: string) => { + console.log(value); + }; + + return ( +
+ + + ); +}; +``` + +## Forward the form state to a parent component + +If your UX requires to submit the form in a parent component (e.g. because that's where your submit button is located), you will need a way to access the form validity and the form data outside your form component. Unless your parent component needs to be aware of every field value change in the form (which should rarely be needed), you don't want to use the `useFormData()` hook and forward the data from there. This would create unnecessary re-renders. Instead it is better to forward the `getFormData()` handler on the form. + +This pattern is useful when, for example, the form is inside one of the steps of multi-step wizard and the button to go "next" is thus outside the scope of the component where the form is declared. + +```js +interface MyForm { + name: string; +} + +interface FormState { + isValid: boolean | undefined; + validate(): Promise; + getData(): MyForm; +} + +const schema: FormSchema = { + name: { + validations: [ + { + validator: ({ value }) => { + if (value === 'John') { + return { message: `The username "John" already exists` }; + } + }, + }, + ], + }, +}; + +interface Props { + defaultValue: MyForm; + onChange(formState: FormState): void; +} + +const MyForm = ({ defaultValue, onChange }: Props) => { + const { form } = useForm({ defaultValue, schema }); + const { isValid, validate, getFormData } = form; + + // getFormData() is a stable reference that is not mutated when the form data change. + // This means that it does not trigger a re-render on each form data change. + useEffect(() => { + const updatedFormState = { isValid, validate, getData: getFormData }; + + // Forward the state to the parent + onChange(updatedFormState); + }, [onChange, isValid, validate, getFormData]); + + return ( +
+ + + ); +}; + +export const ForwardFormStateToParent = () => { + // This would probably come from the server + const formDefaultValue: MyForm = { + name: 'John', + }; + + // As the parent component does not know anything about the form until the form calls an onChange(), + // we initially set the validate() and getData() to return the default state. + const initialState = { + isValid: true, + validate: async () => true, + getData: () => formDefaultValue, + }; + + const [formState, setFormState] = useState(initialState); + + const sendForm = useCallback(async () => { + // The form isValid state will stay "undefined" until either: + // - all the fields are dirty + // - we call the form "validate()" or "submit()" methods + + // This is why we first check if it is undefined and if it is, we call the validate() method + // which will validate **only** the fields that haven't been validated yet. + const isValid = formState.isValid ?? (await formState.validate()); + if (!isValid) { + // Show a callout somewhere... + return; + } + + console.log('Form data', formState.getData()); + }, [formState]); + + return ( + <> +

My form

+ + + Submit + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/serializers_deserializers.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/serializers_deserializers.mdx new file mode 100644 index 0000000000000..393711b393e0f --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/serializers_deserializers.mdx @@ -0,0 +1,104 @@ +--- +id: formLibExampleSerializersDeserializers +slug: /form-lib/examples/serializers-deserializers +title: Serializers & Deserializers +summary: No need for a 1:1 map of your API with your form fields, be creative! +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +Forms help users edit data. This data is often persisted, for example saved in Elasticsearch. When it's persisted, the shape of the data typically reflects the concerns of the domain or the persistence medium. When it's edited in a form, the shape of the data reflects different concerns, such as UI state. Data is **deserialized** from its persisted shape to its form-editable shape and **serialized** from its form-editable shape to its persisted shape. + +With that in mind, you can pass the following handlers to the form + +* **deserializer**: A function that converts the persisted shape to the form-editable shape. +* **serializer**: A function that converts the form-editable shape to the persisted shape. + +Let's see it through an example. + +```js +// This is the persisted shape of our data +interface MyForm { + name: string; + customLabel: string; +} + +// This is the internal fields we will need in our form +interface MyFormUI { + name: string; + customLabel: string; + showAdvancedSettings: boolean; +} + +const formDeserializer = ({ name, customLabel }: MyForm): MyFormUI => { + // Show the advanced settings if a custom label is provided + const showAdvancedSettings = Boolean(customLabel); + + return { + name, + customLabel, + showAdvancedSettings, + }; +}; + +const formSerializer = ({ name, customLabel }: MyFormUI): MyForm => { + // We don't forward the "showAdvancedSettings" field + return { name, customLabel }; +}; + + +const schema: FormSchema = { + name: { label: 'Name' }, + customLabel: { label: 'CustomLabel' }, + showAdvancedSettings: { + label: 'Show advanced settings', + defaultValue: false, + }, +}; + +export const SerializersAndDeserializers = () => { + // Data coming from the server + const fetchedData: MyForm = { + name: 'My resource', + customLabel: 'My custom label', + }; + + const { form } = useForm({ + defaultValue: fetchedData, + schema, + deserializer: formDeserializer, + serializer: formSerializer, + }); + + const [{ showAdvancedSettings }] = useFormData({ + form, + watch: ['showAdvancedSettings'], + }); + + const submitForm = async () => { + const { isValid, data } = await form.submit(); + if (isValid) { + console.log(data); + } + }; + + return ( +
+ + + + + + {/* We don't remove it from the DOM as we would lose the value entered in the field. */} +
+ +
+ + + + Submit + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/style_fields.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/style_fields.mdx new file mode 100644 index 0000000000000..db7c98772eddb --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/style_fields.mdx @@ -0,0 +1,66 @@ +--- +id: formLibExampleStyleFields +slug: /form-lib/examples/styles-fields +title: Style fields +summary: Customize your fields however you want +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +The `` is a render prop component that returns a . +You can then connect its states and handlers to any UI. + +```js +export const MyComponent = () => { + const { form } = useForm(); + + // Notice how we have typed the value of the field with ...> + return ( +
+ path="firstname" config={{ label: 'First name' }}> + {(field) => { + const { + isChangingValue, + errors, + label, + helpText, + value, + onChange, + isValidating + } = field; + + const isInvalid = !isChangingValue && errors.length > 0; + const errorMessage = !isChangingValue && errors.length ? errors[0].message : null; + + return ( + + + + ); + }} + + + ); +}; +``` + +## Using the `component` prop + +The above example can be simplified by extracting the children into its own component and by using the `component` prop on ``. +The component will receive the `field` hook as a prop and any other props you pass to `componentProps`. + +```js + +``` + +**Note:** Before creating your own reusable component have a look at which handle most of the form inputs of [the EUI framework](https://elastic.github.io/eui). \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx new file mode 100644 index 0000000000000..bbd89d707e4fe --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx @@ -0,0 +1,274 @@ +--- +id: formLibExampleValidation +slug: /form-lib/examples/validation +title: Validation +summary: Don't let invalid data leak out of your form! +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +## Basic + +```js +import React from 'react'; +import { + useForm, + Form, + UseField, + FieldConfig, +} from ''; + +interface MyForm { + name: string; +} + +const nameConfig: FieldConfig = { + validations: [ + { + validator: ({ value }) => { + if (value.trim() === '') { + return { + message: 'The name cannot be empty.', + }; + } + }, + }, + // ... + // You can add as many validations as you need. + + // It is a good practice to keep validators single purposed, + // and compose them in the "validations" array. + // This way if any other field has the same validation we can easily + // copy it over or extract it and import it in multiple places. + ], +}; + +export const MyComponent = () => { + const { form } = useForm(); + + return ( +
+ path="name" config={nameConfig}> + {(field) => { + const isInvalid = !field.isChangingValue && field.errors.length > 0; + const errorMessage = !isChangingValue && errors.length ? errors[0].message : null; + + return ( + + + + ); + }} + + + ); +}; +``` + +**Note:** Before creating your own validator, verify that it does not exist already in our . + +## Asynchronous validation + +You can mix synchronous and asynchronous validations. Although it is usually better to first declare the synchronous one(s), this way if any of those fail, the asynchronous validation is not executed. + +In the example below, if you enter "bad" in the field, the asynchronous validation will fail. + +```js +const nameConfig: FieldConfig = { + validations: [ + { + validator: emptyField('The name cannot be empty,'), + }, + { + validator: indexNameField(i18n), + }, + { + validator: ({ value }) => { + return new Promise((resolve) => { + setTimeout(() => { + if (value === 'bad') { + resolve({ message: 'This index already exists' }); + } + resolve(); + }, 2000); + }); + }, + }, + ], +}; +``` + +### Cancel asynchronous validation + +If you need to cancel the previous asynchronous validation before calling the new one, you can do it by adding a `cancel()` handler to the Promise returned. + +**Note:** Make sure **to not** use an `async/await` validator function when returning your Promise, or the `cancel` handler will be stripped out. + +```js +const nameConfig: FieldConfig = { + validations: [ + { + validator: ({ value }) => { + let isCanceled = false; + const promise: Promise & { cancel?(): void } = new Promise((resolve) => { + setTimeout(() => { + if (isCanceled) { + console.log('This promise has been canceled, skipping'); + return resolve(); + } + + if (value === 'bad') { + resolve({ message: 'This index already exists' }); + } + resolve(); + }, 2000); + }); + + promise.cancel = () => { + isCanceled = true; + }; + + return promise; + }, + }, + ], +}; + +export const CancelAsyncValidation = () => { + const { form } = useForm(); + return ( +
+ path="name" config={nameConfig}> + {(field) => { + const isInvalid = !field.isChangingValue && field.errors.length > 0; + return ( + <> + + {isInvalid &&
{field.getErrorsMessages()}
} + + ); + }} + + + ); +}; +``` + +## Validating arrays of items + +When validating an array of items we might have to handle **two types of validations**: one to make sure the array is valid (e.g. it is not empty or it contains X number of items), and another one to make sure that each item in the array is valid. + +To solve that problem, you can give a `type` to a validation to distinguish between different validations. + +Let's go through an example. Imagine that we have a form field to enter "tags" (an array of string). The array cannot be left empty and the tags cannot contain the "?" and "/" characters. + +The form field `value` is an array of string, and the default validation(s) (those without a `type` defined) will run against this **array**. For the validation of the items we will use a **typed** validation. + +**Note:** Typed validation are not executed when the field value changes, we need to manually validate the field with `field.validate(...)`. + +```js +const tagsConfig: FieldConfig = { + defaultValue: [], + validations: [ + // Validator for the Array + { validator: emptyField('You need to add at least one tag') }, + { + // Validator for the items + validator: containsCharsField({ + message: ({ charsFound }) => { + return `Remove the char ${charsFound.join(', ')} from the field.`; + }, + chars: ['?', '/'], + }), + // We give a custom type to this validation. + // This validation won't be executed when the field value changes + // (when items are added or removed from the array). + // This means that we will need to manually call: + // field.validate({ validationType: 'arrayItem' }) + // to run this validation. + type: 'arrayItem', + }, + ], +}; + +export const MyComponent = () => { + const onSubmit: FormConfig['onSubmit'] = async (data, isValid) => { + console.log('Is form valid:', isValid); + console.log('Form data', data); + }; + + const { form } = useForm({ onSubmit }); + + return ( +
+ path="tags" config={tagsConfig}> + {(field) => { + // Check for error messages on **both** the default validation and the "arrayItem" type + const errorMessage = + field.getErrorsMessages() ?? field.getErrorsMessages({ validationType: 'arrayItem' }); + + const onCreateOption = (value: string) => { + const { isValid } = field.validate({ + value: value, + validationType: 'arrayItem', // Validate **only** this validation type against the value provided + }) as { isValid: boolean }; + + if (!isValid) { + // Reject the user's input. + return false; + } + + // Add the tag to the array + field.setValue([...field.value, value]); + }; + + const onChange = (options: EuiComboBoxOptionOption[]) => { + field.setValue(options.map((option) => option.label)); + }; + + const onSearchChange = (value: string) => { + if (value !== undefined) { + // Clear immediately the "arrayItem" validation type + field.clearErrors('arrayItem'); + } + }; + + return ( + <> + ({ label: v }))} + onCreateOption={onCreateOption} + onChange={onChange} + onSearchChange={onSearchChange} + fullWidth + /> + {!field.isValid &&
{errorMessage}
} + + + ); + }} + + + ); +}; +``` + +Great, but that's **a lot** of code for a simple tags field input. Fortunatelly the `` helper component takes care of all the heavy lifting for us. . diff --git a/src/plugins/es_ui_shared/static/forms/docs/helpers/components.mdx b/src/plugins/es_ui_shared/static/forms/docs/helpers/components.mdx new file mode 100644 index 0000000000000..1b35e41a98739 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/helpers/components.mdx @@ -0,0 +1,172 @@ +--- +id: formLibHelpersComponents +slug: /form-lib/helpers/components +title: Components +summary: Build complex forms the easy way +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +The core of the form lib is UI agnostic. It can be used with any React UI library to render the form fields. + +At Elastic we use [the EUI framework](https://elastic.github.io/eui). We created components that connect our `FieldHook` to the `` and its corresponding EUI field. + +You can import these components and pass them to the `component` prop on your ``. + +```js +import { Form, useForm, UseField, TextField, ToggleField } from ''; + +export const MyFormComponent = () => { + const { form } = useForm(); + + return ( +
+ + + + ); +}; +``` + +As you can see it is very straightforward. If there are any validation error(s) on those fields, they will be correctly set on the underlying ``, as well as the field `value`, `onChange` handler, label, helpText... + +## Fields components + +This is the list of component we currently have. This list might grow in the future if we see the need to support additional fields. + +* TextField +* TextAreaField +* NumericField +* CheckBoxField +* ToggleField +* ComboBoxField* +* JsonEditorField +* SelectField +* SuperSelectField +* MultiSelectField +* RadioGroupField +* RangeField + +(*) Currently the `` only support the free form entry of items (e.g a list of "tags" that the user enters). This means that it does not work (yet) **with predefined selections** to chose from. + +## `euiFieldProps` + +Those helper components have been set to a default state that cover most of our use cases. You can override those defaults by passing new props to the `euiFieldProps`. + +```js + prop override + } + }} +/> +``` + +## `Field` + +There is a special `` component that you can use if you prefer. If you use this component, it will check and map to the corresponding component in the list above. If the type does not match any known component, a `` component is rendered. + +It is recommended to use the available `FIELD_TYPES` constant to indicate the type of a field in the `FieldConfig`. + +```js +const schema: FormSchema = { + name: { + label: 'Name', + type: FIELD_TYPES.TEXT + }, + isAdmin: { + label: 'User is admin', + type: FIELD_TYPES.CHECKBOX, + }, + country: { + label: 'Country, + type: FIELD_TYPES.SELECT, + } +}; + +export const MyFormComponent = () => { + const { form } = useForm({ schema }); + + // You now can use the component everywhere + return ( +
+ + + + + ); +}; +``` + +The above example can be simplified one step further with . + +```js +const schema: FormSchema = { + name: { + label: 'Name', + type: FIELD_TYPES.TEXT + }, + ... +}; + +const UseField = getUseField({ prop: Field }); + +// Nice and tidy form component :) +export const MyFormComponent = () => { + const { form } = useForm({ schema }); + + return ( +
+ + + + + ); +}; +``` +## Examples +### ComboBoxField + +The ComboBox has the particualrity of sometimes requiring **two validations**. One for the array and one for the items of the array. In the example below you can see how easy it is to generate an array of tags (`string[]`) in your form thanks to the `` helper component. + +```js +const tagsConfig: FieldConfig = { + defaultValue: [], + validations: [ + // Validate that the array is not empty + { validator: emptyField('You need to add at least one tag')}, + { + // Validate each item about to be added to the combo box + validator: containsCharsField({ + message: ({ charsFound }) => { + return `Remove the char ${charsFound.join(', ')} from the field.`; + }, + chars: ['?', '/'], + }), + // We use a typed validation to validate the array items + // Make sure to use the "ARRAY_ITEM" constant + type: VALIDATION_TYPES.ARRAY_ITEM, + }, + ], +}; + +export const ValidationWithTypeComboBoxField = () => { + const onSubmit: FormConfig['onSubmit'] = async (data, isValid) => { + console.log('Is form valid:', isValid); + console.log('Form data', data); + }; + + const { form } = useForm({ onSubmit }); + + return ( +
+ path="tags" config={tagsConfig} component={ComboBoxField} /> + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/helpers/validators.mdx b/src/plugins/es_ui_shared/static/forms/docs/helpers/validators.mdx new file mode 100644 index 0000000000000..aba2d6dffb1ba --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/helpers/validators.mdx @@ -0,0 +1,46 @@ +--- +id: formLibHelpersValidators +slug: /form-lib/helpers/validators +title: Validators +summary: Build complex forms the easy way +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +As you have seen in the `` configuration, the validations are objects with attached to them. + +After building many forms, we have realized that we are often doing the same validation on a field: is the field empty? does it contain a character not allowed?, does it start with an invalid character? is it valid JSON? ... + +So instead of reinventing the wheel on each form we have exported to most common validators as reusable function that you can use directly in your field validations. Some validator might expose directly the handler to validate, some others expose a function that you need to call with some parameter and you will receive the validator back. + +```js +import { fieldValidators } from ''; + +const { emptyField } = fieldValidators; + +// Some validator expose a function that you need to call to receive the validator handler +const nameConfig: FieldConfig = { + validations: [{ + validator: emptyField('Your custom error message'), + }, { + validator: containsCharsField({ + chars: ' ', + message: 'Spaces are not allowed in a component template name.', + }) + }], +}; +``` + +We have validators for valid + +* index pattern name +* JSON +* URL +* number +* string start with char +* string contains char +* ... + +Before your write your own validator, check (thanks to Typescript suggestions in your IDE) what is already exposed from the `fieldValidators` object. + +And if need to build your own validator and you think that it is common enough for other forms, make a contribution to the form lib and open a PR to add it to our list! diff --git a/src/plugins/es_ui_shared/static/forms/docs/welcome.mdx b/src/plugins/es_ui_shared/static/forms/docs/welcome.mdx new file mode 100644 index 0000000000000..2d1156f403bff --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/welcome.mdx @@ -0,0 +1,30 @@ +--- +id: formLibWelcome +slug: /form-lib/welcome +title: Welcome +summary: Build complex forms the easy way +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +## Presentation + +The form library helps us build forms efficiently by providing a system whose main task is (1) to abstract away the state management of fields values and validity and (2) running validations on the fields when their values change. + +The system is composed of **three parts**: + +* +* +* + +## Motivation + +In the Elasticsearch UI team we build many forms. Many many forms! :blush: For each of them, we used to manually declare the form state, write validation functions, call them on certain events and then update the form state. We were basically re-inventing the wheel for each new form we built. It took our precious dev time to re-think the approach each time, but even more problematic: it meant that each of our form was built slightly differently. Maintaining those forms meant that we needed to remember how the state was being updated on a specific form and how its validation worked. This was far from efficient... + +We needed a system in place that took care of the repetitive task of managing a form state and validating its value, so we could dedicate more time doing what we love: **build amazing UX for our users!**. + +The form lib was born. + +## When shoud I use the form lib? + +As soon as you have a form with 3+ fields and some validation that you need to run on any of those fields, the form lib can help you reduce the boilerplate and the time to get your form running. Of course, the more you use it, the more addicted you will get! :smile: diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index da4e1b101914f..38864945a17d0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -116,10 +116,15 @@ describe('telemetry_application_usage', () => { minutesOnScreen: 10, numberOfClicks: 10, }, + type: opts.type, + references: [], + score: 0, }, ], total: 1, - } as any; + per_page: 10, + page: 1, + }; case SAVED_OBJECTS_DAILY_TYPE: return { saved_objects: [ @@ -131,9 +136,21 @@ describe('telemetry_application_usage', () => { minutesOnScreen: 0.5, numberOfClicks: 1, }, + type: opts.type, + references: [], + score: 0, }, ], total: 1, + per_page: 10, + page: 1, + }; + default: + return { + saved_objects: [], + total: 0, + per_page: 10, + page: 1, }; } }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts index 7d4f03fd30edf..bc6f8c956b669 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/config_usage/register_config_usage_collector.test.ts @@ -28,7 +28,7 @@ describe('kibana_config_usage', () => { const collectorFetchContext = createCollectorFetchContextMock(); const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); - const mockConfigUsage = (Symbol('config usage telemetry') as any) as ConfigUsageData; + const mockConfigUsage = (Symbol('config usage telemetry') as unknown) as ConfigUsageData; coreUsageDataStart.getConfigsUsageData.mockResolvedValue(mockConfigUsage); beforeAll(() => registerConfigUsageCollector(usageCollectionMock, () => coreUsageDataStart)); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts index b671a9f93d369..5410e491a85fd 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.test.ts @@ -28,7 +28,7 @@ describe('telemetry_core', () => { const collectorFetchContext = createCollectorFetchContextMock(); const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); - const getCoreUsageDataReturnValue = (Symbol('core telemetry') as any) as CoreUsageData; + const getCoreUsageDataReturnValue = (Symbol('core telemetry') as unknown) as CoreUsageData; coreUsageDataStart.getCoreUsageData.mockResolvedValue(getCoreUsageDataReturnValue); beforeAll(() => registerCoreUsageCollector(usageCollectionMock, () => coreUsageDataStart)); diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts index 15cbecde386f7..3d5d7854d6f9e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.test.ts @@ -9,13 +9,11 @@ import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { getSavedObjectsCounts } from './get_saved_object_counts'; -export function mockGetSavedObjectsCounts(params: any) { +export function mockGetSavedObjectsCounts(params: TBody) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.search.mockResolvedValue( // @ts-expect-error we only care about the response body - { - body: { ...params }, - } + { body: { ...params } } ); return esClient; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts index e1afbfbcecc4e..2c75d3edc3a84 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts @@ -34,7 +34,8 @@ describe('telemetry_kibana', () => { const getMockFetchClients = (hits?: unknown[]) => { const fetchParamsMock = createCollectorFetchContextMock(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue({ body: { hits: { hits } } } as any); + // @ts-expect-error for the sake of the tests, we only require `hits` + esClient.search.mockResolvedValue({ body: { hits: { hits } } }); fetchParamsMock.esClient = esClient; return fetchParamsMock; }; 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 dfe31b1da3643..c5a2550723814 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -432,7 +432,11 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'text', _meta: { description: 'Non-default value of setting.' }, }, - 'labs:presentation:unifiedToolbar': { + 'labs:presentation:timeToPresent': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, + 'labs:canvas:enable_ui': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts index cb0b1c045397d..8295342c527ab 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts @@ -17,6 +17,7 @@ import { registerManagementUsageCollector, createCollectorFetch, } from './telemetry_management_collector'; +import { IUiSettingsClient } from 'kibana/server'; const logger = loggingSystemMock.createLogger(); @@ -30,7 +31,7 @@ describe('telemetry_application_usage_collector', () => { }); const uiSettingsClient = uiSettingsServiceMock.createClient(); - const getUiSettingsClient = jest.fn(() => uiSettingsClient); + const getUiSettingsClient = jest.fn((): IUiSettingsClient | undefined => uiSettingsClient); const mockedFetchContext = createCollectorFetchContextMock(); beforeAll(() => { @@ -42,7 +43,7 @@ describe('telemetry_application_usage_collector', () => { }); test('isReady() => false if no client', () => { - getUiSettingsClient.mockImplementationOnce(() => undefined as any); + getUiSettingsClient.mockImplementationOnce(() => undefined); expect(collector.isReady()).toBe(false); }); @@ -60,7 +61,7 @@ describe('telemetry_application_usage_collector', () => { }); test('fetch() should not fail if invoked when not ready', async () => { - getUiSettingsClient.mockImplementationOnce(() => undefined as any); + getUiSettingsClient.mockImplementationOnce(() => undefined); await expect(collector.fetch(mockedFetchContext)).resolves.toBe(undefined); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts index cba5140997f3f..7dd1a4dc4410e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts @@ -22,12 +22,13 @@ export function createCollectorFetch(getUiSettingsClient: () => IUiSettingsClien const userProvided = await uiSettingsClient.getUserProvided(); const modifiedEntries = Object.entries(userProvided) .filter(([key]) => key !== 'buildNum') - .reduce((obj: any, [key, { userValue }]) => { + .reduce((obj: Record, [key, { userValue }]) => { const sensitive = uiSettingsClient.isSensitive(key); obj[key] = sensitive ? REDACTED_KEYWORD : userValue; return obj; }, {}); - return modifiedEntries; + // TODO: It would be Partial, but the telemetry-tools for the schema extraction still does not support it. We need to fix it before setting the right Partial type + return (modifiedEntries as unknown) as UsageStats; }; } 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 b8bc06d8a6a29..4dc1773ecfbe2 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -118,5 +118,6 @@ export interface UsageStats { 'banners:placement': string; 'banners:textColor': string; 'banners:backgroundColor': string; - 'labs:presentation:unifiedToolbar': boolean; + 'labs:canvas:enable_ui': boolean; + 'labs:presentation:timeToPresent': boolean; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts index 51ecbf736bfc1..31cb869d14e57 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts @@ -30,6 +30,8 @@ describe('telemetry_ui_metric', () => { const registerType = jest.fn(); const mockedFetchContext = createCollectorFetchContextMock(); + const commonSavedObjectsAttributes = { score: 0, references: [], type: 'ui-metric' }; + beforeAll(() => registerUiMetricUsageCollector(usageCollectionMock, registerType, getUsageCollector) ); @@ -44,13 +46,12 @@ describe('telemetry_ui_metric', () => { test('when savedObjectClient is initialised, return something', async () => { const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); + savedObjectClient.find.mockImplementation(async () => ({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + })); getUsageCollector.mockImplementation(() => savedObjectClient); expect(await collector.fetch(mockedFetchContext)).toStrictEqual({}); @@ -59,20 +60,33 @@ describe('telemetry_ui_metric', () => { test('results grouped by appName', async () => { const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async () => { - return { - saved_objects: [ - { id: 'testAppName:testKeyName1', attributes: { count: 3 } }, - { id: 'testAppName:testKeyName2', attributes: { count: 5 } }, - { id: 'testAppName2:testKeyName3', attributes: { count: 1 } }, - { - id: - 'kibana-user_agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:80.0) Gecko/20100101 Firefox/80.0', - attributes: { count: 1 }, - }, - ], - total: 3, - } as any; + savedObjectClient.find.mockResolvedValue({ + saved_objects: [ + { + ...commonSavedObjectsAttributes, + id: 'testAppName:testKeyName1', + attributes: { count: 3 }, + }, + { + ...commonSavedObjectsAttributes, + id: 'testAppName:testKeyName2', + attributes: { count: 5 }, + }, + { + ...commonSavedObjectsAttributes, + id: 'testAppName2:testKeyName3', + attributes: { count: 1 }, + }, + { + ...commonSavedObjectsAttributes, + id: + 'kibana-user_agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:80.0) Gecko/20100101 Firefox/80.0', + attributes: { count: 1 }, + }, + ], + total: 3, + per_page: 3, + page: 1, }); getUsageCollector.mockImplementation(() => savedObjectClient); diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index bc27cf061eb68..9af1bb5434bb1 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,5 +1,13 @@ .kbnTopNavMenu { - margin-right: $euiSizeXS; + @include kbnThemeStyle('v7') { + margin-right: $euiSizeXS; + } + + @include kbnThemeStyle('v8') { + button:last-child { + margin-right: 0; + } + } } .kbnTopNavMenu__badgeWrapper { diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index 65e42996ae910..ce7855c516c8b 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -8,9 +8,9 @@ import { i18n } from '@kbn/i18n'; -export const UNIFIED_TOOLBAR = 'labs:presentation:unifiedToolbar'; +export const TIME_TO_PRESENT = 'labs:presentation:timeToPresent'; -export const projectIDs = [UNIFIED_TOOLBAR] as const; +export const projectIDs = [TIME_TO_PRESENT] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; @@ -19,17 +19,18 @@ export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; * provided to users of our solutions in Kibana. */ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { - [UNIFIED_TOOLBAR]: { - id: UNIFIED_TOOLBAR, + [TIME_TO_PRESENT]: { + id: TIME_TO_PRESENT, isActive: false, + isDisplayed: false, environments: ['kibana', 'browser', 'session'], - name: i18n.translate('presentationUtil.labs.enableUnifiedToolbarProjectName', { - defaultMessage: 'Unified Toolbar', + name: i18n.translate('presentationUtil.labs.enableTimeToPresentProjectName', { + defaultMessage: 'Canvas Presentation UI', }), description: i18n.translate('presentationUtil.labs.enableUnifiedToolbarProjectDescription', { - defaultMessage: 'Enable the new unified toolbar design for Presentation solutions', + defaultMessage: 'Enable the new presentation-oriented UI for Canvas.', }), - solutions: ['dashboard', 'canvas'], + solutions: ['canvas'], }, }; @@ -51,6 +52,7 @@ export interface ProjectConfig { id: ProjectID; name: string; isActive: boolean; + isDisplayed: boolean; environments: EnvironmentName[]; description: string; solutions: SolutionName[]; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index af806e1c22f1a..508a1f4983031 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -25,11 +25,9 @@ export const withSuspense =

( ); -export const LazyLabsBeakerButton = withSuspense( - React.lazy(() => import('./labs/labs_beaker_button')) -); +export const LazyLabsBeakerButton = React.lazy(() => import('./labs/labs_beaker_button')); -export const LazyLabsFlyout = withSuspense(React.lazy(() => import('./labs/labs_flyout'))); +export const LazyLabsFlyout = React.lazy(() => import('./labs/labs_flyout')); export const LazyDashboardPicker = React.lazy(() => import('./dashboard_picker')); diff --git a/src/plugins/presentation_util/public/components/labs/environment_switch.tsx b/src/plugins/presentation_util/public/components/labs/environment_switch.tsx index 0acdd433cbac8..9b48bacf3780a 100644 --- a/src/plugins/presentation_util/public/components/labs/environment_switch.tsx +++ b/src/plugins/presentation_util/public/components/labs/environment_switch.tsx @@ -16,6 +16,7 @@ import { EuiScreenReaderOnly, } from '@elastic/eui'; +import { pluginServices } from '../../services'; import { EnvironmentName } from '../../../common/labs'; import { LabsStrings } from '../../i18n'; @@ -34,29 +35,36 @@ export interface Props { name: string; } -export const EnvironmentSwitch = ({ env, isChecked, onChange, name }: Props) => ( - - - - - - {name} - - - {switchText[env].name} - - } - onChange={(e) => onChange(e.target.checked)} - compressed - /> - - - - - - - -); +export const EnvironmentSwitch = ({ env, isChecked, onChange, name }: Props) => { + const { capabilities } = pluginServices.getHooks(); + + const canSet = env === 'kibana' ? capabilities.useService().canSetAdvancedSettings() : true; + + return ( + + + + + + {name} - + + {switchText[env].name} + + } + onChange={(e) => onChange(e.target.checked)} + compressed + /> + + + + + + + + ); +}; diff --git a/src/plugins/presentation_util/public/components/labs/labs.stories.tsx b/src/plugins/presentation_util/public/components/labs/labs.stories.tsx index a9a1a0753d24b..e8dd2abb0c5b8 100644 --- a/src/plugins/presentation_util/public/components/labs/labs.stories.tsx +++ b/src/plugins/presentation_util/public/components/labs/labs.stories.tsx @@ -16,7 +16,12 @@ export default { title: 'Labs/Flyout', description: 'A set of components used for providing Labs controls and projects in another solution.', - argTypes: {}, + argTypes: { + canSetAdvancedSettings: { + control: 'boolean', + defaultValue: true, + }, + }, }; export function BeakerButton() { diff --git a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx index 562d3b291a4b3..5b424c7e95f18 100644 --- a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx +++ b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx @@ -10,6 +10,8 @@ import React, { ReactNode, useRef, useState, useEffect } from 'react'; import { EuiFlyout, EuiTitle, + EuiSpacer, + EuiText, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, @@ -18,6 +20,7 @@ import { EuiFlexItem, EuiFlexGroup, EuiIcon, + EuiOverlayMask, } from '@elastic/eui'; import { SolutionName, ProjectStatus, ProjectID, Project, EnvironmentName } from '../../../common'; @@ -104,32 +107,47 @@ export const LabsFlyout = (props: Props) => { footer = ( - - {resetButton} - {refreshButton} + + + onClose()} flush="left"> + {strings.getCloseButtonLabel()} + + + + + {resetButton} + {refreshButton} + + ); return ( - - - -

- - - - - {strings.getTitleLabel()} - -

- - - - - - {footer} - + onClose()} headerZindexLocation="below"> + + + +

+ + + + + {strings.getTitleLabel()} + +

+
+ + +

{strings.getDescriptionMessage()}

+
+
+ + + + {footer} +
+
); }; diff --git a/src/plugins/presentation_util/public/components/labs/project_list.tsx b/src/plugins/presentation_util/public/components/labs/project_list.tsx index 4ecf45409b02c..301fd1aa6414f 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list.tsx +++ b/src/plugins/presentation_util/public/components/labs/project_list.tsx @@ -29,6 +29,10 @@ export const ProjectList = (props: Props) => { const items = Object.values(projects) .map((project) => { + if (!project.isDisplayed) { + return null; + } + // Filter out any panels that don't match the solutions filter, (if provided). if (solutions && !solutions.some((solution) => project.solutions.includes(solution))) { return null; diff --git a/src/plugins/presentation_util/public/components/labs/project_list_item.scss b/src/plugins/presentation_util/public/components/labs/project_list_item.scss index c91a07576b314..898770f7811a1 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list_item.scss +++ b/src/plugins/presentation_util/public/components/labs/project_list_item.scss @@ -10,7 +10,7 @@ left: 4px; bottom: $euiSizeL; width: 4px; - background: $euiColorPrimary; + background: $euiColorSecondary; content: ''; } @@ -37,10 +37,20 @@ } &--isOverridden:before { - left: -12px; + left: -$euiSizeS; } &--isOverridden:first-child:before { top: 0; } } + +.projectListItem__titlePendingChangesIndicator { + margin-left: $euiSizeS; + position: relative; + top: -1px; +} + +.projectListItem__solutions { + text-transform: capitalize; +} diff --git a/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx b/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx index ce93abded521e..bc6c123c21f34 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx +++ b/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx @@ -37,7 +37,7 @@ export function EmptyList() { export const ListItem = ( props: Pick< Props['project'], - 'description' | 'isActive' | 'name' | 'solutions' | 'environments' + 'description' | 'isActive' | 'name' | 'solutions' | 'environments' | 'isDisplayed' > & Omit ) => { diff --git a/src/plugins/presentation_util/public/components/labs/project_list_item.tsx b/src/plugins/presentation_util/public/components/labs/project_list_item.tsx index e4aa1abd3693c..994059c9789ec 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list_item.tsx +++ b/src/plugins/presentation_util/public/components/labs/project_list_item.tsx @@ -15,6 +15,8 @@ import { EuiText, EuiFormFieldset, EuiScreenReaderOnly, + EuiSpacer, + EuiIconTip, } from '@elastic/eui'; import classnames from 'classnames'; @@ -47,8 +49,20 @@ export const ProjectListItem = ({ project, onStatusChange }: Props) => { - -

{name}

+ +

+ {name} + {isOverride ? ( + + + + ) : null} +

@@ -59,10 +73,14 @@ export const ProjectListItem = ({ project, onStatusChange }: Props) => {
- {description} + + + {description} + - + + {isActive ? strings.getEnabledStatusMessage() : strings.getDisabledStatusMessage()} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index b8022201acf59..4fc3651ee9f73 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -4,4 +4,9 @@ // Lighten the border color for all states border-color: $euiBorderColor !important; // sass-lint:disable-line no-important + + @include kbnThemeStyle('v8') { + border-width: $euiBorderWidthThin; + border-style: solid; + } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 870a9a945ed5d..876ee659b71d7 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -1,6 +1,12 @@ .quickButtonGroup { .quickButtonGroup__button { background-color: $euiColorEmptyShade; + @include kbnThemeStyle('v8') { + // sass-lint:disable-block no-important + border-width: $euiBorderWidthThin !important; + border-style: solid !important; + border-color: $euiBorderColor !important; + } } // Temporary fix for two tone icons to make them monochrome diff --git a/src/plugins/presentation_util/public/i18n/labs.tsx b/src/plugins/presentation_util/public/i18n/labs.tsx index ddf6346bd68ca..d9e34fa43ebb7 100644 --- a/src/plugins/presentation_util/public/i18n/labs.tsx +++ b/src/plugins/presentation_util/public/i18n/labs.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; export const LabsStrings = { Components: { @@ -18,7 +19,8 @@ export const LabsStrings = { defaultMessage: 'Kibana', }), help: i18n.translate('presentationUtil.labs.components.kibanaSwitchHelp', { - defaultMessage: 'Sets the corresponding Advanced Setting for this lab project in Kibana', + defaultMessage: + 'Sets the corresponding Advanced Setting for this lab project; affects all Kibana users', }), }), getBrowserSwitchText: () => ({ @@ -51,24 +53,28 @@ export const LabsStrings = { i18n.translate('presentationUtil.labs.components.overrideFlagsLabel', { defaultMessage: 'Override flags', }), + getOverriddenIconTipLabel: () => + i18n.translate('presentationUtil.labs.components.overridenIconTipLabel', { + defaultMessage: 'Default overridden', + }), getEnabledStatusMessage: () => ( Enabled, + status: Enabled, }} - description="Displays the current status of a lab project" + description="Displays the enabled status of a lab project" /> ), getDisabledStatusMessage: () => ( Disabled, + status: Disabled, }} - description="Displays the current status of a lab project" + description="Displays the disabled status of a lab project" /> ), }, @@ -77,6 +83,11 @@ export const LabsStrings = { i18n.translate('presentationUtil.labs.components.titleLabel', { defaultMessage: 'Lab projects', }), + getDescriptionMessage: () => + i18n.translate('presentationUtil.labs.components.descriptionMessage', { + defaultMessage: + 'Lab projects are features and functionality that are in-progress or experimental in nature. They can be enabled and disabled locally for your browser or tab, or in Kibana.', + }), getResetToDefaultLabel: () => i18n.translate('presentationUtil.labs.components.resetToDefaultLabel', { defaultMessage: 'Reset to defaults', @@ -89,6 +100,10 @@ export const LabsStrings = { i18n.translate('presentationUtil.labs.components.calloutHelp', { defaultMessage: 'Refresh to apply changes', }), + getCloseButtonLabel: () => + i18n.translate('presentationUtil.labs.components.closeButtonLabel', { + defaultMessage: 'Close', + }), }, }, }; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index fd3ae89419297..aee3cff92438b 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -8,6 +8,12 @@ import { PresentationUtilPlugin } from './plugin'; +export { + PresentationCapabilitiesService, + PresentationDashboardsService, + PresentationLabsService, +} from './services'; + export { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types'; export { SaveModalDashboardProps } from './components/types'; export { projectIDs, ProjectID, Project } from '../common/labs'; diff --git a/src/plugins/presentation_util/public/services/capabilities.ts b/src/plugins/presentation_util/public/services/capabilities.ts index 58d56d1a4d81d..421e3e672b328 100644 --- a/src/plugins/presentation_util/public/services/capabilities.ts +++ b/src/plugins/presentation_util/public/services/capabilities.ts @@ -10,4 +10,5 @@ export interface PresentationCapabilitiesService { canAccessDashboards: () => boolean; canCreateNewDashboards: () => boolean; canSaveVisualizations: () => boolean; + canSetAdvancedSettings: () => boolean; } diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index c01a95f64619c..30bab78aeb27b 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -10,6 +10,10 @@ import { PluginServices } from './create'; import { PresentationCapabilitiesService } from './capabilities'; import { PresentationDashboardsService } from './dashboards'; import { PresentationLabsService } from './labs'; + +export { PresentationCapabilitiesService } from './capabilities'; +export { PresentationDashboardsService } from './dashboards'; +export { PresentationLabsService } from './labs'; export interface PresentationUtilServices { dashboards: PresentationDashboardsService; capabilities: PresentationCapabilitiesService; diff --git a/src/plugins/presentation_util/public/services/kibana/capabilities.ts b/src/plugins/presentation_util/public/services/kibana/capabilities.ts index d46af31b30667..7b12a9a3cc618 100644 --- a/src/plugins/presentation_util/public/services/kibana/capabilities.ts +++ b/src/plugins/presentation_util/public/services/kibana/capabilities.ts @@ -16,11 +16,12 @@ export type CapabilitiesServiceFactory = KibanaPluginServiceFactory< >; export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreStart }) => { - const { dashboard, visualize } = coreStart.application.capabilities; + const { dashboard, visualize, advancedSettings } = coreStart.application.capabilities; return { canAccessDashboards: () => Boolean(dashboard.show), canCreateNewDashboards: () => Boolean(dashboard.createNew), canSaveVisualizations: () => Boolean(visualize.save), + canSetAdvancedSettings: () => Boolean(advancedSettings.save), }; }; diff --git a/src/plugins/presentation_util/public/services/kibana/labs.ts b/src/plugins/presentation_util/public/services/kibana/labs.ts index d2c0735c76eeb..db78103469880 100644 --- a/src/plugins/presentation_util/public/services/kibana/labs.ts +++ b/src/plugins/presentation_util/public/services/kibana/labs.ts @@ -14,6 +14,7 @@ import { ProjectID, Project, getProjectIDs, + SolutionName, } from '../../../common'; import { PresentationUtilPluginStartDeps } from '../../types'; import { KibanaPluginServiceFactory } from '../create'; @@ -35,9 +36,15 @@ export const labsServiceFactory: LabsServiceFactory = ({ coreStart }) => { const localStorage = window.localStorage; const sessionStorage = window.sessionStorage; - const getProjects = () => + const getProjects = (solutions: SolutionName[] = []) => projectIDs.reduce((acc, id) => { - acc[id] = getProject(id); + const project = getProject(id); + if ( + solutions.length === 0 || + solutions.some((solution) => project.solutions.includes(solution)) + ) { + acc[id] = project; + } return acc; }, {} as { [id in ProjectID]: Project }); diff --git a/src/plugins/presentation_util/public/services/labs.ts b/src/plugins/presentation_util/public/services/labs.ts index 72e9a232ea976..ef583bd4189a9 100644 --- a/src/plugins/presentation_util/public/services/labs.ts +++ b/src/plugins/presentation_util/public/services/labs.ts @@ -16,12 +16,13 @@ import { EnvironmentStatus, environmentNames, isProjectEnabledByStatus, + SolutionName, } from '../../common'; export interface PresentationLabsService { getProjectIDs: () => typeof projectIDs; getProject: (id: ProjectID) => Project; - getProjects: () => Record; + getProjects: (solutions?: SolutionName[]) => Record; setProjectStatus: (id: ProjectID, env: EnvironmentName, status: boolean) => void; reset: () => void; } diff --git a/src/plugins/presentation_util/public/services/storybook/capabilities.ts b/src/plugins/presentation_util/public/services/storybook/capabilities.ts index 60285f00993ab..1dd8cfd571e5c 100644 --- a/src/plugins/presentation_util/public/services/storybook/capabilities.ts +++ b/src/plugins/presentation_util/public/services/storybook/capabilities.ts @@ -18,14 +18,14 @@ type CapabilitiesServiceFactory = PluginServiceFactory< export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ canAccessDashboards, canCreateNewDashboards, - canEditDashboards, canSaveVisualizations, + canSetAdvancedSettings, }) => { const check = (value: boolean = true) => value; return { canAccessDashboards: () => check(canAccessDashboards), canCreateNewDashboards: () => check(canCreateNewDashboards), - canEditDashboards: () => check(canEditDashboards), canSaveVisualizations: () => check(canSaveVisualizations), + canSetAdvancedSettings: () => check(canSetAdvancedSettings), }; }; diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index 37669d52c0096..40fdc40a4632e 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -18,8 +18,8 @@ export { PresentationUtilServices } from '..'; export interface StorybookParams { canAccessDashboards?: boolean; canCreateNewDashboards?: boolean; - canEditDashboards?: boolean; canSaveVisualizations?: boolean; + canSetAdvancedSettings?: boolean; } export const providers: PluginServiceProviders = { diff --git a/src/plugins/presentation_util/public/services/storybook/labs.ts b/src/plugins/presentation_util/public/services/storybook/labs.ts index 8878e218f19e8..396db52460053 100644 --- a/src/plugins/presentation_util/public/services/storybook/labs.ts +++ b/src/plugins/presentation_util/public/services/storybook/labs.ts @@ -8,7 +8,7 @@ import { EnvironmentName, projectIDs, Project } from '../../../common'; import { PluginServiceFactory } from '../create'; -import { projects, ProjectID, getProjectIDs } from '../../../common'; +import { projects, ProjectID, getProjectIDs, SolutionName } from '../../../common'; import { PresentationLabsService, isEnabledByStorageValue, applyProjectStatus } from '../labs'; export type LabsServiceFactory = PluginServiceFactory; @@ -16,9 +16,15 @@ export type LabsServiceFactory = PluginServiceFactory; export const labsServiceFactory: LabsServiceFactory = () => { const storage = window.sessionStorage; - const getProjects = () => + const getProjects = (solutions: SolutionName[] = []) => projectIDs.reduce((acc, id) => { - acc[id] = getProject(id); + const project = getProject(id); + if ( + solutions.length === 0 || + solutions.some((solution) => project.solutions.includes(solution)) + ) { + acc[id] = project; + } return acc; }, {} as { [id in ProjectID]: Project }); diff --git a/src/plugins/presentation_util/public/services/stub/capabilities.ts b/src/plugins/presentation_util/public/services/stub/capabilities.ts index 80b913c4f0856..be1be966285f7 100644 --- a/src/plugins/presentation_util/public/services/stub/capabilities.ts +++ b/src/plugins/presentation_util/public/services/stub/capabilities.ts @@ -14,6 +14,6 @@ type CapabilitiesServiceFactory = PluginServiceFactory ({ canAccessDashboards: () => true, canCreateNewDashboards: () => true, - canEditDashboards: () => true, canSaveVisualizations: () => true, + canSetAdvancedSettings: () => true, }); diff --git a/src/plugins/presentation_util/public/services/stub/labs.ts b/src/plugins/presentation_util/public/services/stub/labs.ts index c83bb68b5d072..c511ed26ef32e 100644 --- a/src/plugins/presentation_util/public/services/stub/labs.ts +++ b/src/plugins/presentation_util/public/services/stub/labs.ts @@ -13,6 +13,7 @@ import { EnvironmentName, getProjectIDs, Project, + SolutionName, } from '../../../common'; import { PluginServiceFactory } from '../create'; import { PresentationLabsService, isEnabledByStorageValue, applyProjectStatus } from '../labs'; @@ -36,9 +37,15 @@ export const labsServiceFactory: LabsServiceFactory = () => { let statuses = reset(); - const getProjects = () => + const getProjects = (solutions: SolutionName[] = []) => projectIDs.reduce((acc, id) => { - acc[id] = getProject(id); + const project = getProject(id); + if ( + solutions.length === 0 || + solutions.some((solution) => project.solutions.includes(solution)) + ) { + acc[id] = project; + } return acc; }, {} as { [id in ProjectID]: Project }); diff --git a/src/plugins/presentation_util/server/index.ts b/src/plugins/presentation_util/server/index.ts index de7e8de405442..d1f9ef6da760a 100644 --- a/src/plugins/presentation_util/server/index.ts +++ b/src/plugins/presentation_util/server/index.ts @@ -8,4 +8,5 @@ import { PresentationUtilPlugin } from './plugin'; +export { SETTING_CATEGORY } from './ui_settings'; export const plugin = () => new PresentationUtilPlugin(); diff --git a/src/plugins/saved_objects_management/common/index.ts b/src/plugins/saved_objects_management/common/index.ts index 06db1eaa25de8..efabdace329c3 100644 --- a/src/plugins/saved_objects_management/common/index.ts +++ b/src/plugins/saved_objects_management/common/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export { +export type { SavedObjectWithMetadata, SavedObjectMetadata, SavedObjectRelation, diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts index a6c25a6785e1a..7899cd0938ad3 100644 --- a/src/plugins/saved_objects_management/common/types.ts +++ b/src/plugins/saved_objects_management/common/types.ts @@ -19,6 +19,7 @@ export interface SavedObjectMetadata { editUrl?: string; inAppUrl?: { path: string; uiCapabilitiesPath: string }; namespaceType?: SavedObjectsNamespaceType; + hiddenType?: boolean; } /** diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx index cf4dcb7c6efda..2a7c56faa8507 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx @@ -89,7 +89,7 @@ export class SavedObjectEdition extends Component<
this.delete()} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index fdd423e10a117..809cd7a96a0ed 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -9,10 +9,12 @@ exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` Array [ Object { "id": "1", + "meta": Object {}, "type": "index-pattern", }, Object { "id": "3", + "meta": Object {}, "type": "dashboard", }, ] diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx index f6f00c95d9bf1..d589d5a700801 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { EuiInMemoryTable, EuiLoadingElastic, @@ -23,6 +23,7 @@ import { EuiButtonEmpty, EuiButton, EuiSpacer, + EuiCallOut, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -42,6 +43,13 @@ export const DeleteConfirmModal: FC = ({ onCancel, selectedObjects, }) => { + const undeletableObjects = useMemo(() => { + return selectedObjects.filter((obj) => obj.meta.hiddenType); + }, [selectedObjects]); + const deletableObjects = useMemo(() => { + return selectedObjects.filter((obj) => !obj.meta.hiddenType); + }, [selectedObjects]); + if (isDeleting) { return ( @@ -49,7 +57,6 @@ export const DeleteConfirmModal: FC = ({ ); } - // can't use `EuiConfirmModal` here as the confirm modal body is wrapped // inside a `

` element, causing UI glitches with the table. return ( @@ -63,6 +70,29 @@ export const DeleteConfirmModal: FC = ({ + {undeletableObjects.length > 0 && ( + <> + + } + iconType="alert" + color="warning" + > +

+ +

+ + + + )}

= ({

{ const component = shallowRender(); const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, + { id: '1', type: 'index-pattern', meta: {} }, + { id: '3', type: 'dashboard', meta: {} }, ] as SavedObjectWithMetadata[]; // Ensure all promises resolve @@ -498,8 +498,8 @@ describe('SavedObjectsTable', () => { it('should delete selected objects', async () => { const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, + { id: '1', type: 'index-pattern', meta: {} }, + { id: '3', type: 'dashboard', meta: {} }, ] as SavedObjectWithMetadata[]; const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({ @@ -529,7 +529,6 @@ describe('SavedObjectsTable', () => { await component.instance().delete(); expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); - expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( mockSavedObjects[0].type, mockSavedObjects[0].id, @@ -542,5 +541,44 @@ describe('SavedObjectsTable', () => { ); expect(component.state('selectedSavedObjects').length).toBe(0); }); + + it('should not delete hidden selected objects', async () => { + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern', meta: {} }, + { id: '3', type: 'hidden-type', meta: { hiddenType: true } }, + ] as SavedObjectWithMetadata[]; + + const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + source: {}, + })); + + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + bulkGet: jest.fn().mockImplementation(() => ({ + savedObjects: mockSavedObjects, + })), + delete: jest.fn(), + }; + + const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + + await component.instance().delete(); + + expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', '1', { + force: true, + }); + }); }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 1d272e818ea1e..c207766918a70 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -455,10 +455,9 @@ export class SavedObjectsTable extends Component - savedObjectsClient.delete(object.type, object.id, { force: true }) - ); + const deletes = selectedSavedObjects + .filter((object) => !object.meta.hiddenType) + .map((object) => savedObjectsClient.delete(object.type, object.id, { force: true })); await Promise.all(deletes); // Unset this diff --git a/src/plugins/saved_objects_management/public/services/types/record.ts b/src/plugins/saved_objects_management/public/services/types/record.ts index 17bdbc3a075f5..fc92c83cfc790 100644 --- a/src/plugins/saved_objects_management/public/services/types/record.ts +++ b/src/plugins/saved_objects_management/public/services/types/record.ts @@ -15,6 +15,7 @@ export interface SavedObjectsManagementRecord { icon: string; title: string; namespaceType: SavedObjectsNamespaceType; + hiddenType: boolean; }; references: SavedObjectReference[]; namespaces?: string[]; diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts index 686715aba7f17..0da14cbee4fd5 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts @@ -317,6 +317,7 @@ describe('findRelationships', () => { title: 'title', icon: 'icon', editUrl: 'editUrl', + hiddenType: false, inAppUrl: { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts index bc775a03e276d..7b5f52d6bf968 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts @@ -49,6 +49,7 @@ describe('injectMetaAttributes', () => { uiCapabilitiesPath: 'uiCapabilitiesPath', }, namespaceType: 'single', + hiddenType: false, }, }); }); diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts index ee64010994109..d5b585371cbdf 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts @@ -25,6 +25,7 @@ export function injectMetaAttributes( result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject); result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject); result.meta.namespaceType = savedObjectsManagement.getNamespaceType(savedObject); + result.meta.hiddenType = savedObjectsManagement.isHidden(savedObject); return result; } diff --git a/src/plugins/saved_objects_management/server/services/management.mock.ts b/src/plugins/saved_objects_management/server/services/management.mock.ts index 6541c0d2847f5..2ab5bea4f8440 100644 --- a/src/plugins/saved_objects_management/server/services/management.mock.ts +++ b/src/plugins/saved_objects_management/server/services/management.mock.ts @@ -19,6 +19,7 @@ const createManagementMock = () => { getEditUrl: jest.fn(), getInAppUrl: jest.fn(), getNamespaceType: jest.fn(), + isHidden: jest.fn().mockReturnValue(false), }; return mocked; }; diff --git a/src/plugins/saved_objects_management/server/services/management.ts b/src/plugins/saved_objects_management/server/services/management.ts index 395ba639846a8..176c52c5a21bc 100644 --- a/src/plugins/saved_objects_management/server/services/management.ts +++ b/src/plugins/saved_objects_management/server/services/management.ts @@ -44,4 +44,8 @@ export class SavedObjectsManagement { public getNamespaceType(savedObject: SavedObject) { return this.registry.getType(savedObject.type)?.namespaceType; } + + public isHidden(savedObject: SavedObject) { + return this.registry.getType(savedObject.type)?.hidden ?? false; + } } diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts index 617b5189de4a8..c93ba53230954 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts @@ -35,9 +35,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureCount: null, reportFailureVersion: failureVersion, - } as any, + }, }) ).toStrictEqual({ failureVersion, failureCount: 0 }); expect( @@ -51,9 +52,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureCount: 'not_a_number', reportFailureVersion: failureVersion, - } as any, + }, }) ).toStrictEqual({ failureVersion, failureCount: 0 }); }); @@ -63,9 +65,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureVersion: null, reportFailureCount: failureCount, - } as any, + }, }) ).toStrictEqual({ failureCount, failureVersion: undefined }); expect( @@ -76,9 +79,10 @@ describe('getTelemetryFailureDetails: get details about server usage fetcher fai expect( getTelemetryFailureDetails({ telemetrySavedObject: { + // @ts-expect-error the test is intentionally testing malformed SOs reportFailureVersion: 123, reportFailureCount: failureCount, - } as any, + }, }) ).toStrictEqual({ failureCount, failureVersion: undefined }); }); diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts index 65e4a2d43eef7..ede56688e0449 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts @@ -55,6 +55,7 @@ describe('getTelemetryOptIn', () => { // build a table of tests with version checks, with results for enabled false type VersionCheckTable = Array>; + // @ts-expect-error the test is intentionally testing malformed objects const EnabledFalseVersionChecks: VersionCheckTable = [ { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.0', result: false }, { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.1', result: false }, @@ -112,7 +113,7 @@ describe('getTelemetryOptIn', () => { interface CallGetTelemetryOptInParams { savedObjectNotFound: boolean; savedObjectForbidden: boolean; - lastVersionChecked?: any; // should be a string, but test with non-strings + lastVersionChecked?: string; // should be a string, but test with non-strings currentKibanaVersion: string; result?: boolean | null; enabled: boolean | null | undefined; diff --git a/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx b/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx index b91f6ee9e4b51..a10c26c22e3fa 100644 --- a/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx +++ b/src/plugins/telemetry/public/components/opted_in_notice_banner.tsx @@ -17,7 +17,7 @@ import { HttpSetup } from '../../../../core/public'; interface Props { http: HttpSetup; - onSeenBanner: () => any; + onSeenBanner: () => unknown; } export class OptedInNoticeBanner extends React.PureComponent { diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index 47ba7828eaec2..aef955e228dd3 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -8,7 +8,8 @@ import { PluginInitializerContext } from 'kibana/public'; import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; -export type { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; +export type { TelemetryPluginStart, TelemetryPluginSetup, TelemetryPluginConfig } from './plugin'; +export type { TelemetryNotifications, TelemetryService } from './services'; export function plugin(initializerContext: PluginInitializerContext) { return new TelemetryPlugin(initializerContext); diff --git a/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx b/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx index 4180f577e3037..f880aef3e3235 100644 --- a/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx +++ b/src/plugins/telemetry/public/services/telemetry_notifications/render_opt_in_banner.tsx @@ -13,7 +13,7 @@ import { toMountPoint } from '../../../../kibana_react/public'; interface RenderBannerConfig { overlays: CoreStart['overlays']; - setOptIn: (isOptIn: boolean) => Promise; + setOptIn: (isOptIn: boolean) => Promise; } export function renderOptInBanner({ setOptIn, overlays }: RenderBannerConfig) { diff --git a/src/plugins/telemetry/public/services/telemetry_sender.ts b/src/plugins/telemetry/public/services/telemetry_sender.ts index 05588f4c9e704..937416d283872 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.ts @@ -58,8 +58,8 @@ export class TelemetrySender { this.isSending = true; try { const telemetryUrl = this.telemetryService.getTelemetryUrl(); - const telemetryData: any | any[] = await this.telemetryService.fetchTelemetry(); - const clusters: string[] = [].concat(telemetryData); + const telemetryData: string | string[] = await this.telemetryService.fetchTelemetry(); + const clusters: string[] = ([] as string[]).concat(telemetryData); await Promise.all( clusters.map( async (cluster) => diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 842496815c15c..76460a57ee442 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8331,7 +8331,13 @@ "description": "Non-default value of setting." } }, - "labs:presentation:unifiedToolbar": { + "labs:presentation:timeToPresent": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, + "labs:canvas:enable_ui": { "type": "boolean", "_meta": { "description": "Non-default value of setting." diff --git a/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts index 6b79cc6c7410a..c5624d1f62bf7 100644 --- a/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts +++ b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts @@ -8,12 +8,15 @@ // // THIS IS A DIRECT COPY OF -// '../../../../../../../../src/core/server/config/ensure_deep_object' +// 'packages/kbn-config/src/raw/ensure_deep_object.ts' // BECAUSE THAT IS BLOCKED FOR IMPORTING BY OUR LINTER. // // IF THAT IS EXPOSED, WE SHOULD USE IT RATHER THAN CLONE IT. // +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ^ Disabling the rule for the entire file because of the complexity to type this + const separator = '.'; /** diff --git a/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts index ac439f0753a2b..2acc6676d13db 100644 --- a/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts +++ b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts @@ -15,10 +15,11 @@ import { readTelemetryFile, MAX_FILE_SIZE, } from './telemetry_usage_collector'; +import { usageCollectionPluginMock } from '../../../../usage_collection/server/mocks'; -const mockUsageCollector = () => ({ - makeUsageCollector: jest.fn().mockImplementationOnce((arg: object) => arg), -}); +const mockUsageCollector = () => { + return usageCollectionPluginMock.createSetupContract(); +}; describe('telemetry_usage_collector', () => { const tempDir = tmpdir(); @@ -105,14 +106,15 @@ describe('telemetry_usage_collector', () => { // dir // the `makeUsageCollector` is mocked above to return the argument passed to it - const usageCollector = mockUsageCollector() as any; + const usageCollector = mockUsageCollector(); const collectorOptions = createTelemetryUsageCollector( usageCollector, async () => tempFiles.unreadable ); expect(collectorOptions.type).toBe('static_telemetry'); - expect(await collectorOptions.fetch({} as any)).toEqual(expectedObject); // Sending any as the callCluster client because it's not needed in this collector but TS requires it when calling it. + // @ts-expect-error this collector does not require any arguments in the fetch method, but TS complains + expect(await collectorOptions.fetch()).toEqual(expectedObject); }); }); }); diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 5db1b62cb3e26..fb188a2414b98 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -9,10 +9,7 @@ import { Observable, Subscription, timer } from 'rxjs'; import { take } from 'rxjs/operators'; import fetch from 'node-fetch'; -import { - TelemetryCollectionManagerPluginStart, - UsageStatsPayload, -} from 'src/plugins/telemetry_collection_manager/server'; +import type { TelemetryCollectionManagerPluginStart } from 'src/plugins/telemetry_collection_manager/server'; import { PluginInitializerContext, Logger, @@ -40,6 +37,7 @@ interface TelemetryConfig { telemetryUrl: string; failureCount: number; failureVersion: string | undefined; + currentVersion: string; } export class FetcherTask { @@ -104,7 +102,7 @@ export class FetcherTask { return; } - let clusters: Array = []; + let clusters: string[] = []; this.isSending = true; try { @@ -160,6 +158,7 @@ export class FetcherTask { telemetryUrl, failureCount, failureVersion, + currentVersion: currentKibanaVersion, }; } @@ -187,11 +186,11 @@ export class FetcherTask { private shouldSendReport({ telemetryOptIn, telemetrySendUsageFrom, - reportFailureCount, + failureCount, + failureVersion, currentVersion, - reportFailureVersion, - }: any) { - if (reportFailureCount > 2 && reportFailureVersion === currentVersion) { + }: TelemetryConfig) { + if (failureCount > 2 && failureVersion === currentVersion) { return false; } @@ -209,7 +208,7 @@ export class FetcherTask { }); } - private async sendTelemetry(url: string, cluster: any): Promise { + private async sendTelemetry(url: string, cluster: string): Promise { this.logger.debug(`Sending usage stats.`); /** * send OPTIONS before sending usage data. diff --git a/src/plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts index 1c335426ffd03..005f50721e778 100644 --- a/src/plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -11,7 +11,6 @@ import { TelemetryPlugin } from './plugin'; import * as constants from '../common/constants'; import { configSchema, TelemetryConfigType } from './config'; -export { FetcherTask } from './fetcher'; export { handleOldSettings } from './handle_old_settings'; export type { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; @@ -42,4 +41,6 @@ export type { TelemetryLocalStats, DataTelemetryIndex, DataTelemetryPayload, + NodeUsage, + NodeUsageAggregation, } from './telemetry_collection'; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts index 9e70e31925226..cd414beb42182 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.test.ts @@ -9,14 +9,10 @@ import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { getClusterInfo } from './get_cluster_info'; -export function mockGetClusterInfo(clusterInfo: any) { +export function mockGetClusterInfo(clusterInfo: ClusterInfo) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.info.mockResolvedValue( - // @ts-expect-error we only care about the response body - { - body: { ...clusterInfo }, - } - ); + // @ts-expect-error we only care about the response body + esClient.info.mockResolvedValue({ body: { ...clusterInfo } }); return esClient; } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts index a2c22fbbb0a78..06d3ebeb7ea0e 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.test.ts @@ -10,15 +10,9 @@ import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { getClusterStats } from './get_cluster_stats'; import { TIMEOUT } from './constants'; -export function mockGetClusterStats(clusterStats: any) { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.cluster.stats.mockResolvedValue(clusterStats); - return esClient; -} - describe('get_cluster_stats', () => { it('uses the esClient to get the response from the `cluster.stats` API', async () => { - const response = Promise.resolve({ body: { cluster_uuid: '1234' } }); + const response = { body: { cluster_uuid: '1234' } }; const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.cluster.stats.mockImplementationOnce( // @ts-expect-error the method only cares about the response body @@ -26,8 +20,8 @@ describe('get_cluster_stats', () => { return response; } ); - const result = getClusterStats(esClient); + const result = await getClusterStats(esClient); expect(esClient.cluster.stats).toHaveBeenCalledWith({ timeout: TIMEOUT }); - expect(result).toStrictEqual(response); + expect(result).toStrictEqual(response.body); }); }); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts index d2113dce9548f..dab1eaeed27ce 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts @@ -271,7 +271,7 @@ describe('get_data_telemetry', () => { function mockEsClient( indicesMappings: string[] = [], // an array of `indices` to get mappings from. { isECS = false, dataStreamDataset = '', dataStreamType = '', shipper = '' } = {}, - indexStats: any = {} + indexStats = {} ) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; // @ts-expect-error diff --git a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 566c942890150..3f1966901544a 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -8,7 +8,7 @@ import { omit } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { ISavedObjectsRepository, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server'; import { ElasticsearchClient } from 'src/core/server'; @@ -27,7 +27,7 @@ export interface KibanaUsageStats { }; }; - [plugin: string]: any; + [plugin: string]: Record; } export function handleKibanaStats( @@ -73,7 +73,7 @@ export function handleKibanaStats( export async function getKibana( usageCollection: UsageCollectionSetup, asInternalUser: ElasticsearchClient, - soClient: SavedObjectsClientContract | ISavedObjectsRepository, + soClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter ): Promise { const usage = await usageCollection.bulkFetch(asInternalUser, soClient, kibanaRequest); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index edf8dbb30809b..7fd6ca4080d6a 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -20,17 +20,18 @@ import { StatsCollectionConfig } from '../../../telemetry_collection_manager/ser function mockUsageCollection(kibanaUsage = {}) { const usageCollection = usageCollectionPluginMock.createSetupContract(); usageCollection.bulkFetch = jest.fn().mockResolvedValue(kibanaUsage); - usageCollection.toObject = jest.fn().mockImplementation((data: any) => data); + usageCollection.toObject = jest.fn().mockImplementation((data) => data); return usageCollection; } // set up successful call mocks for info, cluster stats, nodes usage and data telemetry -function mockGetLocalStats(clusterInfo: any, clusterStats: any) { +function mockGetLocalStats( + clusterInfo: ClusterInfo, + clusterStats: ClusterStats +) { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.info.mockResolvedValue( // @ts-expect-error we only care about the response body - { - body: { ...clusterInfo }, - } + { body: { ...clusterInfo } } ); esClient.cluster.stats // @ts-expect-error we only care about the response body @@ -70,8 +71,8 @@ function mockGetLocalStats(clusterInfo: any, clusterStats: any) { } function mockStatsCollectionConfig( - clusterInfo: any, - clusterStats: any, + clusterInfo: unknown, + clusterStats: unknown, kibana: {} ): StatsCollectionConfig { return { @@ -113,13 +114,13 @@ describe('get_local_stats', () => { }, }, ]; - const clusterStats = { + const clusterStats = ({ _nodes: { failed: 123 }, cluster_name: 'real-cool', indices: { totally: 456 }, nodes: { yup: 'abc' }, random: 123, - }; + } as unknown) as estypes.ClusterStatsResponse; const kibana = { kibana: { diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index 67f9ebb8ff3e4..72f6ba855096c 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -26,10 +26,10 @@ import { getDataTelemetry, DATA_TELEMETRY_ID, DataTelemetryPayload } from './get * @param {Object} clusterStats Cluster stats (GET /_cluster/stats) * @param {Object} kibana The Kibana Usage stats */ -export function handleLocalStats( +export function handleLocalStats( // eslint-disable-next-line @typescript-eslint/naming-convention { cluster_name, cluster_uuid, version }: estypes.RootNodeInfoResponse, - { _nodes, cluster_name: clusterName, ...clusterStats }: any, + { _nodes, cluster_name: clusterName, ...clusterStats }: ClusterStats, kibana: KibanaUsageStats | undefined, dataTelemetry: DataTelemetryPayload | undefined, context: StatsCollectionContext diff --git a/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts b/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts index e46d4be540734..544142c8d742f 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts @@ -9,12 +9,12 @@ import { ElasticsearchClient } from 'src/core/server'; import { TIMEOUT } from './constants'; -export interface NodeAggregation { +export interface NodeUsageAggregation { [key: string]: number; } // we set aggregations as an optional type because it was only added in v7.8.0 -export interface NodeObj { +export interface NodeUsage { node_id?: string; timestamp: number | string; since: number; @@ -22,20 +22,20 @@ export interface NodeObj { [key: string]: number; }; aggregations?: { - [key: string]: NodeAggregation; + [key: string]: NodeUsageAggregation; }; } export interface NodesFeatureUsageResponse { cluster_name: string; nodes: { - [key: string]: NodeObj; + [key: string]: NodeUsage; }; } export type NodesUsageGetter = ( esClient: ElasticsearchClient -) => Promise<{ nodes: NodeObj[] | Array<{}> }>; +) => Promise<{ nodes: NodeUsage[] | Array<{}> }>; /** * Get the nodes usage data from the connected cluster. * @@ -61,7 +61,7 @@ export async function fetchNodesUsage( export const getNodesUsage: NodesUsageGetter = async (esClient) => { const result = await fetchNodesUsage(esClient); const transformedNodes = Object.entries(result?.nodes || {}).map(([key, value]) => ({ - ...(value as NodeObj), + ...(value as NodeUsage), node_id: key, })); return { nodes: transformedNodes }; diff --git a/src/plugins/telemetry/server/telemetry_collection/index.ts b/src/plugins/telemetry/server/telemetry_collection/index.ts index 151e89a11a192..f55147a0a083f 100644 --- a/src/plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -10,5 +10,6 @@ export { DATA_TELEMETRY_ID, buildDataTelemetryPayload } from './get_data_telemet export type { DataTelemetryIndex, DataTelemetryPayload } from './get_data_telemetry'; export { getLocalStats } from './get_local_stats'; export type { TelemetryLocalStats } from './get_local_stats'; +export type { NodeUsage, NodeUsageAggregation } from './get_nodes_usage'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts index fbead0125fe09..5ea8211739a13 100644 --- a/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts +++ b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts @@ -8,6 +8,7 @@ import { getTelemetrySavedObject } from './get_telemetry_saved_object'; import { SavedObjectsErrorHelpers } from '../../../../core/server'; +import { savedObjectsClientMock } from '../../../../core/server/mocks'; describe('getTelemetrySavedObject', () => { it('returns null when saved object not found', async () => { @@ -51,7 +52,7 @@ interface CallGetTelemetrySavedObjectParams { savedObjectNotFound: boolean; savedObjectForbidden: boolean; savedObjectOtherError: boolean; - result?: any; + result?: unknown; } const DefaultParams = { @@ -68,26 +69,22 @@ function getCallGetTelemetrySavedObjectParams( async function callGetTelemetrySavedObject(params: CallGetTelemetrySavedObjectParams) { const savedObjectsClient = getMockSavedObjectsClient(params); - return await getTelemetrySavedObject(savedObjectsClient as any); + return await getTelemetrySavedObject(savedObjectsClient); } const SavedObjectForbiddenMessage = 'savedObjectForbidden'; const SavedObjectOtherErrorMessage = 'savedObjectOtherError'; function getMockSavedObjectsClient(params: CallGetTelemetrySavedObjectParams) { - return { - async get(type: string, id: string) { - if (params.savedObjectNotFound) throw SavedObjectsErrorHelpers.createGenericNotFoundError(); - if (params.savedObjectForbidden) - throw SavedObjectsErrorHelpers.decorateForbiddenError( - new Error(SavedObjectForbiddenMessage) - ); - if (params.savedObjectOtherError) - throw SavedObjectsErrorHelpers.decorateGeneralError( - new Error(SavedObjectOtherErrorMessage) - ); - - return { attributes: { enabled: null } }; - }, - }; + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockImplementation(async (type, id) => { + if (params.savedObjectNotFound) throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + if (params.savedObjectForbidden) + throw SavedObjectsErrorHelpers.decorateForbiddenError(new Error(SavedObjectForbiddenMessage)); + if (params.savedObjectOtherError) + throw SavedObjectsErrorHelpers.decorateGeneralError(new Error(SavedObjectOtherErrorMessage)); + + return { id, type, attributes: { enabled: null }, references: [] }; + }); + return savedObjectsClient; } diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts index a2c24627f6fd7..1b80a2c29b362 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts @@ -13,12 +13,12 @@ export function getKID(useProdKey = false): string { return useProdKey ? 'kibana1' : 'kibana_dev1'; } -export async function encryptTelemetry( - payload: any, +export async function encryptTelemetry( + payload: Payload | Payload[], { useProdKey = false } = {} ): Promise { const kid = getKID(useProdKey); const encryptor = await createRequestEncryptor(telemetryJWKS); - const clusters = [].concat(payload); - return Promise.all(clusters.map((cluster: any) => encryptor.encrypt(kid, cluster))); + const clusters = ([] as Payload[]).concat(payload); + return Promise.all(clusters.map((cluster) => encryptor.encrypt(kid, cluster))); } diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index 692d91b963d9d..0efdde5eeafd6 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -7,7 +7,7 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { +import type { PluginInitializerContext, CoreSetup, CoreStart, @@ -19,7 +19,7 @@ import { SavedObjectsClientContract, } from 'src/core/server'; -import { +import type { TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart, BasicStatsPayload, @@ -29,6 +29,8 @@ import { StatsCollectionConfig, UsageStatsPayload, StatsCollectionContext, + UnencryptedStatsGetterConfig, + EncryptedStatsGetterConfig, } from './types'; import { encryptTelemetry } from './encryption'; import { TelemetrySavedObjectsClient } from './telemetry_saved_objects_client'; @@ -40,7 +42,7 @@ interface TelemetryCollectionPluginsDepsSetup { export class TelemetryCollectionManagerPlugin implements Plugin { private readonly logger: Logger; - private collectionStrategy: CollectionStrategy | undefined; + private collectionStrategy: CollectionStrategy | undefined; private usageGetterMethodPriority = -1; private usageCollection?: UsageCollectionSetup; private elasticsearchClient?: IClusterClient; @@ -215,6 +217,8 @@ export class TelemetryCollectionManagerPlugin })); }; + private async getStats(config: UnencryptedStatsGetterConfig): Promise; + private async getStats(config: EncryptedStatsGetterConfig): Promise; private async getStats(config: StatsGetterConfig) { if (!this.usageCollection) { return []; diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 896b1671328a9..e0ba7e7527af7 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -95,13 +95,14 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = ` size="s" />

@@ -156,12 +157,26 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = `

, "displayName": "Provide usage statistics", + "isCustom": true, + "isOverridden": false, "name": "telemetry:enabled", + "requiresPageReload": false, "type": "boolean", "value": true, } } - toasts={null} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } /> @@ -170,6 +185,7 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = ` exports[`TelemetryManagementSectionComponent renders null because allowChangingOptInStatus is false 1`] = ` Promise; + fetchExample: () => Promise; onClose: () => void; } interface State { isLoading: boolean; hasPrivilegeToRead: boolean; - data: any[] | null; + data: unknown[] | null; } /** diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx index 7e7e255edea8c..019dedd793fa2 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx @@ -12,9 +12,11 @@ import TelemetryManagementSection from './telemetry_management_section'; import { TelemetryService } from '../../../telemetry/public/services'; import { coreMock } from '../../../../core/public/mocks'; import { render } from '@testing-library/react'; +import type { DocLinksStart } from 'src/core/public'; describe('TelemetryManagementSectionComponent', () => { const coreStart = coreMock.createStart(); + const docLinks = {} as DocLinksStart['links']; const coreSetup = coreMock.createSetup(); it('renders as expected', () => { @@ -45,6 +47,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ) ).toMatchSnapshot(); @@ -78,6 +81,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); @@ -93,6 +97,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} toasts={coreStart.notifications.toasts} isSecurityExampleEnabled={isSecurityExampleEnabled} + docLinks={docLinks} /> ); @@ -130,6 +135,7 @@ describe('TelemetryManagementSectionComponent', () => { isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -177,6 +183,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -215,6 +222,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -254,6 +262,7 @@ describe('TelemetryManagementSectionComponent', () => { isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -293,6 +302,7 @@ describe('TelemetryManagementSectionComponent', () => { isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); @@ -332,6 +342,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} + docLinks={docLinks} /> ); try { @@ -339,11 +350,12 @@ describe('TelemetryManagementSectionComponent', () => { await expect( toggleOptInComponent.prop('handleChange')() ).resolves.toBe(true); - expect((component.state() as any).enabled).toBe(true); + // TODO: Fix `mountWithIntl` types in @kbn/test/jest to make testing easier + expect((component.state() as { enabled: boolean }).enabled).toBe(true); await expect( toggleOptInComponent.prop('handleChange')() ).resolves.toBe(true); - expect((component.state() as any).enabled).toBe(false); + expect((component.state() as { enabled: boolean }).enabled).toBe(false); telemetryService.setOptIn = jest.fn().mockRejectedValue(Error('test-error')); await expect( toggleOptInComponent.prop('handleChange')() @@ -381,6 +393,7 @@ describe('TelemetryManagementSectionComponent', () => { enableSaving={true} toasts={coreStart.notifications.toasts} isSecurityExampleEnabled={isSecurityExampleEnabled} + docLinks={docLinks} /> ).html() ).toMatchSnapshot(); diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx index 5a9f3922c6caf..e9ddc4cf82dfc 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx @@ -20,12 +20,12 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import type { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import type { DocLinksStart, ToastsStart } from 'src/core/public'; import { PRIVACY_STATEMENT_URL } from '../../../telemetry/common/constants'; import { OptInExampleFlyout } from './opt_in_example_flyout'; import { OptInSecurityExampleFlyout } from './opt_in_security_example_flyout'; import { LazyField } from '../../../advanced_settings/public'; -import { ToastsStart } from '../../../../core/public'; import { TrackApplicationView } from '../../../usage_collection/public'; type TelemetryService = TelemetryPluginSetup['telemetryService']; @@ -40,6 +40,7 @@ interface Props { enableSaving: boolean; query?: { text: string }; toasts: ToastsStart; + docLinks: DocLinksStart['links']; } interface State { @@ -130,24 +131,26 @@ export class TelemetryManagementSection extends Component { {this.maybeGetAppliesSettingMessage()} diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx index cc38b1ec74b37..91881dffa52d7 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx @@ -8,10 +8,12 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; -// It should be this but the types are way too vague in the AdvancedSettings plugin `Record` -// type Props = Omit; -type Props = any; +import type { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import type TelemetryManagementSection from './telemetry_management_section'; +export type TelemetryManagementSectionWrapperProps = Omit< + TelemetryManagementSection['props'], + 'telemetryService' | 'showAppliesSettingMessage' | 'isSecurityExampleEnabled' +>; const TelemetryManagementSectionComponent = lazy(() => import('./telemetry_management_section')); @@ -19,7 +21,7 @@ export function telemetryManagementSectionWrapper( telemetryService: TelemetryPluginSetup['telemetryService'], shouldShowSecuritySolutionUsageExample: () => boolean ) { - const TelemetryManagementSectionWrapper = (props: Props) => ( + const TelemetryManagementSectionWrapper = (props: TelemetryManagementSectionWrapperProps) => ( }> ); }, diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 90e873388d22e..22c91ac0c038d 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -141,11 +141,11 @@ export type CollectorOptions< }); export class Collector { - public readonly extendFetchContext: CollectorOptionsFetchExtendedContext; - public readonly type: CollectorOptions['type']; - public readonly init?: CollectorOptions['init']; - public readonly fetch: CollectorFetchMethod; - public readonly isReady: CollectorOptions['isReady']; + public readonly extendFetchContext: CollectorOptionsFetchExtendedContext; + public readonly type: CollectorOptions['type']; + public readonly init?: CollectorOptions['init']; + public readonly fetch: CollectorFetchMethod; + public readonly isReady: CollectorOptions['isReady']; /** * @private Constructor of a Collector. It should be called via the CollectorSet factory methods: `makeStatsCollector` and `makeUsageCollector` * @param log {@link Logger} @@ -160,7 +160,9 @@ export class Collector { isReady, extendFetchContext = {}, ...options - }: CollectorOptions + }: // Any does not affect here, but needs to be set so it doesn't affect anything else down the line + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CollectorOptions ) { if (type === undefined) { throw new Error('Collector must be instantiated with a options.type string property'); diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index 0ef9a27cf094c..5a617e2316dda 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -13,7 +13,7 @@ import { UsageCollector } from './usage_collector'; import { elasticsearchServiceMock, loggingSystemMock, - savedObjectsRepositoryMock, + savedObjectsClientMock, } from '../../../../core/server/mocks'; const logger = loggingSystemMock.createLogger(); @@ -34,7 +34,7 @@ describe('CollectorSet', () => { loggerSpies.warn.mockRestore(); }); const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const mockSoClient = savedObjectsRepositoryMock.create(); + const mockSoClient = savedObjectsClientMock.create(); const req = void 0; // No need to instantiate any KibanaRequest in these tests it('should throw an error if non-Collector type of object is registered', () => { @@ -43,8 +43,9 @@ describe('CollectorSet', () => { collectors.registerCollector({ type: 'type_collector_test', init, + // @ts-expect-error we are intentionally sending it wrong. fetch, - } as any); // We are intentionally sending it wrong. + }); }; expect(registerPojo).toThrowError( @@ -71,13 +72,14 @@ describe('CollectorSet', () => { }); it('should log debug status of fetching from the collector', async () => { - mockEsClient.get.mockResolvedValue({ passTest: 1000 } as any); + // @ts-expect-error we are just mocking the output of any call + mockEsClient.ping.mockResolvedValue({ passTest: 1000 }); const collectors = new CollectorSet({ logger }); collectors.registerCollector( new Collector(logger, { type: 'MY_TEST_COLLECTOR', - fetch: (collectorFetchContext: any) => { - return collectorFetchContext.esClient.get(); + fetch: (collectorFetchContext) => { + return collectorFetchContext.esClient.ping(); }, isReady: () => true, }) @@ -122,7 +124,8 @@ describe('CollectorSet', () => { new Collector(logger, { type: 'MY_TEST_COLLECTOR', fetch: () => ({ test: 1 }), - isReady: true as any, + // @ts-expect-error we are intentionally sending it wrong + isReady: true, }) ); @@ -138,10 +141,11 @@ describe('CollectorSet', () => { it('should not break if isReady is not provided', async () => { const collectors = new CollectorSet({ logger }); collectors.registerCollector( + // @ts-expect-error we are intentionally sending it wrong. new Collector(logger, { type: 'MY_TEST_COLLECTOR', fetch: () => ({ test: 1 }), - } as any) + }) ); const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req); diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 4de5691eaaa70..d42eb6644bbbe 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -7,16 +7,17 @@ */ import { snakeCase } from 'lodash'; -import { +import type { Logger, ElasticsearchClient, - ISavedObjectsRepository, SavedObjectsClientContract, KibanaRequest, } from 'src/core/server'; import { Collector, CollectorOptions } from './collector'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; +// Needed for the general array containing all the collectors. We don't really care about their types here +// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyCollector = Collector; interface CollectorSetConfig { @@ -144,7 +145,7 @@ export class CollectorSet { public bulkFetch = async ( esClient: ElasticsearchClient, - soClient: SavedObjectsClientContract | ISavedObjectsRepository, + soClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter collectors: Map = this.collectors ) => { @@ -183,7 +184,7 @@ export class CollectorSet { public bulkFetchUsage = async ( esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository, + savedObjectsClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter ) => { const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector); diff --git a/src/plugins/usage_collection/server/collector/usage_collector.ts b/src/plugins/usage_collection/server/collector/usage_collector.ts index 1509b10654f49..3af3a7bb65f84 100644 --- a/src/plugins/usage_collection/server/collector/usage_collector.ts +++ b/src/plugins/usage_collection/server/collector/usage_collector.ts @@ -23,6 +23,8 @@ export class UsageCollector exte > { constructor( log: Logger, + // Needed because it doesn't affect on anything here but being explicit creates a lot of pain down the line + // eslint-disable-next-line @typescript-eslint/no-explicit-any collectorOptions: UsageCollectorOptions ) { super(log, collectorOptions); diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts index 08fdec4ae804f..789e01020bb2e 100644 --- a/src/plugins/usage_collection/server/report/store_report.test.ts +++ b/src/plugins/usage_collection/server/report/store_report.test.ts @@ -118,7 +118,7 @@ describe('store_report', () => { expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1); expect(storeApplicationUsageMock).toHaveBeenCalledWith( repository, - Object.values(report.application_usage as Record), + Object.values(report.application_usage!), expect.any(Date) ); }); diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts index 416a69dd9a8f9..6cae56afa281b 100644 --- a/src/plugins/usage_collection/server/routes/stats/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -15,7 +15,6 @@ import { first } from 'rxjs/operators'; import { ElasticsearchClient, IRouter, - ISavedObjectsRepository, KibanaRequest, MetricsServiceSetup, SavedObjectsClientContract, @@ -30,6 +29,12 @@ const STATS_NOT_READY_MESSAGE = i18n.translate('usageCollection.stats.notReadyMe const SNAPSHOT_REGEX = /-snapshot/i; +interface UsageObject { + kibana?: UsageObject; + xpack?: UsageObject; + [key: string]: unknown | UsageObject; +} + export function registerStatsRoute({ router, config, @@ -55,9 +60,9 @@ export function registerStatsRoute({ }) { const getUsage = async ( esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository, + savedObjectsClient: SavedObjectsClientContract, kibanaRequest: KibanaRequest - ): Promise => { + ): Promise => { const usage = await collectorSet.bulkFetchUsage(esClient, savedObjectsClient, kibanaRequest); return collectorSet.toObject(usage); }; @@ -104,7 +109,7 @@ export function registerStatsRoute({ const usagePromise = shouldGetUsage ? getUsage(asCurrentUser, savedObjectsClient, req) - : Promise.resolve({}); + : Promise.resolve({}); const [usage, clusterUuid] = await Promise.all([ usagePromise, getClusterUuid(asCurrentUser), @@ -138,7 +143,7 @@ export function registerStatsRoute({ } return accum; - }, {} as any); + }, {} as UsageObject); extended = { usage: modifiedUsage, diff --git a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js index 4d93a5207fa9e..09ce57639b952 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import _ from 'lodash'; @@ -24,7 +25,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiFormLabel, EuiSpacer, EuiFieldText, EuiTitle, @@ -156,32 +156,36 @@ export class AnnotationsEditor extends Component { - - + - - - + - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index c5b3d86f61b5d..556a3f2f691fb 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -126,6 +126,7 @@ export const IndexPattern = ({ ); const isTimeSeries = model.type === PANEL_TYPES.TIMESERIES; const isDataTimerangeModeInvalid = + !disabled && selectedTimeRangeOption && !isTimerangeModeEnabled(selectedTimeRangeOption.value, uiRestrictions); diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx index 99c3fa8ea9673..f5cc90ee49acd 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import uuid from 'uuid'; import { @@ -23,8 +24,7 @@ import { EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; + import type { Writable } from '@kbn/utility-types'; // @ts-ignore @@ -157,18 +157,20 @@ export class GaugePanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx index c3f0f00125769..c33b4df914a81 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx @@ -172,18 +172,20 @@ export class MarkdownPanelConfig extends Component< - - + - - - + @@ -218,35 +220,34 @@ export class MarkdownPanelConfig extends Component< /> - - + - - - - + - - + - - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx index f38d0ec83e957..68486d0d1e83f 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import uuid from 'uuid'; import { @@ -16,12 +17,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiFormLabel, EuiSpacer, EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error import { SeriesEditor } from '../series_editor'; @@ -121,18 +120,20 @@ export class MetricPanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx index 0847a35066494..4eae56c748671 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx @@ -17,7 +17,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiFormLabel, EuiSpacer, EuiFieldText, EuiTitle, @@ -28,6 +27,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { FieldSelect } from '../aggs/field_select'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; @@ -246,18 +246,20 @@ export class TablePanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx index ae36408a08b46..ae9d7326140a7 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { @@ -22,8 +24,6 @@ import { EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; @@ -212,18 +212,20 @@ export class TimeseriesPanelConfig extends Component< - - + - - - + @@ -333,19 +335,17 @@ export class TimeseriesPanelConfig extends Component< /> - - + - - - - + @@ -366,15 +366,16 @@ export class TimeseriesPanelConfig extends Component< /> - - - - - - + + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx index a537a769cac11..30d65f6edd845 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import uuid from 'uuid'; import { @@ -23,7 +24,6 @@ import { EuiHorizontalRule, EuiCode, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; @@ -149,18 +149,20 @@ export class TopNPanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js index 8f3893feb89bd..86781c9922e46 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config.js @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import PropTypes from 'prop-types'; import React from 'react'; import { DataFormatPicker } from './data_format_picker'; @@ -21,10 +22,7 @@ import { EuiFormRow, EuiCode, EuiHorizontalRule, - EuiFormLabel, - EuiSpacer, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from './series_config_query_bar_with_ignore_global_filter'; export const SeriesConfig = (props) => { @@ -104,18 +102,17 @@ export const SeriesConfig = (props) => { - - + - - - + - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 1c3a0411998b0..72f5034cfc61b 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import React, { useState, useEffect } from 'react'; @@ -23,8 +24,6 @@ import { EuiCode, EuiHorizontalRule, EuiFieldNumber, - EuiFormLabel, - EuiSpacer, } from '@elastic/eui'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from '../../series_config_query_bar_with_ignore_global_filter'; @@ -235,14 +234,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + ); @@ -331,12 +329,17 @@ export const TimeseriesConfig = injectI18n(function (props) { ? props.model.series_index_pattern : props.indexPatternForQuery; - const initialPalette = { - ...model.palette, + const initialPalette = model.palette ?? { + type: 'palette', + name: 'default', + }; + + const palette = { + ...initialPalette, name: model.split_color_mode === 'kibana' ? 'kibana_palette' - : model.split_color_mode || model.palette.name, + : model.split_color_mode || initialPalette.name, }; return ( @@ -408,14 +411,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + {palettesRegistry && ( @@ -430,7 +432,7 @@ export const TimeseriesConfig = injectI18n(function (props) { > @@ -443,14 +445,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + - - + - - - + import('./application/components/timeseries_visualization') ); @@ -39,6 +41,10 @@ export const getTimeseriesVisRenderer: (deps: { name: 'timeseries_vis', reuseDomNode: true, render: async (domNode, config, handlers) => { + // Build optimization. Move app styles from main bundle + // @ts-expect-error TS error, cannot find type declaration for scss + await import('./application/index.scss'); + handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); diff --git a/src/plugins/vis_type_timeseries/public/to_ast.ts b/src/plugins/vis_type_timeseries/public/to_ast.ts index 90d57218da28c..c0c0a5b1546a9 100644 --- a/src/plugins/vis_type_timeseries/public/to_ast.ts +++ b/src/plugins/vis_type_timeseries/public/to_ast.ts @@ -7,9 +7,9 @@ */ import { buildExpression, buildExpressionFunction } from '../../expressions/public'; -import { Vis } from '../../visualizations/public'; -import { TimeseriesExpressionFunctionDefinition } from './metrics_fn'; -import { TimeseriesVisParams } from './types'; +import type { Vis } from '../../visualizations/public'; +import type { TimeseriesExpressionFunctionDefinition } from './metrics_fn'; +import type { TimeseriesVisParams } from './types'; export const toExpressionAst = (vis: Vis) => { const timeseries = buildExpressionFunction('tsvb', { diff --git a/src/plugins/vis_type_xy/public/utils/domain.ts b/src/plugins/vis_type_xy/public/utils/domain.ts index 322ffc087766c..9cd74cd0433cc 100644 --- a/src/plugins/vis_type_xy/public/utils/domain.ts +++ b/src/plugins/vis_type_xy/public/utils/domain.ts @@ -57,8 +57,8 @@ export const getAdjustedDomain = ( const lastXValue = xValues[xValues.length - 1]; const domainMin = Math.min(firstXValue, domain.min); - const domainMaxValue = hasBars ? domain.max - interval : lastXValue + interval; - const domainMax = Math.max(domainMaxValue, lastXValue); + const domainMaxValue = Math.max(domain.max - interval, lastXValue); + const domainMax = hasBars ? domainMaxValue : domainMaxValue + interval; const minInterval = getAdjustedInterval( xValues, intervalESValue, diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 87997ab4231a2..dcd34c604dc31 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -735,6 +735,7 @@ async function migrateIndex({ mappingProperties, batchSize: 10, log: getLogMock(), + setStatus: () => {}, pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry), diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 8fb3884a5b37b..9bf3045bd0138 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -240,6 +240,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', + hiddenType: false, editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -259,6 +260,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', + hiddenType: false, editUrl: '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -278,6 +280,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', + hiddenType: false, editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -289,6 +292,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', + hiddenType: false, editUrl: '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { @@ -308,6 +312,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', + hiddenType: false, editUrl: '/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index fee525067719f..17e562d221d72 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: schema.string(), }), namespaceType: schema.string(), + hiddenType: schema.boolean(), }), }); const invalidRelationSchema = schema.object({ @@ -89,6 +90,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'management.kibana.indexPatterns', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -105,6 +107,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -132,6 +135,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'management.kibana.indexPatterns', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -148,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'parent', }, @@ -192,6 +197,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -208,6 +214,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -232,6 +239,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -248,6 +256,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -292,6 +301,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -308,6 +318,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'dashboard.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -334,6 +345,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'child', }, @@ -378,6 +390,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, }, { @@ -394,6 +407,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, }, }, ]); @@ -420,6 +434,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'discover.show', }, namespaceType: 'single', + hiddenType: false, }, relationship: 'parent', }, @@ -466,6 +481,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, namespaceType: 'single', + hiddenType: false, title: 'Visualization', }, relationship: 'child', diff --git a/test/common/config.js b/test/common/config.js index b44f2de5042eb..84848347f94cd 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -21,7 +21,7 @@ export default function () { servers, esTestCluster: { - serverArgs: ['xpack.security.enabled=false', 'geoip.downloader.enabled=false'], + serverArgs: ['xpack.security.enabled=false'], }, kbnTestServer: { diff --git a/test/common/services/security/user.ts b/test/common/services/security/user.ts index 3bd31bb5ed186..d6813105ecbf6 100644 --- a/test/common/services/security/user.ts +++ b/test/common/services/security/user.ts @@ -33,7 +33,7 @@ export class User { public async delete(username: string) { this.log.debug(`deleting user ${username}`); - const { data, status, statusText } = await await this.kbnClient.request({ + const { data, status, statusText } = await this.kbnClient.request({ path: `/internal/security/users/${username}`, method: 'DELETE', }); @@ -44,4 +44,32 @@ export class User { } this.log.debug(`deleted user ${username}`); } + + public async disable(username: string) { + this.log.debug(`disabling user ${username}`); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${encodeURIComponent(username)}/_disable`, + method: 'POST', + }); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`disabled user ${username}`); + } + + public async enable(username: string) { + this.log.debug(`enabling user ${username}`); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${encodeURIComponent(username)}/_enable`, + method: 'POST', + }); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`enabled user ${username}`); + } } diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index dc5d56271c7fd..1c3862e07e9d7 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -35,7 +35,10 @@ export default function ({ getService, getPageObjects }) { describe('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); - await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); + await kibanaServer.uiSettings.update({ + 'doc_table:legacy': true, + defaultIndex: 'logstash-*', + }); await PageObjects.common.navigateToApp('discover'); for (const columnName of TEST_COLUMN_NAMES) { diff --git a/test/functional/apps/dashboard/copy_panel_to.ts b/test/functional/apps/dashboard/copy_panel_to.ts index 9abdc2ceffc01..641d520801c4d 100644 --- a/test/functional/apps/dashboard/copy_panel_to.ts +++ b/test/functional/apps/dashboard/copy_panel_to.ts @@ -91,6 +91,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.expectOnDashboard(`Editing ${fewPanelsTitle}`); const newPanelCount = await PageObjects.dashboard.getPanelCount(); expect(newPanelCount).to.be(fewPanelsPanelCount + 1); + + // Save & ensure that view mode is applied properly. + await PageObjects.dashboard.clickQuickSave(); + await testSubjects.existOrFail('saveDashboardSuccess'); + + await PageObjects.dashboard.clickCancelOutOfEditMode(); + const panelOptions = await dashboardPanelActions.getPanelHeading(markdownTitle); + await dashboardPanelActions.openContextMenu(panelOptions); + await dashboardPanelActions.expectMissingEditPanelAction(); }); it('does not show the current dashboard in the dashboard picker', async () => { diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 7bdc3490a959f..8ed54f88afea3 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -131,7 +131,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return actualCount === expectedCount; }); const newDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); - expect(Math.round(newDurationHours)).to.be(27); + expect(Math.round(newDurationHours)).to.be(26); await retry.waitFor('doc table to contain the right search result', async () => { const rowData = await PageObjects.discover.getDocTableField(1); diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts index ea95e0adff617..f780f4ecad97c 100644 --- a/test/functional/apps/discover/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -32,8 +32,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await fieldEditor.save(); }; - // FLAKY: https://github.com/elastic/kibana/issues/97864 - describe.skip('discover integration with runtime fields editor', function describeIndexTests() { + describe('discover integration with runtime fields editor', function describeIndexTests() { before(async function () { await esArchiver.load('discover'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -43,19 +42,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - after(async () => { - await kibanaServer.uiSettings.replace({ 'discover:searchFieldsFromSource': true }); - }); - it('allows adding custom label to existing fields', async function () { - await PageObjects.discover.clickFieldListItemAdd('bytes'); + const customLabel = 'megabytes'; await PageObjects.discover.editField('bytes'); await fieldEditor.enableCustomLabel(); - await fieldEditor.setCustomLabel('megabytes'); + await fieldEditor.setCustomLabel(customLabel); await fieldEditor.save(); await PageObjects.header.waitUntilLoadingHasFinished(); - expect(await PageObjects.discover.getDocHeader()).to.have.string('megabytes'); - expect((await PageObjects.discover.getAllFieldNames()).includes('megabytes')).to.be(true); + expect((await PageObjects.discover.getAllFieldNames()).includes(customLabel)).to.be(true); + await PageObjects.discover.clickFieldListItemAdd('bytes'); + expect(await PageObjects.discover.getDocHeader()).to.have.string(customLabel); }); it('allows creation of a new field', async function () { diff --git a/test/functional/apps/visualize/_area_chart.ts b/test/functional/apps/visualize/_area_chart.ts index 1ae476b0868fb..f6eaa2c685f5d 100644 --- a/test/functional/apps/visualize/_area_chart.ts +++ b/test/functional/apps/visualize/_area_chart.ts @@ -96,7 +96,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct chart', async function () { const xAxisLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00', '2015-09-23 00:00'], - ['2015-09-19 12:00', '2015-09-20 12:00', '2015-09-21 12:00', '2015-09-22 12:00'] + [ + '2015-09-19 12:00', + '2015-09-20 12:00', + '2015-09-21 12:00', + '2015-09-22 12:00', + '2015-09-23 12:00', + ] ); const yAxisLabels = await PageObjects.visChart.getExpectedValue( ['0', '200', '400', '600', '800', '1,000', '1,200', '1,400', '1,600'], diff --git a/test/functional/apps/visualize/_point_series_options.ts b/test/functional/apps/visualize/_point_series_options.ts index ac641fb554b0b..d4bcc19a7c87c 100644 --- a/test/functional/apps/visualize/_point_series_options.ts +++ b/test/functional/apps/visualize/_point_series_options.ts @@ -60,7 +60,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); } - describe('point series', function describeIndexTests() { + describe('vlad point series', function describeIndexTests() { before(initChart); describe('secondary value axis', function () { @@ -281,10 +281,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00'], [ '2015-09-19 12:00', - '2015-09-20 06:00', - '2015-09-21 00:00', - '2015-09-21 18:00', + '2015-09-20 12:00', + '2015-09-21 12:00', '2015-09-22 12:00', + '2015-09-23 12:00', ] ); @@ -328,6 +328,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '14:30', '15:00', '15:30', + '16:00', ] ); return labels.toString() === xLabels.toString(); @@ -396,6 +397,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '21:30', '22:00', '22:30', + '23:00', ] ); return labels2.toString() === xLabels2.toString(); diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/data.json new file mode 100644 index 0000000000000..057373579c100 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/data.json @@ -0,0 +1,88 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-actions-export-hidden:obj_1", + "source": { + "test-actions-export-hidden": { + "title": "hidden object 1" + }, + "type": "test-actions-export-hidden", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-actions-export-hidden:obj_2", + "source": { + "test-actions-export-hidden": { + "title": "hidden object 2" + }, + "type": "test-actions-export-hidden", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "visualization:75c3e060-1e7c-11e9-8488-65449e65d0ed", + "source": { + "visualization": { + "title": "A Pie", + "visState": "{\"title\":\"A Pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"dimensions\":{\"metric\":{\"accessor\":0,\"format\":{\"id\":\"number\"},\"params\":{},\"aggType\":\"count\"}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "type": "visualization", + "updated_at": "2019-01-22T19:32:31.206Z" + }, + "references" : [ + { + "name" : "kibanaSavedObjectMeta.searchSourceJSON.index", + "type" : "index-pattern", + "id" : "logstash-*" + } + ] + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "dashboard:i-exist", + "source": { + "dashboard": { + "title": "A Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.0.0\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"id\":\"75c3e060-1e7c-11e9-8488-65449e65d0ed\",\"embeddableConfig\":{}}]", + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "type": "dashboard", + "updated_at": "2019-01-22T19:32:47.232Z" + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/mappings.json new file mode 100644 index 0000000000000..a862731c13f7a --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_types/mappings.json @@ -0,0 +1,504 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": true, + "properties": { + "test-actions-export-hidden": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } +} diff --git a/test/functional/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 66941e201e9ba..f337bffe80f2c 100644 --- a/test/functional/fixtures/es_archiver/visualize/data.json +++ b/test/functional/fixtures/es_archiver/visualize/data.json @@ -207,7 +207,7 @@ "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"message.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"message\"}}},{\"name\":\"user\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"user.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"user\"}}}]", "title": "test_index*" }, - "type": "test_index*" + "type": "index-pattern" } } } diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index c28d351aa77fb..fc4de6ed7f82f 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -294,10 +294,12 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv return await testSubjects.isEnabled('savedObjectsManagementDelete'); } - async clickDelete() { + async clickDelete({ confirmDelete = true }: { confirmDelete?: boolean } = {}) { await testSubjects.click('savedObjectsManagementDelete'); - await testSubjects.click('confirmModalConfirmButton'); - await this.waitTableIsLoaded(); + if (confirmDelete) { + await testSubjects.click('confirmModalConfirmButton'); + await this.waitTableIsLoaded(); + } } async getImportWarnings() { diff --git a/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts b/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts index daaf6426bdddc..408ac03dd946b 100644 --- a/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts +++ b/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts @@ -90,8 +90,6 @@ export class SavedObjectExportTransformsPlugin implements Plugin { }, }); - ///////////// - ///////////// // example of a SO type that will throw an object-transform-error savedObjects.registerType({ name: 'test-export-transform-error', @@ -134,8 +132,29 @@ export class SavedObjectExportTransformsPlugin implements Plugin { }, }, }); + + // example of a SO type that is exportable while being hidden + savedObjects.registerType({ + name: 'test-actions-export-hidden', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + enabled: { + type: 'boolean', + }, + }, + }, + management: { + defaultSearchField: 'title', + importableAndExportable: true, + getTitle: (obj) => obj.attributes.title, + }, + }); } public start() {} + public stop() {} } diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 0e52b536410e4..0145a84423b3c 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -25,7 +25,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); const getAppWrapperHeight = async () => { - const wrapper = await find.byClassName('app-wrapper'); + const wrapper = await find.byClassName('kbnAppWrapper'); return (await wrapper.getSize()).height; }; diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts index 00ba74a988cf4..ba4835cdab089 100644 --- a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts @@ -15,6 +15,5 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./delete')); - loadTestFile(require.resolve('./interface/saved_objects_management')); }); } diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts deleted file mode 100644 index dfd0b9dd07476..0000000000000 --- a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import path from 'path'; -import expect from '@kbn/expect'; -import { PluginFunctionalProviderContext } from '../../../services'; - -export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { - const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); - const esArchiver = getService('esArchiver'); - const fixturePaths = { - hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), - hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), - }; - - describe('Saved objects management Interface', () => { - before(() => esArchiver.emptyKibanaIndex()); - beforeEach(async () => { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); - }); - describe('importable/exportable hidden type', () => { - it('imports objects successfully', async () => { - await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); - await PageObjects.savedObjects.checkImportSucceeded(); - }); - - it('shows test-hidden-importable-exportable in table', async () => { - await PageObjects.savedObjects.searchForObject('type:(test-hidden-importable-exportable)'); - const results = await PageObjects.savedObjects.getTableSummary(); - expect(results.length).to.be(1); - - const { title } = results[0]; - expect(title).to.be( - 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' - ); - }); - }); - - describe('non-importable/exportable hidden type', () => { - it('fails to import object', async () => { - await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); - await PageObjects.savedObjects.checkImportSucceeded(); - - const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); - expect(errorsCount).to.be(1); - }); - }); - }); -} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_importable.ndjson similarity index 100% rename from test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson rename to test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_importable.ndjson diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_non_importable.ndjson similarity index 100% rename from test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson rename to test/plugin_functional/test_suites/saved_objects_management/exports/_import_hidden_non_importable.ndjson diff --git a/test/plugin_functional/test_suites/saved_objects_management/hidden_types.ts b/test/plugin_functional/test_suites/saved_objects_management/hidden_types.ts new file mode 100644 index 0000000000000..464b7c6e7ced7 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/hidden_types.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'path'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +const fixturePaths = { + hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), + hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), +}; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + + describe('saved objects management with hidden types', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_types' + ); + }); + + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_types' + ); + }); + + beforeEach(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + }); + + describe('API calls', () => { + it('should flag the object as hidden in its meta', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find?type=test-actions-export-hidden') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect( + resp.body.saved_objects.map((obj: any) => ({ + id: obj.id, + type: obj.type, + hidden: obj.meta.hiddenType, + })) + ).to.eql([ + { + id: 'obj_1', + type: 'test-actions-export-hidden', + hidden: true, + }, + { + id: 'obj_2', + type: 'test-actions-export-hidden', + hidden: true, + }, + ]); + }); + }); + }); + + describe('Delete modal', () => { + it('should display a warning then trying to delete hidden saved objects', async () => { + await PageObjects.savedObjects.clickCheckboxByTitle('A Pie'); + await PageObjects.savedObjects.clickCheckboxByTitle('A Dashboard'); + await PageObjects.savedObjects.clickCheckboxByTitle('hidden object 1'); + + await PageObjects.savedObjects.clickDelete({ confirmDelete: false }); + expect(await testSubjects.exists('cannotDeleteObjectsConfirmWarning')).to.eql(true); + }); + + it('should not delete the hidden objects when performing the operation', async () => { + await PageObjects.savedObjects.clickCheckboxByTitle('A Pie'); + await PageObjects.savedObjects.clickCheckboxByTitle('hidden object 1'); + + await PageObjects.savedObjects.clickDelete({ confirmDelete: true }); + + const objectNames = (await PageObjects.savedObjects.getTableSummary()).map( + (obj) => obj.title + ); + expect(objectNames.includes('hidden object 1')).to.eql(true); + expect(objectNames.includes('A Pie')).to.eql(false); + }); + }); + + describe('importing hidden types', () => { + describe('importable/exportable hidden type', () => { + it('imports objects successfully', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + }); + + it('shows test-hidden-importable-exportable in table', async () => { + await PageObjects.savedObjects.searchForObject( + 'type:(test-hidden-importable-exportable)' + ); + const results = await PageObjects.savedObjects.getTableSummary(); + expect(results.length).to.be(1); + + const { title } = results[0]; + expect(title).to.be( + 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' + ); + }); + }); + + describe('non-importable/exportable hidden type', () => { + it('fails to import object', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + + const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); + expect(errorsCount).to.be(1); + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts index 9f2d28b582f78..edaa819e5ea58 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./export_transform')); loadTestFile(require.resolve('./import_warnings')); + loadTestFile(require.resolve('./hidden_types')); }); } diff --git a/test/scripts/jenkins_xpack_build_plugins.sh b/test/scripts/jenkins_xpack_build_plugins.sh index 496964983cc6c..cb0b5ec1d56da 100755 --- a/test/scripts/jenkins_xpack_build_plugins.sh +++ b/test/scripts/jenkins_xpack_build_plugins.sh @@ -13,6 +13,7 @@ node scripts/build_kibana_platform_plugins \ --scan-dir "$XPACK_DIR/test/plugin_api_perf/plugins" \ --scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \ --scan-dir "$XPACK_DIR/test/usage_collection/plugins" \ + --scan-dir "$XPACK_DIR/test/security_functional/fixtures/common" \ --scan-dir "$KIBANA_DIR/examples" \ --scan-dir "$XPACK_DIR/examples" \ --workers 12 \ diff --git a/x-pack/package.json b/x-pack/package.json index 0c0924b51264a..c09db67483121 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -35,7 +35,6 @@ "@kbn/test": "link:../packages/kbn-test" }, "dependencies": { - "@elastic/safer-lodash-set": "link:../packages/elastic-safer-lodash-set", "@kbn/i18n": "link:../packages/kbn-i18n", "@kbn/interpreter": "link:../packages/kbn-interpreter", "@kbn/ui-framework": "link:../packages/kbn-ui-framework" diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 9b22e31c05e8a..30108a0777819 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -92,6 +92,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -123,6 +124,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -162,6 +164,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -199,6 +202,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -250,6 +254,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -274,6 +279,7 @@ describe('create()', () => { isPreconfigured: false, name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); @@ -283,6 +289,7 @@ describe('create()', () => { Object { "actionTypeId": "my-action-type", "config": Object {}, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -347,6 +354,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: { a: true, b: true, @@ -373,6 +381,7 @@ describe('create()', () => { isPreconfigured: false, name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: { a: true, b: true, @@ -390,6 +399,7 @@ describe('create()', () => { "b": true, "c": true, }, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -449,6 +459,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -482,6 +493,7 @@ describe('create()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -518,6 +530,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -566,6 +579,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -628,6 +642,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -653,6 +668,7 @@ describe('get()', () => { attributes: { name: 'my name', actionTypeId: 'my-action-type', + isMissingSecrets: false, config: {}, }, references: [], @@ -821,6 +837,7 @@ describe('getAll()', () => { type: 'type', attributes: { name: 'test', + isMissingSecrets: false, config: { foo: 'bar', }, @@ -881,6 +898,7 @@ describe('getAll()', () => { type: 'type', attributes: { name: 'test', + isMissingSecrets: false, config: { foo: 'bar', }, @@ -932,6 +950,7 @@ describe('getAll()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, referencedByCount: 6, }, { @@ -959,6 +978,7 @@ describe('getBulk()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, }, references: [], }, @@ -1030,6 +1050,7 @@ describe('getBulk()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, }, references: [], }, @@ -1088,6 +1109,7 @@ describe('getBulk()', () => { config: { foo: 'bar', }, + isMissingSecrets: false, }, references: [], }, @@ -1143,6 +1165,7 @@ describe('getBulk()', () => { foo: 'bar', }, id: '1', + isMissingSecrets: false, isPreconfigured: false, name: 'test', }, @@ -1231,6 +1254,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, }, references: [], }); @@ -1239,6 +1263,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, secrets: {}, @@ -1319,6 +1344,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, }, references: [], }); @@ -1327,6 +1353,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, secrets: {}, @@ -1345,6 +1372,7 @@ describe('update()', () => { id: 'my-action', isPreconfigured: false, actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, }); @@ -1355,6 +1383,7 @@ describe('update()', () => { Object { "actionTypeId": "my-action-type", "config": Object {}, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -1374,6 +1403,70 @@ describe('update()', () => { `); }); + test('updates an action with isMissingSecrets "true" (set true as the import result), to isMissingSecrets', async () => { + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + isMissingSecrets: true, + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + isMissingSecrets: true, + name: 'my name', + config: {}, + secrets: {}, + }, + references: [], + }); + const result = await actionsClient.update({ + id: 'my-action', + action: { + name: 'my name', + config: {}, + secrets: {}, + }, + }); + expect(result).toEqual({ + id: 'my-action', + isPreconfigured: false, + actionTypeId: 'my-action-type', + isMissingSecrets: true, + name: 'my name', + config: {}, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "action", + Object { + "actionTypeId": "my-action-type", + "config": Object {}, + "isMissingSecrets": false, + "name": "my name", + "secrets": Object {}, + }, + Object { + "id": "my-action", + "overwrite": true, + "references": Array [], + }, + ] + `); + }); + test('validates config', async () => { actionTypeRegistry.register({ id: 'my-action-type', @@ -1428,6 +1521,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: true, name: 'my name', config: { a: true, @@ -1454,6 +1548,7 @@ describe('update()', () => { id: 'my-action', isPreconfigured: false, actionTypeId: 'my-action-type', + isMissingSecrets: true, name: 'my name', config: { a: true, @@ -1472,6 +1567,7 @@ describe('update()', () => { "b": true, "c": true, }, + "isMissingSecrets": false, "name": "my name", "secrets": Object {}, }, @@ -1507,6 +1603,7 @@ describe('update()', () => { type: 'action', attributes: { actionTypeId: 'my-action-type', + isMissingSecrets: false, name: 'my name', config: {}, secrets: {}, diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 9f87de5f686cc..c655141415b54 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -155,6 +155,7 @@ export class ActionsClient { { actionTypeId, name, + isMissingSecrets: false, config: validatedActionTypeConfig as SavedObjectAttributes, secrets: validatedActionTypeSecrets as SavedObjectAttributes, }, @@ -164,6 +165,7 @@ export class ActionsClient { return { id: result.id, actionTypeId: result.attributes.actionTypeId, + isMissingSecrets: result.attributes.isMissingSecrets, name: result.attributes.name, config: result.attributes.config, isPreconfigured: false, @@ -228,6 +230,7 @@ export class ActionsClient { ...attributes, actionTypeId, name, + isMissingSecrets: false, config: validatedActionTypeConfig as SavedObjectAttributes, secrets: validatedActionTypeSecrets as SavedObjectAttributes, }, @@ -245,6 +248,7 @@ export class ActionsClient { return { id, actionTypeId: result.attributes.actionTypeId as string, + isMissingSecrets: result.attributes.isMissingSecrets as boolean, name: result.attributes.name as string, config: result.attributes.config as Record, isPreconfigured: false, @@ -299,6 +303,7 @@ export class ActionsClient { return { id, actionTypeId: result.attributes.actionTypeId, + isMissingSecrets: result.attributes.isMissingSecrets, name: result.attributes.name, config: result.attributes.config, isPreconfigured: false, diff --git a/x-pack/plugins/actions/server/routes/create.test.ts b/x-pack/plugins/actions/server/routes/create.test.ts index e5d8e6f5861f3..51a55309b52ae 100644 --- a/x-pack/plugins/actions/server/routes/create.test.ts +++ b/x-pack/plugins/actions/server/routes/create.test.ts @@ -39,12 +39,14 @@ describe('createActionRoute', () => { actionTypeId: 'abc', config: { foo: true }, isPreconfigured: false, + isMissingSecrets: false, }; const createApiResult = { - ...omit(createResult, ['actionTypeId', 'isPreconfigured']), + ...omit(createResult, ['actionTypeId', 'isPreconfigured', 'isMissingSecrets']), connector_type_id: createResult.actionTypeId, is_preconfigured: createResult.isPreconfigured, + is_missing_secrets: createResult.isMissingSecrets, }; const actionsClient = actionsClientMock.create(); @@ -99,6 +101,7 @@ describe('createActionRoute', () => { id: '1', name: 'My name', actionTypeId: 'abc', + isMissingSecrets: false, config: { foo: true }, isPreconfigured: false, }); @@ -138,6 +141,7 @@ describe('createActionRoute', () => { name: 'My name', actionTypeId: 'abc', config: { foo: true }, + isMissingSecrets: false, isPreconfigured: false, }); diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts index c05f2180bd62b..0c243b6a4eaa9 100644 --- a/x-pack/plugins/actions/server/routes/create.ts +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -29,11 +29,13 @@ const rewriteBodyReq: RewriteRequestCase = ({ const rewriteBodyRes: RewriteResponseCase = ({ actionTypeId, isPreconfigured, + isMissingSecrets, ...res }) => ({ ...res, connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, + is_missing_secrets: isMissingSecrets, }); export const createActionRoute = ( diff --git a/x-pack/plugins/actions/server/routes/get.test.ts b/x-pack/plugins/actions/server/routes/get.test.ts index 6a42f3b27370e..1107ec243bc01 100644 --- a/x-pack/plugins/actions/server/routes/get.test.ts +++ b/x-pack/plugins/actions/server/routes/get.test.ts @@ -38,6 +38,7 @@ describe('getActionRoute', () => { name: 'action name', config: {}, isPreconfigured: false, + isMissingSecrets: false, }; const actionsClient = actionsClientMock.create(); @@ -57,6 +58,7 @@ describe('getActionRoute', () => { "config": Object {}, "connector_type_id": "2", "id": "1", + "is_missing_secrets": false, "is_preconfigured": false, "name": "action name", }, @@ -73,6 +75,7 @@ describe('getActionRoute', () => { name: 'action name', config: {}, is_preconfigured: false, + is_missing_secrets: false, }, }); }); diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts index 59766fc133ba6..3f4a67c3bfbcd 100644 --- a/x-pack/plugins/actions/server/routes/get.ts +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -19,11 +19,13 @@ const paramSchema = schema.object({ const rewriteBodyRes: RewriteResponseCase = ({ actionTypeId, isPreconfigured, + isMissingSecrets, ...res }) => ({ ...res, connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, + is_missing_secrets: isMissingSecrets, }); export const getActionRoute = ( diff --git a/x-pack/plugins/actions/server/routes/get_all.ts b/x-pack/plugins/actions/server/routes/get_all.ts index 831722fd36eed..2d3a2727e9663 100644 --- a/x-pack/plugins/actions/server/routes/get_all.ts +++ b/x-pack/plugins/actions/server/routes/get_all.ts @@ -12,12 +12,15 @@ import { ActionsRequestHandlerContext, FindActionResult } from '../types'; import { verifyAccessAndContext } from './verify_access_and_context'; const rewriteBodyRes: RewriteResponseCase = (results) => { - return results.map(({ actionTypeId, isPreconfigured, referencedByCount, ...res }) => ({ - ...res, - connector_type_id: actionTypeId, - is_preconfigured: isPreconfigured, - referenced_by_count: referencedByCount, - })); + return results.map( + ({ actionTypeId, isPreconfigured, referencedByCount, isMissingSecrets, ...res }) => ({ + ...res, + connector_type_id: actionTypeId, + is_preconfigured: isPreconfigured, + referenced_by_count: referencedByCount, + is_missing_secrets: isMissingSecrets, + }) + ); }; export const getAllActionRoute = ( diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts index d1758717e80f9..276ce80751726 100644 --- a/x-pack/plugins/actions/server/routes/update.ts +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -25,11 +25,13 @@ const bodySchema = schema.object({ const rewriteBodyRes: RewriteResponseCase = ({ actionTypeId, isPreconfigured, + isMissingSecrets, ...res }) => ({ ...res, connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, + is_missing_secrets: isMissingSecrets, }); export const updateActionRoute = ( diff --git a/x-pack/plugins/actions/server/saved_objects/get_import_result_message.test.ts b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.test.ts new file mode 100644 index 0000000000000..b5a5ab75b9248 --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { SavedObject } from 'kibana/server'; +import { RawAction } from '../types'; +import { getImportResultMessage } from './get_import_result_message'; + +describe('getImportResultMessage', () => { + it('Return message with total imported connectors and the proper secrets need to update ', async () => { + const savedObjectConnectors = [ + { + type: 'action', + id: 'ed02cb70-a6ef-11eb-bd58-6b2eae02c6ef', + attributes: { + actionTypeId: '.server-log', + config: {}, + isMissingSecrets: false, + name: 'test', + }, + references: [], + migrationVersion: { action: '7.14.0' }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-27T04:10:33.043Z', + version: 'WzcxLDFd', + namespaces: ['default'], + }, + { + type: 'action', + id: 'e8aa94e0-a6ef-11eb-bd58-6b2eae02c6ef', + attributes: { + actionTypeId: '.email', + config: [Object], + isMissingSecrets: true, + name: 'test', + }, + references: [], + migrationVersion: { action: '7.14.0' }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-27T04:10:33.043Z', + version: 'WzcyLDFd', + namespaces: ['default'], + }, + ]; + const message = getImportResultMessage( + (savedObjectConnectors as unknown) as Array> + ); + expect(message).toBe('1 connector has secrets that require updates.'); + }); +}); diff --git a/x-pack/plugins/actions/server/saved_objects/get_import_result_message.ts b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.ts new file mode 100644 index 0000000000000..3b88a750c7430 --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/get_import_result_message.ts @@ -0,0 +1,25 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { SavedObject } from 'kibana/server'; +import { RawAction } from '../types'; + +export function getImportResultMessage(connectors: Array>) { + const connectorsWithSecrets = connectors.filter( + (connector) => connector.attributes.isMissingSecrets + ); + return i18n.translate('xpack.actions.savedObjects.onImportText', { + defaultMessage: + '{connectorsWithSecretsLength} {connectorsWithSecretsLength, plural, one {connector has} other {connectors have}} secrets that require updates.', + values: { + connectorsWithSecretsLength: connectorsWithSecrets.length, + }, + }); +} + +export const GO_TO_CONNECTORS_BUTTON_LABLE = 'Go to connectors'; diff --git a/x-pack/plugins/actions/server/saved_objects/index.ts b/x-pack/plugins/actions/server/saved_objects/index.ts index c8626660de2d9..3c6a78a6f0866 100644 --- a/x-pack/plugins/actions/server/saved_objects/index.ts +++ b/x-pack/plugins/actions/server/saved_objects/index.ts @@ -5,10 +5,12 @@ * 2.0. */ -import { SavedObjectsServiceSetup } from 'kibana/server'; +import { SavedObject, SavedObjectsServiceSetup } from 'kibana/server'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import mappings from './mappings.json'; import { getMigrations } from './migrations'; +import { RawAction } from '../types'; +import { getImportResultMessage, GO_TO_CONNECTORS_BUTTON_LABLE } from './get_import_result_message'; export const ACTION_SAVED_OBJECT_TYPE = 'action'; export const ALERT_SAVED_OBJECT_TYPE = 'alert'; @@ -24,6 +26,25 @@ export function setupSavedObjects( namespaceType: 'single', mappings: mappings.action, migrations: getMigrations(encryptedSavedObjects), + management: { + defaultSearchField: 'name', + importableAndExportable: true, + getTitle(obj) { + return `Connector: [${obj.attributes.name}]`; + }, + onImport(connectors) { + return { + warnings: [ + { + type: 'action_required', + message: getImportResultMessage(connectors as Array>), + actionPath: '/app/management/insightsAndAlerting/triggersActions/connectors', + buttonLabel: GO_TO_CONNECTORS_BUTTON_LABLE, + }, + ], + }; + }, + }, }); // Encrypted attributes diff --git a/x-pack/plugins/actions/server/saved_objects/mappings.json b/x-pack/plugins/actions/server/saved_objects/mappings.json index ef6a0c9919920..c598b96ba2451 100644 --- a/x-pack/plugins/actions/server/saved_objects/mappings.json +++ b/x-pack/plugins/actions/server/saved_objects/mappings.json @@ -12,6 +12,9 @@ "actionTypeId": { "type": "keyword" }, + "isMissingSecrets": { + "type": "boolean" + }, "config": { "enabled": false, "type": "object" diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/migrations.test.ts index a75735e514c10..4c30925e61894 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.test.ts @@ -43,7 +43,7 @@ describe('7.10.0', () => { test('rename cases configuration object', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; - const action = getMockData({}); + const action = getCasesMockData({}); const migratedAction = migration710(action, context); expect(migratedAction.attributes.config).toEqual({ incidentConfiguration: { mapping: [] }, @@ -112,10 +112,32 @@ describe('7.11.0', () => { }); }); +describe('7.14.0', () => { + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjectsSetup.createMigration.mockImplementation( + (shouldMigrateWhenPredicate, migration) => migration + ); + }); + + test('add isMissingSecrets property for actions', () => { + const migration714 = getMigrations(encryptedSavedObjectsSetup)['7.14.0']; + const action = getMockData({ isMissingSecrets: undefined }); + const migratedAction = migration714(action, context); + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + isMissingSecrets: false, + }, + }); + }); +}); + function getMockDataForWebhook( overwrites: Record = {}, hasUserAndPassword: boolean -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc> { const secrets = hasUserAndPassword ? { user: 'test', password: '123' } : { user: '', password: '' }; @@ -134,7 +156,7 @@ function getMockDataForWebhook( function getMockDataForEmail( overwrites: Record = {} -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc> { return { attributes: { name: 'abc', @@ -148,9 +170,9 @@ function getMockDataForEmail( }; } -function getMockData( +function getCasesMockData( overwrites: Record = {} -): SavedObjectUnsanitizedDoc { +): SavedObjectUnsanitizedDoc> { return { attributes: { name: 'abc', @@ -163,3 +185,19 @@ function getMockData( type: 'action', }; } + +function getMockData( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + name: 'abc', + actionTypeId: '123', + config: {}, + secrets: {}, + ...overwrites, + }, + id: uuid.v4(), + type: 'action', + }; +} diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.ts b/x-pack/plugins/actions/server/saved_objects/migrations.ts index 9bd54330f5d05..17932b6b90f97 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.ts @@ -41,9 +41,15 @@ export function getMigrations( pipeMigrations(removeCasesFieldMappings, addHasAuthConfigurationObject) ); + const migrationActionsFourteen = encryptedSavedObjects.createMigration( + (doc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(addisMissingSecretsField) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), + '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), }; } @@ -127,6 +133,18 @@ const addHasAuthConfigurationObject = ( }; }; +const addisMissingSecretsField = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + isMissingSecrets: false, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index d6f99a766ed34..ea22e90dfed40 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -63,6 +63,7 @@ export interface ActionResult { expect(await loadAlertType({ http, id: 'test-another' })).toEqual(alertType); }); - - test('should throw if required alertType is missing', async () => { - http.get.mockResolvedValueOnce([ - { - id: 'test-another', - name: 'Test Another', - actionVariables: [], - actionGroups: [{ id: 'default', name: 'Default' }], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - recoveryActionGroup: RecoveredActionGroup, - producer: 'alerts', - }, - ]); - - expect(loadAlertType({ http, id: 'test' })).rejects.toMatchInlineSnapshot( - `[Error: Alert type "test" is not registered.]` - ); - }); }); describe('loadAlert', () => { diff --git a/x-pack/plugins/alerting/public/alert_api.ts b/x-pack/plugins/alerting/public/alert_api.ts index d1213c80b95be..f3faa65a4b384 100644 --- a/x-pack/plugins/alerting/public/alert_api.ts +++ b/x-pack/plugins/alerting/public/alert_api.ts @@ -6,7 +6,6 @@ */ import { HttpSetup } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; import { LEGACY_BASE_ALERT_API_PATH } from '../common'; import type { Alert, AlertType } from '../common'; @@ -20,21 +19,11 @@ export async function loadAlertType({ }: { http: HttpSetup; id: AlertType['id']; -}): Promise { - const maybeAlertType = ((await http.get( +}): Promise { + const alertTypes = (await http.get( `${LEGACY_BASE_ALERT_API_PATH}/list_alert_types` - )) as AlertType[]).find((type) => type.id === id); - if (!maybeAlertType) { - throw new Error( - i18n.translate('xpack.alerting.loadAlertType.missingAlertTypeError', { - defaultMessage: 'Alert type "{id}" is not registered.', - values: { - id, - }, - }) - ); - } - return maybeAlertType; + )) as AlertType[]; + return alertTypes.find((type) => type.id === id); } export async function loadAlert({ diff --git a/x-pack/plugins/alerting/public/plugin.ts b/x-pack/plugins/alerting/public/plugin.ts index 5ca3c0af26801..025467d92a6ac 100644 --- a/x-pack/plugins/alerting/public/plugin.ts +++ b/x-pack/plugins/alerting/public/plugin.ts @@ -30,14 +30,19 @@ export class AlertingPublicPlugin implements Plugin - this.alertNavigationRegistry!.register( - consumer, - await loadAlertType({ http: core.http, id: alertType }), - handler - ); + ) => { + const alertType = await loadAlertType({ http: core.http, id: alertTypeId }); + if (!alertType) { + // eslint-disable-next-line no-console + console.log( + `Unable to register navigation for alert type "${alertTypeId}" because it is not registered on the server side.` + ); + return; + } + this.alertNavigationRegistry!.register(consumer, alertType, handler); + }; const registerDefaultNavigation = async (consumer: string, handler: AlertNavigationHandler) => this.alertNavigationRegistry!.registerDefault(consumer, handler); @@ -54,6 +59,14 @@ export class AlertingPublicPlugin implements Plugin { ); }); + test('throws an error if API key creation throws', async () => { + const data = getMockData(); + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.create({ data }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error creating rule: could not create API key - no"` + ); + }); + test('throws error when ensureActionTypeEnabled throws', async () => { const data = getMockData(); alertTypeRegistry.ensureAlertTypeEnabled.mockImplementation(() => { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts index db24d192c7755..7b0d6d7b1f10b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts @@ -359,6 +359,17 @@ describe('enable()', () => { ); }); + test('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.enable({ id: '1' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error enabling rule: could not create API key - no"` + ); + }); + test('falls back when failing to getDecryptedAsInternalUser', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts index 24cef4677a9a2..cdbfbbac9f9a1 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts @@ -692,6 +692,53 @@ describe('update()', () => { `); }); + it('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => + await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating rule: could not create API key - no"` + ); + }); + it('should validate params', async () => { alertTypeRegistry.get.mockReturnValueOnce({ id: '123', diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts index e0be54054e593..18bae8d34a8da 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts @@ -99,13 +99,13 @@ describe('updateApiKey()', () => { references: [], }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert); + }); + + test('updates the API key for the alert', async () => { alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, result: { id: '234', name: '123', api_key: 'abc' }, }); - }); - - test('updates the API key for the alert', async () => { await alertsClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -145,7 +145,22 @@ describe('updateApiKey()', () => { ); }); + test('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.updateApiKey({ id: '1' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating API key for rule: could not create API key - no"` + ); + }); + test('falls back to SOC when getDecryptedAsInternalUser throws an error', async () => { + alertsClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '234', name: '123', api_key: 'abc' }, + }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index 965449b78f3e0..b8c232f968523 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -26,6 +26,8 @@ import { EuiText, EuiIcon, EuiBadge, + EuiButtonIcon, + EuiOutsideClickDetector, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -87,7 +89,6 @@ export function SelectableUrlList({ }: Props) { const [darkMode] = useUiSetting$('theme:darkMode'); - const [popoverRef, setPopoverRef] = useState(null); const [searchRef, setSearchRef] = useState(null); const titleRef = useRef(null); @@ -105,7 +106,7 @@ export function SelectableUrlList({ // @ts-ignore - not sure, why it's not working useEvent('keydown', onEnterKey, searchRef); - const searchOnFocus = (e: React.FocusEvent) => { + const onInputClick = (e: React.MouseEvent) => { setPopoverIsOpen(true); }; @@ -114,15 +115,6 @@ export function SelectableUrlList({ setPopoverIsOpen(true); }; - const searchOnBlur = (e: React.FocusEvent) => { - if ( - !popoverRef?.contains(e.relatedTarget as HTMLElement) && - !popoverRef?.contains(titleRef.current as HTMLDivElement) - ) { - setPopoverIsOpen(false); - } - }; - const formattedOptions = formatOptions(data.items ?? []); const closePopover = () => { @@ -163,11 +155,21 @@ export function SelectableUrlList({ function PopOverTitle() { return ( - + {loading ? : titleText} + + closePopover()} + aria-label={i18n.translate('xpack.apm.csm.search.url.close', { + defaultMessage: 'Close', + })} + iconType={'cross'} + /> + ); @@ -183,8 +185,7 @@ export function SelectableUrlList({ singleSelection={false} searchProps={{ isClearable: true, - onFocus: searchOnFocus, - onBlur: searchOnBlur, + onClick: onInputClick, onInput: onSearchInput, inputRef: setSearchRef, placeholder: I18LABELS.searchByUrl, @@ -199,56 +200,57 @@ export function SelectableUrlList({ noMatchesMessage={emptyMessage} > {(list, search) => ( - -
- - {searchValue && ( - - - {searchValue}, - icon: ( - - Enter - - ), - }} - /> - - - )} - {list} - - - - { - onTermChange(); - closePopover(); - }} - > - {i18n.translate('xpack.apm.apply.label', { - defaultMessage: 'Apply', - })} - - - - -
-
+ closePopover()}> + +
+ + {searchValue && ( + + + {searchValue}, + icon: ( + + Enter + + ), + }} + /> + + + )} + {list} + + + + { + onTermChange(); + closePopover(); + }} + > + {i18n.translate('xpack.apm.apply.label', { + defaultMessage: 'Apply', + })} + + + + +
+
+
)} ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx new file mode 100644 index 0000000000000..10919cf4a32aa --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 React from 'react'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { + expectTextsInDocument, + expectTextsNotInDocument, + renderWithTheme, +} from '../../../../utils/testHelpers'; +import { InstanceDetails } from './intance_details'; +import * as useInstanceDetailsFetcher from './use_instance_details_fetcher'; + +type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; + +describe('InstanceDetails', () => { + it('renders loading spinner when data is being fetched', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ data: undefined, status: FETCH_STATUS.LOADING }); + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('loadingSpinner')).toBeInTheDocument(); + }); + + it('renders all sections', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + container: { id: 'baz' }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Container', 'Cloud']); + }); + + it('hides service section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + container: { id: 'baz' }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Container', 'Cloud']); + expectTextsNotInDocument(component, ['Service']); + }); + + it('hides container section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Cloud']); + expectTextsNotInDocument(component, ['Container']); + }); + + it('hides cloud section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + container: { id: 'baz' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Container']); + expectTextsNotInDocument(component, ['Cloud']); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx index f50d02bb15454..ba1da7e6dd6eb 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -82,7 +82,7 @@ export function InstanceDetails({ serviceName, serviceNodeName }: Props) { ) { return (
- +
); } diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx index ff34359d83c76..1b503e9b05286 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -9,25 +9,20 @@ import { i18n } from '@kbn/i18n'; import { startsWith, uniqueId } from 'lodash'; import React, { useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { esKuery, IIndexPattern, QuerySuggestion, } from '../../../../../../../src/plugins/data/public'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { getBoolFilter } from './get_bool_filter'; // @ts-expect-error import { Typeahead } from './Typeahead'; import { useProcessorEvent } from './use_processor_event'; -const Container = euiStyled.div` - margin-bottom: 10px; -`; - interface State { suggestions: QuerySuggestion[]; isLoadingSuggestions: boolean; @@ -145,16 +140,14 @@ export function KueryBar(props: { prepend?: React.ReactNode | string }) { } return ( - - - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx index c836919a8a6ab..54d8790c32d33 100644 --- a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx @@ -65,7 +65,8 @@ export function KeyValueFilterList({ icon?: string; onClickFilter: (filter: { key: string; value: any }) => void; }) { - if (!keyValueList.length) { + const nonEmptyKeyValueList = removeEmptyValues(keyValueList); + if (!nonEmptyKeyValueList.length) { return null; } @@ -77,7 +78,7 @@ export function KeyValueFilterList({ buttonClassName="buttonContentContainer" > - {removeEmptyValues(keyValueList).map(({ key, value }) => { + {nonEmptyKeyValueList.map(({ key, value }) => { return ( - - {showTransactionTypeSelector && ( - - - - )} + - + + {showTransactionTypeSelector && ( + + + + )} + + + + - + {showTimeComparison && ( - + )} - + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx index 772b42ed13577..dc071fe93bbbd 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -6,7 +6,6 @@ */ import { EuiSelect } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; @@ -18,7 +17,7 @@ import * as urlHelpers from './Links/url_helpers'; // min-width on here to the width when "request" is loaded so it doesn't start // out collapsed and change its width when the list of transaction types is loaded. const EuiSelectWithWidth = styled(EuiSelect)` - min-width: 157px; + min-width: 200px; `; export function TransactionTypeSelect() { @@ -45,9 +44,6 @@ export function TransactionTypeSelect() { diff --git a/x-pack/plugins/apm/public/hooks/use_break_points.ts b/x-pack/plugins/apm/public/hooks/use_break_points.ts index 53e46cfe898ac..fb8dc8f6a55b8 100644 --- a/x-pack/plugins/apm/public/hooks/use_break_points.ts +++ b/x-pack/plugins/apm/public/hooks/use_break_points.ts @@ -10,26 +10,28 @@ import useWindowSize from 'react-use/lib/useWindowSize'; import useDebounce from 'react-use/lib/useDebounce'; import { isWithinMaxBreakpoint } from '@elastic/eui'; -export function useBreakPoints() { - const [screenSizes, setScreenSizes] = useState({ - isSmall: false, - isMedium: false, - isLarge: false, - isXl: false, - }); +function isMinXXL(windowWidth: number) { + return windowWidth >= 1600; +} + +function getScreenSizes(windowWidth: number) { + const isXXL = isMinXXL(windowWidth); + return { + isSmall: isWithinMaxBreakpoint(windowWidth, 's'), + isMedium: isWithinMaxBreakpoint(windowWidth, 'm'), + isLarge: isWithinMaxBreakpoint(windowWidth, 'l'), + isXl: isWithinMaxBreakpoint(windowWidth, 'xl') && !isXXL, + isXXL, + }; +} +export function useBreakPoints() { const { width } = useWindowSize(); + const [screenSizes, setScreenSizes] = useState(getScreenSizes(width)); useDebounce( () => { - const windowWidth = window.innerWidth; - - setScreenSizes({ - isSmall: isWithinMaxBreakpoint(windowWidth, 's'), - isMedium: isWithinMaxBreakpoint(windowWidth, 'm'), - isLarge: isWithinMaxBreakpoint(windowWidth, 'l'), - isXl: isWithinMaxBreakpoint(windowWidth, 'xl'), - }); + setScreenSizes(getScreenSizes(width)); }, 50, [width] diff --git a/x-pack/plugins/banners/public/components/banner.tsx b/x-pack/plugins/banners/public/components/banner.tsx index ae28986297659..5a1e20621f3d4 100644 --- a/x-pack/plugins/banners/public/components/banner.tsx +++ b/x-pack/plugins/banners/public/components/banner.tsx @@ -25,7 +25,7 @@ export const Banner: FC = ({ bannerConfig }) => { color: textColor, }} > -
+
diff --git a/x-pack/plugins/canvas/common/index.ts b/x-pack/plugins/canvas/common/index.ts new file mode 100644 index 0000000000000..51a53586dee3c --- /dev/null +++ b/x-pack/plugins/canvas/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const UI_SETTINGS = { + ENABLE_LABS_UI: 'labs:canvas:enable_ui', +}; diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index afd3d1408e1f1..b60f8db5b25b4 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -514,6 +514,20 @@ export const ComponentStrings = { defaultMessage: 'Keyboard shortcuts', }), }, + LabsControl: { + getLabsButtonLabel: () => + i18n.translate('xpack.canvas.workpadHeaderLabsControlSettings.labsButtonLabel', { + defaultMessage: 'Labs', + }), + getAriaLabel: () => + i18n.translate('xpack.canvas.workpadHeaderLabsControlSettings.labsAriaLabel', { + defaultMessage: 'View labs projects', + }), + getTooltip: () => + i18n.translate('xpack.canvas.workpadHeaderLabsControlSettings.labsTooltip', { + defaultMessage: 'View labs projects', + }), + }, Link: { getErrorMessage: (message: string) => i18n.translate('xpack.canvas.link.errorMessage', { diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss index 3ab04e31eb9c1..15d6b13e3fbf8 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -5,10 +5,6 @@ body.canvas-isFullscreen { padding-top: 0; } - .headerWrapper ~ .app-wrapper { - min-height: 100vh; - } - // following rule is for docked navigation &.euiBody--collapsibleNavIsDocked { padding-left: 0 !important; // sass-lint:disable-line no-important @@ -19,6 +15,11 @@ body.canvas-isFullscreen { display: none; } + // hide global banners + #globalBannerList { + display: none; + } + // set the background color .canvasLayout { background: $euiColorInk; diff --git a/x-pack/plugins/canvas/public/components/popover/popover.tsx b/x-pack/plugins/canvas/public/components/popover/popover.tsx index 193673932f5fc..275d800fe2ca1 100644 --- a/x-pack/plugins/canvas/public/components/popover/popover.tsx +++ b/x-pack/plugins/canvas/public/components/popover/popover.tsx @@ -86,7 +86,7 @@ export class Popover extends Component { return button(handleClick); }; - const appWrapper = document.querySelector('.app-wrapper'); + const appWrapper = document.querySelector('.kbnAppWrapper'); const EuiPopoverAny = (EuiPopover as any) as React.FC; return ( diff --git a/x-pack/plugins/canvas/public/components/workpad_header/labs_control/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/index.ts new file mode 100644 index 0000000000000..fde077e88f86f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { LabsControl } from './labs_control'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/labs_control/labs_control.tsx b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/labs_control.tsx new file mode 100644 index 0000000000000..eea59e6aa49f3 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/labs_control.tsx @@ -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 React, { useState } from 'react'; +import { EuiButtonEmpty, EuiNotificationBadge } from '@elastic/eui'; + +import { + LazyLabsFlyout, + withSuspense, +} from '../../../../../../../src/plugins/presentation_util/public'; + +import { ComponentStrings } from '../../../../i18n'; +import { useLabsService } from '../../../services'; +const { LabsControl: strings } = ComponentStrings; + +const Flyout = withSuspense(LazyLabsFlyout, null); + +export const LabsControl = () => { + const { isLabsEnabled, getProjects } = useLabsService(); + const [isShown, setIsShown] = useState(false); + + if (!isLabsEnabled()) { + return null; + } + + const projects = getProjects(['canvas']); + const overrideCount = Object.values(projects).filter((project) => project.status.isOverride) + .length; + + return ( + <> + setIsShown(!isShown)} size="xs"> + {strings.getLabsButtonLabel()} + {overrideCount > 0 ? ( + + {overrideCount} + + ) : null} + + {isShown ? setIsShown(false)} /> : null} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx index dc9b7a670846b..415d3ddf46709 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx @@ -19,6 +19,7 @@ import { EditMenu } from './edit_menu'; import { ElementMenu } from './element_menu'; import { ShareMenu } from './share_menu'; import { ViewMenu } from './view_menu'; +import { LabsControl } from './labs_control'; import { CommitFn } from '../../../types'; const { WorkpadHeader: strings } = ComponentStrings; @@ -111,6 +112,9 @@ export const WorkpadHeader: FunctionComponent = ({ + + +
diff --git a/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts b/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts index 68c1247275668..3b46967c2a360 100644 --- a/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts +++ b/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { get, sortBy } from 'lodash'; +import { get } from 'lodash'; import { PointSeriesColumns, DatatableRow, Ticks } from '../../../types'; export const getTickHash = (columns: PointSeriesColumns, rows: DatatableRow[]) => { @@ -21,23 +21,19 @@ export const getTickHash = (columns: PointSeriesColumns, rows: DatatableRow[]) = }; if (get(columns, 'x.type') === 'string') { - sortBy(rows, ['x']) - .reverse() - .forEach((row) => { - if (!ticks.x.hash[row.x]) { - ticks.x.hash[row.x] = ticks.x.counter++; - } - }); + rows.forEach((row) => { + if (!ticks.x.hash[row.x]) { + ticks.x.hash[row.x] = ticks.x.counter++; + } + }); } if (get(columns, 'y.type') === 'string') { - sortBy(rows, ['y']) - .reverse() - .forEach((row) => { - if (!ticks.y.hash[row.y]) { - ticks.y.hash[row.y] = ticks.y.counter++; - } - }); + rows.reverse().forEach((row) => { + if (!ticks.y.hash[row.y]) { + ticks.y.hash[row.y] = ticks.y.counter++; + } + }); } return ticks; diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts index 47b9212bbc4c0..c0c73c3a21bc6 100644 --- a/x-pack/plugins/canvas/public/functions/plot/index.ts +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -81,8 +81,7 @@ export function plotFunctionFactory( fn: (input, args) => { const seriesStyles: { [key: string]: SeriesStyle } = keyBy(args.seriesStyle || [], 'label') || {}; - - const sortedRows = sortBy(input.rows, ['x', 'y', 'color', 'size', 'text']); + const sortedRows = input.rows; const ticks = getTickHash(input.columns, sortedRows); const font = args.font ? getFontSpec(args.font) : {}; diff --git a/x-pack/plugins/canvas/public/services/labs.ts b/x-pack/plugins/canvas/public/services/labs.ts index 9bc4bea3e35c3..7f5de8d1e6570 100644 --- a/x-pack/plugins/canvas/public/services/labs.ts +++ b/x-pack/plugins/canvas/public/services/labs.ts @@ -7,23 +7,23 @@ import { projectIDs, - Project, - ProjectID, + PresentationLabsService, } from '../../../../../src/plugins/presentation_util/public'; import { CanvasServiceFactory } from '.'; - -export interface CanvasLabsService { - getProject: (id: ProjectID) => Project; - getProjects: () => Record; +import { UI_SETTINGS } from '../../common'; +export interface CanvasLabsService extends PresentationLabsService { + projectIDs: typeof projectIDs; + isLabsEnabled: () => boolean; } export const labsServiceFactory: CanvasServiceFactory = async ( _coreSetup, - _coreStart, + coreStart, _setupPlugins, startPlugins ) => ({ projectIDs, + isLabsEnabled: () => coreStart.uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI), ...startPlugins.presentationUtil.labsService, }); diff --git a/x-pack/plugins/canvas/public/services/stubs/labs.ts b/x-pack/plugins/canvas/public/services/stubs/labs.ts index 52168ebeb6f80..7caa1d0139a70 100644 --- a/x-pack/plugins/canvas/public/services/stubs/labs.ts +++ b/x-pack/plugins/canvas/public/services/stubs/labs.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { projectIDs } from '../../../../../../src/plugins/presentation_util/public'; import { CanvasLabsService } from '../labs'; const noop = (..._args: any[]): any => {}; @@ -12,4 +13,9 @@ const noop = (..._args: any[]): any => {}; export const labsService: CanvasLabsService = { getProject: noop, getProjects: noop, + getProjectIDs: () => projectIDs, + isLabsEnabled: () => true, + projectIDs, + reset: noop, + setProjectStatus: noop, }; diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index c95d825fb9b0b..9360825830e56 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -19,6 +19,7 @@ import { loadSampleData } from './sample_data'; import { setupInterpreter } from './setup_interpreter'; import { customElementType, workpadType, workpadTemplateType } from './saved_objects'; import { initializeTemplates } from './templates'; +import { getUISettings } from './ui_settings'; interface PluginsSetup { expressions: ExpressionsServerSetup; @@ -36,6 +37,7 @@ export class CanvasPlugin implements Plugin { } public setup(coreSetup: CoreSetup, plugins: PluginsSetup) { + coreSetup.uiSettings.register(getUISettings()); coreSetup.savedObjects.registerType(customElementType); coreSetup.savedObjects.registerType(workpadType); coreSetup.savedObjects.registerType(workpadTemplateType); diff --git a/x-pack/plugins/canvas/server/ui_settings.ts b/x-pack/plugins/canvas/server/ui_settings.ts new file mode 100644 index 0000000000000..75c4cc082c557 --- /dev/null +++ b/x-pack/plugins/canvas/server/ui_settings.ts @@ -0,0 +1,32 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { SETTING_CATEGORY } from '../../../../src/plugins/presentation_util/server'; +import { UiSettingsParams } from '../../../../src/core/types'; +import { UI_SETTINGS } from '../common'; + +/** + * uiSettings definitions for Presentation Util. + */ +export const getUISettings = (): Record> => ({ + [UI_SETTINGS.ENABLE_LABS_UI]: { + name: i18n.translate('xpack.canvas.labs.enableUI', { + defaultMessage: 'Enable labs button in Canvas', + }), + description: i18n.translate('xpack.canvas.labs.enableUnifiedToolbarProjectDescription', { + defaultMessage: + 'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable experimental features in Canvas.', + }), + value: false, + type: 'boolean', + schema: schema.boolean(), + category: [SETTING_CATEGORY], + requiresPageReload: true, + }, +}); diff --git a/x-pack/plugins/cloud/README.md b/x-pack/plugins/cloud/README.md index 13172e0a6ddc0..3fe0b3c8b8415 100644 --- a/x-pack/plugins/cloud/README.md +++ b/x-pack/plugins/cloud/README.md @@ -1,3 +1,11 @@ # `cloud` plugin The `cloud` plugin adds cloud specific features to Kibana. +The client-side plugin configures following values: +- `isCloudEnabled = true` for both ESS and ECE deployments +- `cloudId` is the ID of the Cloud deployment Kibana is running on +- `baseUrl` is the URL of the Cloud interface, for Elastic Cloud production environment the value is `https://cloud.elastic.co` +- `deploymentUrl` is the URL of the specific Cloud deployment Kibana is running on, the value is already concatenated with `baseUrl` +- `profileUrl` is the URL of the Cloud user profile page, the value is already concatenated with `baseUrl` +- `organizationUrl` is the URL of the Cloud account (& billing) page, the value is already concatenated with `baseUrl` +- `cname` value is the same as `baseUrl` on ESS but can be customized on ECE diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts new file mode 100644 index 0000000000000..5a66e6cf9c521 --- /dev/null +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -0,0 +1,190 @@ +/* + * 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 { nextTick } from '@kbn/test/jest'; +import { coreMock } from 'src/core/public/mocks'; +import { homePluginMock } from 'src/plugins/home/public/mocks'; +import { securityMock } from '../../security/public/mocks'; +import { CloudPlugin } from './plugin'; + +describe('Cloud Plugin', () => { + describe('#start', () => { + function setupPlugin({ + roles = [], + simulateUserError = false, + }: { roles?: string[]; simulateUserError?: boolean } = {}) { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext({ + id: 'cloudId', + base_url: 'https://cloud.elastic.co', + deployment_url: '/abc123', + profile_url: '/profile/alice', + organization_url: '/org/myOrg', + }) + ); + const coreSetup = coreMock.createSetup(); + const homeSetup = homePluginMock.createSetupContract(); + const securitySetup = securityMock.createSetup(); + if (simulateUserError) { + securitySetup.authc.getCurrentUser.mockRejectedValue(new Error('Something happened')); + } else { + securitySetup.authc.getCurrentUser.mockResolvedValue( + securityMock.createMockAuthenticatedUser({ + roles, + }) + ); + } + + plugin.setup(coreSetup, { home: homeSetup, security: securitySetup }); + + return { coreSetup, securitySetup, plugin }; + } + + it('registers help support URL', async () => { + const { plugin } = setupPlugin(); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + expect(coreStart.chrome.setHelpSupportUrl).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setHelpSupportUrl.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "https://support.elastic.co/", + ] + `); + }); + + it('registers a custom nav link for superusers', async () => { + const { plugin } = setupPlugin({ roles: ['superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "euiIconType": "arrowLeft", + "href": "https://cloud.elastic.co/abc123", + "title": "Manage this deployment", + }, + ] + `); + }); + + it('registers a custom nav link when there is an error retrieving the current user', async () => { + const { plugin } = setupPlugin({ simulateUserError: true }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "euiIconType": "arrowLeft", + "href": "https://cloud.elastic.co/abc123", + "title": "Manage this deployment", + }, + ] + `); + }); + + it('does not register a custom nav link for non-superusers', async () => { + const { plugin } = setupPlugin({ roles: ['not-a-superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(coreStart.chrome.setCustomNavLink).not.toHaveBeenCalled(); + }); + + it('registers user profile links for superusers', async () => { + const { plugin } = setupPlugin({ roles: ['superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1); + expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "href": "https://cloud.elastic.co/profile/alice", + "iconType": "user", + "label": "Profile", + "order": 100, + "setAsProfile": true, + }, + Object { + "href": "https://cloud.elastic.co/org/myOrg", + "iconType": "gear", + "label": "Account & Billing", + "order": 200, + }, + ], + ] + `); + }); + + it('registers profile links when there is an error retrieving the current user', async () => { + const { plugin } = setupPlugin({ simulateUserError: true }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1); + expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "href": "https://cloud.elastic.co/profile/alice", + "iconType": "user", + "label": "Profile", + "order": 100, + "setAsProfile": true, + }, + Object { + "href": "https://cloud.elastic.co/org/myOrg", + "iconType": "gear", + "label": "Account & Billing", + "order": 200, + }, + ], + ] + `); + }); + + it('does not register profile links for non-superusers', async () => { + const { plugin } = setupPlugin({ roles: ['not-a-superuser'] }); + + const coreStart = coreMock.createStart(); + const securityStart = securityMock.createStart(); + plugin.start(coreStart, { security: securityStart }); + + await nextTick(); + + expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 8ca4f7711811a..8ba0dfdc8b081 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -7,7 +7,7 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { i18n } from '@kbn/i18n'; -import { SecurityPluginStart } from '../../security/public'; +import { AuthenticatedUser, SecurityPluginSetup, SecurityPluginStart } from '../../security/public'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK } from '../common/constants'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; @@ -25,6 +25,7 @@ export interface CloudConfigType { interface CloudSetupDependencies { home?: HomePublicPluginSetup; + security?: Pick; } interface CloudStartDependencies { @@ -44,13 +45,14 @@ export interface CloudSetup { export class CloudPlugin implements Plugin { private config!: CloudConfigType; private isCloudEnabled: boolean; + private authenticatedUserPromise?: Promise; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); this.isCloudEnabled = false; } - public setup(core: CoreSetup, { home }: CloudSetupDependencies) { + public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { const { id, cname, @@ -68,6 +70,10 @@ export class CloudPlugin implements Plugin { } } + if (security) { + this.authenticatedUserPromise = security.authc.getCurrentUser().catch(() => null); + } + return { cloudId: id, cname, @@ -82,19 +88,47 @@ export class CloudPlugin implements Plugin { public start(coreStart: CoreStart, { security }: CloudStartDependencies) { const { deployment_url: deploymentUrl, base_url: baseUrl } = this.config; coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); - if (baseUrl && deploymentUrl) { - coreStart.chrome.setCustomNavLink({ - title: i18n.translate('xpack.cloud.deploymentLinkLabel', { - defaultMessage: 'Manage this deployment', - }), - euiIconType: 'arrowLeft', - href: getFullCloudUrl(baseUrl, deploymentUrl), - }); - } - if (security && this.isCloudEnabled) { - const userMenuLinks = createUserMenuLinks(this.config); - security.navControlService.addUserMenuLinks(userMenuLinks); - } + const setLinks = (authorized: boolean) => { + if (!authorized) return; + + if (baseUrl && deploymentUrl) { + coreStart.chrome.setCustomNavLink({ + title: i18n.translate('xpack.cloud.deploymentLinkLabel', { + defaultMessage: 'Manage this deployment', + }), + euiIconType: 'arrowLeft', + href: getFullCloudUrl(baseUrl, deploymentUrl), + }); + } + + if (security && this.isCloudEnabled) { + const userMenuLinks = createUserMenuLinks(this.config); + security.navControlService.addUserMenuLinks(userMenuLinks); + } + }; + + this.checkIfAuthorizedForLinks() + .then(setLinks) + // In the event of an unexpected error, fail *open*. + // Cloud admin console will always perform the actual authorization checks. + .catch(() => setLinks(true)); + } + + /** + * Determines if the current user should see links back to Cloud. + * This isn't a true authorization check, but rather a heuristic to + * see if the current user is *likely* a cloud deployment administrator. + * + * At this point, we do not have enough information to reliably make this determination, + * but we do know that all cloud deployment admins are superusers by default. + */ + private async checkIfAuthorizedForLinks() { + // Security plugin is disabled + if (!this.authenticatedUserPromise) return true; + // Otherwise check roles. If user is not defined due to an unexpected error, then fail *open*. + // Cloud admin console will always perform the actual authorization checks. + const user = await this.authenticatedUserPromise; + return user?.roles.includes('superuser') ?? true; } } diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 6abfb864d1cd0..fea8be9f934e1 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -10,6 +10,7 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/se import { CloudConfigType } from './config'; import { registerCloudUsageCollector } from './collectors'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; +import { parseDeploymentIdFromDeploymentUrl } from './utils'; interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -17,6 +18,7 @@ interface PluginsSetup { export interface CloudSetup { cloudId?: string; + deploymentId?: string; isCloudEnabled: boolean; apm: { url?: string; @@ -40,6 +42,7 @@ export class CloudPlugin implements Plugin { return { cloudId: this.config.id, + deploymentId: parseDeploymentIdFromDeploymentUrl(this.config.deployment_url), isCloudEnabled, apm: { url: this.config.apm?.url, diff --git a/x-pack/plugins/cloud/server/utils.test.ts b/x-pack/plugins/cloud/server/utils.test.ts new file mode 100644 index 0000000000000..00e7de7336c7a --- /dev/null +++ b/x-pack/plugins/cloud/server/utils.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parseDeploymentIdFromDeploymentUrl } from './utils'; + +describe('parseDeploymentIdFromDeploymentUrl', () => { + it('should return undefined if there is no deploymentUrl configured', () => { + expect(parseDeploymentIdFromDeploymentUrl()).toBeUndefined(); + }); + + it('should return the deploymentId if this is a valid deployment url', () => { + expect(parseDeploymentIdFromDeploymentUrl('deployments/uuid-deployment-1')).toBe( + 'uuid-deployment-1' + ); + }); +}); diff --git a/x-pack/plugins/cloud/server/utils.ts b/x-pack/plugins/cloud/server/utils.ts new file mode 100644 index 0000000000000..635f0e83f79e5 --- /dev/null +++ b/x-pack/plugins/cloud/server/utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function parseDeploymentIdFromDeploymentUrl(deploymentUrl?: string) { + if (!deploymentUrl) { + return; + } + return deploymentUrl.split('/').pop(); +} diff --git a/x-pack/plugins/discover_enhanced/common/config.ts b/x-pack/plugins/discover_enhanced/common/config.ts index f8de31aed719a..26b4cc6520c1d 100644 --- a/x-pack/plugins/discover_enhanced/common/config.ts +++ b/x-pack/plugins/discover_enhanced/common/config.ts @@ -6,5 +6,8 @@ */ export interface Config { - actions: { exploreDataInChart: { enabled: boolean } }; + actions: { + exploreDataInChart: { enabled: boolean }; + exploreDataInContextMenu: { enabled: boolean }; + }; } diff --git a/x-pack/plugins/discover_enhanced/public/plugin.ts b/x-pack/plugins/discover_enhanced/public/plugin.ts index a5425307aec6f..60f242d682ffc 100644 --- a/x-pack/plugins/discover_enhanced/public/plugin.ts +++ b/x-pack/plugins/discover_enhanced/public/plugin.ts @@ -56,8 +56,10 @@ export class DiscoverEnhancedPlugin if (isSharePluginInstalled) { const params = { start }; - const exploreDataAction = new ExploreDataContextMenuAction(params); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, exploreDataAction); + if (this.config.actions.exploreDataInContextMenu.enabled) { + const exploreDataAction = new ExploreDataContextMenuAction(params); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, exploreDataAction); + } if (this.config.actions.exploreDataInChart.enabled) { const exploreDataChartAction = new ExploreDataChartAction(params); diff --git a/x-pack/plugins/discover_enhanced/server/config.ts b/x-pack/plugins/discover_enhanced/server/config.ts index f57b162dc5b4d..95ac46a662ea0 100644 --- a/x-pack/plugins/discover_enhanced/server/config.ts +++ b/x-pack/plugins/discover_enhanced/server/config.ts @@ -13,6 +13,9 @@ export const configSchema = schema.object({ exploreDataInChart: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), + exploreDataInContextMenu: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts index b6c68151c9974..8f0c63d46c8e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -32,6 +32,7 @@ jest.mock('react-router-dom', () => { useHistory: jest.fn(() => mockHistory), useLocation: jest.fn(() => mockLocation), useParams: jest.fn(() => ({})), + useRouteMatch: jest.fn(() => null), // Note: RR's generatePath() opinionatedly encodeURI()s paths (although this doesn't actually // show up/affect the final browser URL). Since we already have a generateEncodedPath helper & // RR is removing this behavior in history 5.0+, I'm mocking tests to remove the extra encoding diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index dfca497807718..7d8c1b420378f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -168,8 +168,7 @@ export const EngineNav: React.FC = () => { )} {canViewMetaEngineSourceEngines && isMetaEngine && ( {ENGINES_TITLE} @@ -236,8 +235,7 @@ export const EngineNav: React.FC = () => { )} {canManageEngineSearchUi && ( {SEARCH_UI_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index d01958942e0a1..9565408f7f47c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -22,6 +22,8 @@ import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { SearchUI } from '../search_ui'; +import { SourceEngines } from '../source_engines'; import { Synonyms } from '../synonyms'; import { EngineRouter } from './engine_router'; @@ -135,4 +137,18 @@ describe('EngineRouter', () => { expect(wrapper.find(ApiLogs)).toHaveLength(1); }); + + it('renders a search ui view', () => { + setMockValues({ ...values, myRole: { canManageEngineSearchUi: true } }); + const wrapper = shallow(); + + expect(wrapper.find(SearchUI)).toHaveLength(1); + }); + + it('renders a source engines view', () => { + setMockValues({ ...values, myRole: { canViewMetaEngineSourceEngines: true } }); + const wrapper = shallow(); + + expect(wrapper.find(SourceEngines)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index c246af3611563..80d1096237345 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -25,12 +25,12 @@ import { ENGINE_DOCUMENT_DETAIL_PATH, // ENGINE_SCHEMA_PATH, // ENGINE_CRAWLER_PATH, - // META_ENGINE_SOURCE_ENGINES_PATH, + META_ENGINE_SOURCE_ENGINES_PATH, ENGINE_RELEVANCE_TUNING_PATH, ENGINE_SYNONYMS_PATH, ENGINE_CURATIONS_PATH, ENGINE_RESULT_SETTINGS_PATH, - // ENGINE_SEARCH_UI_PATH, + ENGINE_SEARCH_UI_PATH, ENGINE_API_LOGS_PATH, } from '../../routes'; import { AnalyticsRouter } from '../analytics'; @@ -40,6 +40,8 @@ import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { SearchUI } from '../search_ui'; +import { SourceEngines } from '../source_engines'; import { Synonyms } from '../synonyms'; import { EngineLogic, getEngineBreadcrumbs } from './'; @@ -51,12 +53,12 @@ export const EngineRouter: React.FC = () => { // canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, - // canViewMetaEngineSourceEngines, + canViewMetaEngineSourceEngines, canManageEngineRelevanceTuning, canManageEngineSynonyms, canManageEngineCurations, canManageEngineResultSettings, - // canManageEngineSearchUi, + canManageEngineSearchUi, canViewEngineApiLogs, }, } = useValues(AppLogic); @@ -122,6 +124,16 @@ export const EngineRouter: React.FC = () => { )} + {canManageEngineSearchUi && ( + + + + )} + {canViewMetaEngineSourceEngines && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts index 04e1ee5c1b61a..3a4c7d51c50a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts @@ -7,12 +7,10 @@ import { kea, MakeLogicType } from 'kea'; -import { Meta } from '../../../../../../../common/types'; import { flashAPIErrors } from '../../../../../shared/flash_messages'; - import { HttpLogic } from '../../../../../shared/http'; - import { EngineDetails } from '../../../engine/types'; +import { EnginesAPIResponse } from '../../types'; interface MetaEnginesTableValues { expandedRows: { [id: string]: boolean }; @@ -30,11 +28,6 @@ interface MetaEnginesTableActions { hideRow(itemId: string): { itemId: string }; } -interface EnginesAPIResponse { - results: EngineDetails[]; - meta: Meta; -} - export const MetaEnginesTableLogic = kea< MakeLogicType >({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts index 282731fda3bd2..36c31f9891f6e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts @@ -16,6 +16,7 @@ import { updateMetaPageIndex } from '../../../shared/table_pagination'; import { EngineDetails, EngineTypes } from '../engine/types'; import { DELETE_ENGINE_MESSAGE } from './constants'; +import { EnginesAPIResponse } from './types'; interface EnginesValues { dataLoading: boolean; @@ -27,10 +28,6 @@ interface EnginesValues { metaEnginesLoading: boolean; } -interface EnginesAPIResponse { - results: EngineDetails[]; - meta: Meta; -} interface EnginesActions { deleteEngine(engine: EngineDetails): { engine: EngineDetails }; onDeleteEngineSuccess(engine: EngineDetails): { engine: EngineDetails }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/types.ts new file mode 100644 index 0000000000000..95b507954b8d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/types.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. + */ + +import { Meta } from '../../../../../common/types'; +import { EngineDetails } from '../engine/types'; + +export interface EnginesAPIResponse { + results: EngineDetails[]; + meta: Meta; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx index b193e00c1d48d..a53e8a099177c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiPageContent } from '@elastic/eui'; +import { EuiPage, EuiPageContent } from '@elastic/eui'; import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; @@ -19,9 +19,11 @@ export const ErrorConnecting: React.FC = () => { - - - + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts index 054e3cf14a777..f161f891eb4a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts @@ -6,3 +6,4 @@ */ export { SEARCH_UI_TITLE } from './constants'; +export { SearchUI } from './search_ui'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx new file mode 100644 index 0000000000000..352ef257dc8a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SearchUI } from './'; + +describe('SearchUI', () => { + it('renders', () => { + shallow(); + // TODO: Check for form + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx new file mode 100644 index 0000000000000..086769f1556e9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { getEngineBreadcrumbs } from '../engine'; + +import { SEARCH_UI_TITLE } from './constants'; + +export const SearchUI: React.FC = () => { + return ( + <> + + + + TODO + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/index.ts new file mode 100644 index 0000000000000..5f85fba54d8e7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { SourceEngines } from './source_engines'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx new file mode 100644 index 0000000000000..4bf62de408a2b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCodeBlock } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { SourceEngines } from '.'; + +const MOCK_ACTIONS = { + // SourceEnginesLogic + fetchSourceEngines: jest.fn(), +}; + +const MOCK_VALUES = { + dataLoading: false, + sourceEngines: [], +}; + +describe('SourceEngines', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + }); + + describe('non-happy-path states', () => { + it('renders a loading component before data has loaded', () => { + setMockValues({ ...MOCK_VALUES, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('renders and calls a function to initialize data', () => { + setMockValues(MOCK_VALUES); + const wrapper = shallow(); + + expect(wrapper.find(EuiCodeBlock)).toHaveLength(1); + expect(MOCK_ACTIONS.fetchSourceEngines).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx new file mode 100644 index 0000000000000..0b68eb5fd2c2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx @@ -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 React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiCodeBlock, EuiPageHeader } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Loading } from '../../../shared/loading'; +import { getEngineBreadcrumbs } from '../engine'; + +import { SourceEnginesLogic } from './source_engines_logic'; + +const SOURCE_ENGINES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.souceEngines.title', + { + defaultMessage: 'Manage engines', + } +); + +export const SourceEngines: React.FC = () => { + const { fetchSourceEngines } = useActions(SourceEnginesLogic); + const { dataLoading, sourceEngines } = useValues(SourceEnginesLogic); + + useEffect(() => { + fetchSourceEngines(); + }, []); + + if (dataLoading) return ; + + return ( + <> + + + + {JSON.stringify(sourceEngines, null, 2)} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts new file mode 100644 index 0000000000000..df1165620adc3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { EngineDetails } from '../engine/types'; + +import { SourceEnginesLogic } from './source_engines_logic'; + +const DEFAULT_VALUES = { + dataLoading: true, + sourceEngines: [], +}; + +describe('SourceEnginesLogic', () => { + const { http } = mockHttpValues; + const { mount } = new LogicMounter(SourceEnginesLogic); + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('initializes with default values', () => { + expect(SourceEnginesLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('setSourceEngines', () => { + beforeEach(() => { + SourceEnginesLogic.actions.onSourceEnginesFetch([ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[]); + }); + + it('sets the source engines', () => { + expect(SourceEnginesLogic.values.sourceEngines).toEqual([ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ]); + }); + + it('sets dataLoading to false', () => { + expect(SourceEnginesLogic.values.dataLoading).toEqual(false); + }); + }); + + describe('fetchSourceEngines', () => { + it('calls addSourceEngines and displayRow when it has retrieved all pages', async () => { + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 1, + }, + }, + results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }) + ); + jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch'); + + SourceEnginesLogic.actions.fetchSourceEngines(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/source_engines', { + query: { + 'page[current]': 1, + 'page[size]': 25, + }, + }); + expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ]); + }); + + it('display a flash message on error', async () => { + http.get.mockReturnValueOnce(Promise.reject()); + mount(); + + SourceEnginesLogic.actions.fetchSourceEngines(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); + + it('recursively fetches a number of pages', async () => { + mount(); + jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch'); + + // First page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-1' }], + }) + ); + + // Second and final page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-2' }], + }) + ); + + SourceEnginesLogic.actions.fetchSourceEngines(); + await nextTick(); + + expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([ + // First page + { name: 'source-engine-1' }, + // Second and final page + { name: 'source-engine-2' }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts new file mode 100644 index 0000000000000..b8a5c7c359518 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts @@ -0,0 +1,85 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; +import { EngineDetails } from '../engine/types'; +import { EnginesAPIResponse } from '../engines/types'; + +interface SourceEnginesLogicValues { + dataLoading: boolean; + sourceEngines: EngineDetails[]; +} + +interface SourceEnginesLogicActions { + fetchSourceEngines: () => void; + onSourceEnginesFetch: ( + sourceEngines: SourceEnginesLogicValues['sourceEngines'] + ) => { sourceEngines: SourceEnginesLogicValues['sourceEngines'] }; +} + +export const SourceEnginesLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'source_engines_logic'], + actions: () => ({ + fetchSourceEngines: true, + onSourceEnginesFetch: (sourceEngines) => ({ sourceEngines }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + onSourceEnginesFetch: () => false, + }, + ], + sourceEngines: [ + [], + { + onSourceEnginesFetch: (_, { sourceEngines }) => sourceEngines, + }, + ], + }), + listeners: ({ actions }) => ({ + fetchSourceEngines: () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + let enginesAccumulator: EngineDetails[] = []; + + // We need to recursively fetch all source engines because we put the data + // into an EuiInMemoryTable to enable searching + const recursiveFetchSourceEngines = async (page = 1) => { + try { + const { meta, results }: EnginesAPIResponse = await http.get( + `/api/app_search/engines/${engineName}/source_engines`, + { + query: { + 'page[current]': page, + 'page[size]': 25, + }, + } + ); + + enginesAccumulator = [...enginesAccumulator, ...results]; + + if (page >= meta.page.total_pages) { + actions.onSourceEnginesFetch(enginesAccumulator); + } else { + recursiveFetchSourceEngines(page + 1); + } + } catch (e) { + flashAPIErrors(e); + } + }; + + recursiveFetchSourceEngines(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx new file mode 100644 index 0000000000000..f1382bb5972b2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Create your first synonym set'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/synonyms-guide.html') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx new file mode 100644 index 0000000000000..2eb6643bda503 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx @@ -0,0 +1,52 @@ +/* + * 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 React from 'react'; + +import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +import { SynonymIcon } from './'; + +export const EmptyState: React.FC = () => { + return ( + + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.title', { + defaultMessage: 'Create your first synonym set', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.description', { + defaultMessage: + 'Synonyms relate queries with similar context or meaning together. Use them to guide users to relevant content.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.buttonLabel', { + defaultMessage: 'Read the synonyms guide', + })} + + } + /> +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts new file mode 100644 index 0000000000000..8a2bf1c0d2f78 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { SynonymIcon } from './synonym_icon'; +export { SynonymCard } from './synonym_card'; +export { EmptyState } from './empty_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx new file mode 100644 index 0000000000000..ef24e206ed681 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCard, EuiButton } from '@elastic/eui'; + +import { SynonymCard, SynonymIcon } from './'; + +describe('SynonymCard', () => { + const MOCK_SYNONYM_SET = { + id: 'syn-1234567890', + synonyms: ['lorem', 'ipsum', 'dolor', 'sit', 'amet'], + }; + + const wrapper = shallow() + .find(EuiCard) + .dive(); + + it('renders with the first synonym as the title', () => { + expect(wrapper.find('h2').text()).toEqual('lorem'); + }); + + it('renders a synonym icon for each subsequent synonym', () => { + expect(wrapper.find(SynonymIcon)).toHaveLength(4); + }); + + it('renders a manage synonym button', () => { + wrapper.find(EuiButton).simulate('click'); + // TODO: expect open modal action + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx new file mode 100644 index 0000000000000..77363306527c3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx @@ -0,0 +1,45 @@ +/* + * 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 React from 'react'; + +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiText, EuiButton } from '@elastic/eui'; + +import { MANAGE_BUTTON_LABEL } from '../../../../shared/constants'; + +import { SynonymSet } from '../types'; + +import { SynonymIcon } from './'; + +export const SynonymCard: React.FC = (synonymSet) => { + const [firstSynonym, ...remainingSynonyms] = synonymSet.synonyms; + + return ( + + + {} /* TODO */}>{MANAGE_BUTTON_LABEL} + + + } + > + + {remainingSynonyms.map((synonym) => ( +
+ {synonym} +
+ ))} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx new file mode 100644 index 0000000000000..8120532fbd6f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx @@ -0,0 +1,19 @@ +/* + * 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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { SynonymIcon } from './'; + +describe('SynonymIcon', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.hasClass('euiIcon')).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx new file mode 100644 index 0000000000000..f76b8be818c47 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +export const SynonymIcon: React.FC = ({ ...props }) => ( + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts index cbbd1e631b7ef..2cb50b6cba1b3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts @@ -7,6 +7,15 @@ import { i18n } from '@kbn/i18n'; +import { DEFAULT_META } from '../../../shared/constants'; + +export const SYNONYMS_PAGE_META = { + page: { + ...DEFAULT_META.page, + size: 12, // Use a multiple of 3, since synonym cards are in rows of 3 + }, +}; + export const SYNONYMS_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.synonyms.title', { defaultMessage: 'Synonyms' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts index 177bc5eade0f6..4b9de7ef90603 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts @@ -7,3 +7,4 @@ export { SYNONYMS_TITLE } from './constants'; export { Synonyms } from './synonyms'; +export { SynonymsLogic } from './synonyms_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx index e093442f77b77..11692a1542c4d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx @@ -5,17 +5,123 @@ * 2.0. */ +import { setMockValues, setMockActions, rerender } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; +import { EuiPageHeader, EuiButton, EuiPagination } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { SynonymCard, EmptyState } from './components'; + import { Synonyms } from './'; describe('Synonyms', () => { + const MOCK_SYNONYM_SET = { + id: 'syn-1234567890', + synonyms: ['a', 'b', 'c'], + }; + + const values = { + synonymSets: [MOCK_SYNONYM_SET, MOCK_SYNONYM_SET, MOCK_SYNONYM_SET], + meta: { page: { current: 1 } }, + dataLoading: false, + }; + const actions = { + loadSynonyms: jest.fn(), + onPaginate: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + it('renders', () => { - shallow(); - // TODO: Check for Synonym cards, Synonym modal + const wrapper = shallow(); + + expect(wrapper.find(SynonymCard)).toHaveLength(3); + // TODO: Check for synonym modal + }); + + it('renders a create action button', () => { + const wrapper = shallow() + .find(EuiPageHeader) + .dive() + .children() + .dive(); + + wrapper.find(EuiButton).simulate('click'); + // TODO: Expect open modal action + }); + + it('renders an empty state if no synonyms exist', () => { + setMockValues({ ...values, synonymSets: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + describe('loading', () => { + it('renders a loading state on initial page load', () => { + setMockValues({ ...values, synonymSets: [], dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('does not render a full loading state after initial page load', () => { + setMockValues({ ...values, synonymSets: [MOCK_SYNONYM_SET], dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(0); + }); + }); + + describe('API & pagination', () => { + it('loads synonyms on page load and on pagination', () => { + const wrapper = shallow(); + expect(actions.loadSynonyms).toHaveBeenCalledTimes(1); + + setMockValues({ ...values, meta: { page: { current: 5 } } }); + rerender(wrapper); + expect(actions.loadSynonyms).toHaveBeenCalledTimes(2); + }); + + it('automatically paginations users back a page if they delete the only remaining synonym on the page', () => { + setMockValues({ ...values, meta: { page: { current: 5 } }, synonymSets: [] }); + shallow(); + + expect(actions.onPaginate).toHaveBeenCalledWith(4); + }); + + it('does not paginate backwards if the user is on the first page (should show the state instead)', () => { + setMockValues({ ...values, meta: { page: { current: 1 } }, synonymSets: [] }); + const wrapper = shallow(); + + expect(actions.onPaginate).not.toHaveBeenCalled(); + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + it('handles off-by-one shenanigans between EuiPagination and our API', () => { + setMockValues({ + ...values, + meta: { page: { total_pages: 10, current: 1 } }, + }); + const wrapper = shallow(); + const pagination = wrapper.find(EuiPagination); + + expect(pagination.prop('pageCount')).toEqual(10); + expect(pagination.prop('activePage')).toEqual(0); + + pagination.simulate('pageClick', 4); + expect(actions.onPaginate).toHaveBeenCalledWith(5); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx index 0b18271660911..59bd501f54681 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx @@ -5,23 +5,86 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; -import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; +import { useValues, useActions } from 'kea'; + +import { + EuiPageHeader, + EuiButton, + EuiPageContentBody, + EuiSpacer, + EuiFlexGrid, + EuiFlexItem, + EuiPagination, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Loading } from '../../../shared/loading'; import { getEngineBreadcrumbs } from '../engine'; +import { SynonymCard, EmptyState } from './components'; import { SYNONYMS_TITLE } from './constants'; +import { SynonymsLogic } from './'; + export const Synonyms: React.FC = () => { + const { loadSynonyms, onPaginate } = useActions(SynonymsLogic); + const { synonymSets, meta, dataLoading } = useValues(SynonymsLogic); + const hasSynonyms = synonymSets.length > 0; + + useEffect(() => { + loadSynonyms(); + }, [meta.page.current]); + + useEffect(() => { + // If users delete the only synonym set on the page, send them back to the previous page + if (!hasSynonyms && meta.page.current !== 1) { + onPaginate(meta.page.current - 1); + } + }, [synonymSets]); + + if (dataLoading && !hasSynonyms) return ; + return ( <> - + {} /* TODO */}> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.synonyms.createSynonymSetButtonLabel', + { defaultMessage: 'Create a synonym set' } + )} + , + ]} + /> - TODO + + + {hasSynonyms ? ( + <> + + {synonymSets.map(({ id, synonyms }) => ( + + + + ))} + + + onPaginate(pageIndex + 1)} + /> + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts new file mode 100644 index 0000000000000..2497787a55f1e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { SYNONYMS_PAGE_META } from './constants'; + +import { SynonymsLogic } from './'; + +describe('SynonymsLogic', () => { + const { mount } = new LogicMounter(SynonymsLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SYNONYMS_RESPONSE = { + meta: { + page: { + current: 1, + size: 12, + total_results: 1, + total_pages: 1, + }, + }, + results: [ + { + id: 'some-synonym-id', + synonyms: ['hello', 'world'], + }, + ], + }; + + const DEFAULT_VALUES = { + dataLoading: true, + synonymSets: [], + meta: SYNONYMS_PAGE_META, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(SynonymsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onSynonymsLoad', () => { + it('should set synonyms and meta state, & dataLoading to false', () => { + mount(); + + SynonymsLogic.actions.onSynonymsLoad(MOCK_SYNONYMS_RESPONSE); + + expect(SynonymsLogic.values).toEqual({ + ...DEFAULT_VALUES, + synonymSets: MOCK_SYNONYMS_RESPONSE.results, + meta: MOCK_SYNONYMS_RESPONSE.meta, + dataLoading: false, + }); + }); + }); + + describe('onPaginate', () => { + it('should set meta.page.current state', () => { + mount(); + + SynonymsLogic.actions.onPaginate(3); + + expect(SynonymsLogic.values).toEqual({ + ...DEFAULT_VALUES, + meta: { page: { ...DEFAULT_VALUES.meta.page, current: 3 } }, + }); + }); + }); + }); + + describe('listeners', () => { + describe('loadSynonyms', () => { + it('should set dataLoading state', () => { + mount({ dataLoading: false }); + + SynonymsLogic.actions.loadSynonyms(); + + expect(SynonymsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + + it('should make an API call and set synonyms & meta state', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_SYNONYMS_RESPONSE)); + mount(); + jest.spyOn(SynonymsLogic.actions, 'onSynonymsLoad'); + + SynonymsLogic.actions.loadSynonyms(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/synonyms', { + query: { + 'page[current]': 1, + 'page[size]': 12, + }, + }); + expect(SynonymsLogic.actions.onSynonymsLoad).toHaveBeenCalledWith(MOCK_SYNONYMS_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount(); + + SynonymsLogic.actions.loadSynonyms(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts new file mode 100644 index 0000000000000..a55fcf83a5f8b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts @@ -0,0 +1,79 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { Meta } from '../../../../../common/types'; +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { updateMetaPageIndex } from '../../../shared/table_pagination'; +import { EngineLogic } from '../engine'; + +import { SYNONYMS_PAGE_META } from './constants'; +import { SynonymSet, SynonymsApiResponse } from './types'; + +interface SynonymsValues { + dataLoading: boolean; + synonymSets: SynonymSet[]; + meta: Meta; +} + +interface SynonymsActions { + loadSynonyms(): void; + onSynonymsLoad(response: SynonymsApiResponse): SynonymsApiResponse; + onPaginate(newPageIndex: number): { newPageIndex: number }; +} + +export const SynonymsLogic = kea>({ + path: ['enterprise_search', 'app_search', 'synonyms_logic'], + actions: () => ({ + loadSynonyms: true, + onSynonymsLoad: ({ results, meta }) => ({ results, meta }), + onPaginate: (newPageIndex) => ({ newPageIndex }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + loadSynonyms: () => true, + onSynonymsLoad: () => false, + }, + ], + synonymSets: [ + [], + { + onSynonymsLoad: (_, { results }) => results, + }, + ], + meta: [ + SYNONYMS_PAGE_META, + { + onSynonymsLoad: (_, { meta }) => meta, + onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex), + }, + ], + }), + listeners: ({ actions, values }) => ({ + loadSynonyms: async () => { + const { meta } = values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/synonyms`, { + query: { + 'page[current]': meta.page.current, + 'page[size]': meta.page.size, + }, + }); + actions.onSynonymsLoad(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.ts new file mode 100644 index 0000000000000..2f6da766a6d50 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.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 { Meta } from '../../../../../common/types'; + +export interface SynonymSet { + id: string; + synonyms: string[]; +} + +export interface SynonymsApiResponse { + results: SynonymSet[]; + meta: Meta; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 62a0ccc01f29a..d26838335d8f6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -6,12 +6,13 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; -import '../__mocks__/enterprise_search_url.mock'; import { setMockValues, rerender } from '../__mocks__'; +import '../__mocks__/enterprise_search_url.mock'; +import '../__mocks__/react_router_history.mock'; import React from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useRouteMatch } from 'react-router-dom'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -20,7 +21,7 @@ import { Layout, SideNav, SideNavLink } from '../shared/layout'; jest.mock('./app_logic', () => ({ AppLogic: jest.fn() })); import { AppLogic } from './app_logic'; -import { EngineRouter } from './components/engine'; +import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; @@ -31,6 +32,12 @@ import { SetupGuide } from './components/setup_guide'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; describe('AppSearch', () => { + it('always renders the Setup Guide', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + it('renders AppSearchUnconfigured when config.host is not set', () => { setMockValues({ config: { host: '' } }); const wrapper = shallow(); @@ -38,8 +45,15 @@ describe('AppSearch', () => { expect(wrapper.find(AppSearchUnconfigured)).toHaveLength(1); }); - it('renders AppSearchConfigured when config.host set', () => { - setMockValues({ config: { host: 'some.url' } }); + it('renders ErrorConnecting when Enterprise Search is unavailable', () => { + setMockValues({ errorConnecting: true }); + const wrapper = shallow(); + + expect(wrapper.find(ErrorConnecting)).toHaveLength(1); + }); + + it('renders AppSearchConfigured when config.host is set & available', () => { + setMockValues({ errorConnecting: false, config: { host: 'some.url' } }); const wrapper = shallow(); expect(wrapper.find(AppSearchConfigured)).toHaveLength(1); @@ -47,10 +61,9 @@ describe('AppSearch', () => { }); describe('AppSearchUnconfigured', () => { - it('renders the Setup Guide and redirects to the Setup Guide', () => { + it('redirects to the Setup Guide', () => { const wrapper = shallow(); - expect(wrapper.find(SetupGuide)).toHaveLength(1); expect(wrapper.find(Redirect)).toHaveLength(1); }); }); @@ -64,8 +77,8 @@ describe('AppSearchConfigured', () => { }); it('renders with layout', () => { - expect(wrapper.find(Layout)).toHaveLength(2); - expect(wrapper.find(Layout).last().prop('readOnlyMode')).toBeFalsy(); + expect(wrapper.find(Layout)).toHaveLength(1); + expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(EnginesOverview)).toHaveLength(1); expect(wrapper.find(EngineRouter)).toHaveLength(1); }); @@ -74,13 +87,6 @@ describe('AppSearchConfigured', () => { expect(AppLogic).toHaveBeenCalledWith(DEFAULT_INITIAL_APP_DATA); }); - it('renders ErrorConnecting', () => { - setMockValues({ myRole: {}, errorConnecting: true }); - rerender(wrapper); - - expect(wrapper.find(ErrorConnecting)).toHaveLength(1); - }); - it('passes readOnlyMode state', () => { setMockValues({ myRole: {}, readOnlyMode: true }); rerender(wrapper); @@ -145,11 +151,22 @@ describe('AppSearchNav', () => { expect(wrapper.find(SideNavLink).prop('to')).toEqual('/engines'); }); - it('renders an Engine subnav if passed', () => { - const wrapper = shallow(Testing
} />); - const link = wrapper.find(SideNavLink).dive(); + describe('engine subnavigation', () => { + const getEnginesLink = (wrapper: ShallowWrapper) => wrapper.find(SideNavLink).dive(); - expect(link.find('[data-test-subj="subnav"]')).toHaveLength(1); + it('does not render the engine subnav on top-level routes', () => { + (useRouteMatch as jest.Mock).mockReturnValueOnce(false); + const wrapper = shallow(); + + expect(getEnginesLink(wrapper).find(EngineNav)).toHaveLength(0); + }); + + it('renders the engine subnav if currently on an engine route', () => { + (useRouteMatch as jest.Mock).mockReturnValueOnce(true); + const wrapper = shallow(); + + expect(getEnginesLink(wrapper).find(EngineNav)).toHaveLength(1); + }); }); it('renders the Settings link', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 3a46a90d20d66..0b87321d87535 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import { Route, Redirect, Switch, useRouteMatch } from 'react-router-dom'; import { useValues } from 'kea'; @@ -45,18 +45,28 @@ import { export const AppSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); - return !config.host ? ( - - ) : ( - )} /> + const { errorConnecting } = useValues(HttpLogic); + + return ( + + + + + + {!config.host ? ( + + ) : errorConnecting ? ( + + ) : ( + )} /> + )} + + ); }; export const AppSearchUnconfigured: React.FC = () => ( - - - @@ -67,79 +77,68 @@ export const AppSearchConfigured: React.FC> = (props) = const { myRole: { canManageEngines, canManageMetaEngines, canViewRoleMappings }, } = useValues(AppLogic(props)); - const { errorConnecting, readOnlyMode } = useValues(HttpLogic); + const { readOnlyMode } = useValues(HttpLogic); return ( - - - {process.env.NODE_ENV === 'development' && ( )} - - } />} readOnlyMode={readOnlyMode}> - - - } readOnlyMode={readOnlyMode}> - {errorConnecting ? ( - - ) : ( - - - + + + + + + + + + + + + + + + + + {canViewRoleMappings && ( + + - - + )} + {canManageEngines && ( + + - - + )} + {canManageMetaEngines && ( + + - - - - {canViewRoleMappings && ( - - - - )} - {canManageEngines && ( - - - - )} - {canManageMetaEngines && ( - - - - )} - - - - - )} + )} + + + + ); }; -interface AppSearchNavProps { - subNav?: React.ReactNode; -} - -export const AppSearchNav: React.FC = ({ subNav }) => { +export const AppSearchNav: React.FC = () => { const { myRole: { canViewSettings, canViewAccountCredentials, canViewRoleMappings }, } = useValues(AppLogic); + const isEngineRoute = !!useRouteMatch(ENGINE_PATH); + return ( - + : null} isRoot> {ENGINES_TITLE} {canViewSettings && {SETTINGS_TITLE}} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts new file mode 100644 index 0000000000000..e6caa5c3a7642 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { StatusItem } from './status_item'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx new file mode 100644 index 0000000000000..c1c18b51f9fd3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiPopover, EuiCopy, EuiButton, EuiButtonIcon } from '@elastic/eui'; + +import { StatusItem } from './'; + +describe('SourceRow', () => { + const details = ['foo', 'bar']; + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiPopover)).toHaveLength(1); + }); + + it('renders the copy button', () => { + const copyMock = jest.fn(); + const wrapper = shallow(); + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + + expect(copyEl.find(EuiButton).props().onClick).toEqual(copyMock); + }); + + it('handles popover visibility toggle click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiPopover).dive().find(EuiButtonIcon); + button.simulate('click'); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); + + wrapper.find(EuiPopover).prop('closePopover')(); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx new file mode 100644 index 0000000000000..79455ccc1d90d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { useState } from 'react'; + +import { + EuiCopy, + EuiButton, + EuiButtonIcon, + EuiToolTip, + EuiSpacer, + EuiCodeBlock, + EuiPopover, +} from '@elastic/eui'; + +import { COPY_TEXT, STATUS_POPOVER_TOOLTIP } from '../../../constants'; + +interface StatusItemProps { + details: string[]; +} + +export const StatusItem: React.FC = ({ details }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + const formattedDetails = details.join('\n'); + + const tooltipPopoverTrigger = ( + + + + ); + + const infoPopover = ( + + + {formattedDetails} + + + + {(copy) => ( + + {COPY_TEXT} + + )} + + + ); + + return infoPopover; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 9f758cacdfce3..dcebc35d45f71 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -751,3 +751,14 @@ export const REMOVE_BUTTON = i18n.translate( defaultMessage: 'Remove', } ); + +export const COPY_TEXT = i18n.translate('xpack.enterpriseSearch.workplaceSearch.copyText', { + defaultMessage: 'Copy', +}); + +export const STATUS_POPOVER_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.statusPopoverTooltip', + { + defaultMessage: 'Click to view info', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 8186c43efef49..ee4bcfb9afd34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -6,17 +6,17 @@ */ import React, { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; - -import { Location } from 'history'; import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { setSuccessMessage } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; -import { SOURCE_ADDED_PATH, getSourcesPath } from '../../../../routes'; +import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { staticSourceData } from '../../source_data'; @@ -34,7 +34,6 @@ import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { - const { search } = useLocation() as Location; const { initializeAddSource, setAddSourceStep, @@ -78,6 +77,13 @@ export const AddSource: React.FC = (props) => { const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); + const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', + { + defaultMessage: '{name} connected', + values: { name }, + } + ); const goToConnectInstance = () => { setAddSourceStep(AddSourceSteps.ConnectInstanceStep); @@ -88,9 +94,8 @@ export const AddSource: React.FC = (props) => { const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); const goToFormSourceCreated = () => { - KibanaLogic.values.navigateToUrl( - `${getSourcesPath(SOURCE_ADDED_PATH, isOrganization)}${search}` - ); + KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); + setSuccessMessage(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); }; const header = ; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 86c911e7e0b00..153df1bc00496 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -14,7 +14,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, - EuiIconTip, EuiLink, EuiPanel, EuiSpacer, @@ -37,6 +36,7 @@ import aclImage from '../../../assets/supports_acl.svg'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { CredentialItem } from '../../../components/shared/credential_item'; import { LicenseBadge } from '../../../components/shared/license_badge'; +import { StatusItem } from '../../../components/shared/status_item'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; import { RECENT_ACTIVITY_TITLE, @@ -199,15 +199,7 @@ export const Overview: React.FC = () => { {!custom && ( - {status}{' '} - {activityDetails && ( - ( -
{detail}
- ))} - /> - )} + {status} {activityDetails && }
)} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 4bc623ac9fdf8..aa6cbf3cf6574 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -36,6 +36,7 @@ import { import { SourceDataItem } from '../../../types'; import { AddSourceLogic } from '../components/add_source/add_source_logic'; import { + SOURCE_SETTINGS_HEADING, SOURCE_SETTINGS_TITLE, SOURCE_SETTINGS_DESCRIPTION, SOURCE_NAME_LABEL, @@ -128,7 +129,7 @@ export const SourceSettings: React.FC = () => { return ( <> - +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 32df63d0faba9..78722bf766961 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -257,6 +257,13 @@ export const READY_TEXT = i18n.translate( } ); +export const SOURCE_SETTINGS_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.settings.heading', + { + defaultMessage: 'Settings', + } +); + export const SOURCE_SETTINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.settings.title', { @@ -295,7 +302,7 @@ export const SOURCE_CONFIG_LINK = i18n.translate( export const SOURCE_REMOVE_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.remove.title', { - defaultMessage: 'Remove this source', + defaultMessage: 'Remove this content source', } ); diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index dd1a62d243d03..e402d233da58d 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -116,7 +116,7 @@ export class EnterpriseSearchPlugin implements Plugin { // The Workplace Search Personal dashboard needs the chrome hidden. We hide it globally // here first to prevent a flash of chrome on the Personal dashboard and unhide it for admin routes. - chrome.setIsVisible(false); + if (this.config.host) chrome.setIsVisible(false); await this.getInitialData(http); const pluginData = this.getPluginData(); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss index a7c3926407ea0..a47f3712cbb64 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss +++ b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss @@ -1,6 +1,11 @@ -@import 'file_datavisualizer_view/index'; -@import 'results_view/index'; -@import 'analysis_summary/index'; @import 'about_panel/index'; -@import 'import_summary/index'; +@import 'analysis_summary/index'; +@import 'edit_flyout/index'; +@import 'embedded_map/index'; @import 'experimental_badge/index'; +@import 'file_contents/index'; +@import 'file_datavisualizer_view/index'; +@import 'import_summary/index'; +@import 'results_view/index'; +@import 'stats_table/index'; +@import 'top_values/top_values'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx index a5d05bb06f78e..c2b7e18059769 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -104,7 +104,7 @@ const Contents: FC<{ username: string | null; }> = ({ value, index, username }) => { return ( - +
= ({ } + data-test-subj="fileDataVisFilebeatConfigLink" title={ ; + getIndexNameFormComponent(): Promise>; importerFactory: typeof importerFactory; getMaxBytes: typeof getMaxBytes; getMaxBytesFormatted: typeof getMaxBytesFormatted; @@ -35,6 +37,13 @@ export async function getFileUploadComponent(): Promise< return fileUploadModules.JsonUploadAndParse; } +export async function getIndexNameFormComponent(): Promise< + React.ComponentType +> { + const fileUploadModules = await lazyLoadModules(); + return fileUploadModules.IndexNameForm; +} + export async function importerFactory( format: string, options: ImportFactoryOptions diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx index 7ac0685e57700..65866243a3e47 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -6,16 +6,12 @@ */ import React, { ChangeEvent, Component } from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText, EuiSelect, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; -import { - getExistingIndexNames, - getExistingIndexPatternNames, - checkIndexPatternValid, - // @ts-expect-error -} from '../../util/indexing_service'; +import { IndexNameForm } from './index_name_form'; +import { validateIndexName } from '../../util/indexing_service'; const GEO_FIELD_TYPE_OPTIONS = [ { @@ -41,38 +37,15 @@ interface Props { interface State { hasFile: boolean; isPointsOnly: boolean; - indexNames: string[]; } export class GeoJsonUploadForm extends Component { - private _isMounted = false; - state: State = { hasFile: false, isPointsOnly: false, - indexNames: [], }; - async componentDidMount() { - this._isMounted = true; - this._loadIndexNames(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - _loadIndexNames = async () => { - const indexNameList = await getExistingIndexNames(); - const indexPatternList = await getExistingIndexPatternNames(); - if (this._isMounted) { - this.setState({ - indexNames: [...indexNameList, ...indexPatternList], - }); - } - }; - - _onFileSelect = (onFileSelectParameters: OnFileSelectParameters) => { + _onFileSelect = async (onFileSelectParameters: OnFileSelectParameters) => { this.setState({ hasFile: true, isPointsOnly: onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes, @@ -80,7 +53,8 @@ export class GeoJsonUploadForm extends Component { this.props.onFileSelect(onFileSelectParameters); - this._onIndexNameChange(onFileSelectParameters.indexName); + const indexNameError = await validateIndexName(onFileSelectParameters.indexName); + this.props.onIndexNameChange(onFileSelectParameters.indexName, indexNameError); const geoFieldType = onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes @@ -97,7 +71,7 @@ export class GeoJsonUploadForm extends Component { this.props.onFileClear(); - this._onIndexNameChange(''); + this.props.onIndexNameChange(''); }; _onGeoFieldTypeSelect = (event: ChangeEvent) => { @@ -106,28 +80,6 @@ export class GeoJsonUploadForm extends Component { ); }; - _onIndexNameChange = (name: string) => { - let error: string | undefined; - if (this.state.indexNames.includes(name)) { - error = i18n.translate('xpack.fileUpload.indexSettings.indexNameAlreadyExistsErrorMessage', { - defaultMessage: 'Index name already exists.', - }); - } else if (!checkIndexPatternValid(name)) { - error = i18n.translate( - 'xpack.fileUpload.indexSettings.indexNameContainsIllegalCharactersErrorMessage', - { - defaultMessage: 'Index name contains illegal characters.', - } - ); - } - - this.props.onIndexNameChange(name, error); - }; - - _onIndexNameChangeEvent = (event: ChangeEvent) => { - this._onIndexNameChange(event.target.value); - }; - _renderGeoFieldTypeSelect() { return this.state.hasFile && this.state.isPointsOnly ? ( { ) : null; } - _renderIndexNameInput() { - const isInvalid = this.props.indexNameError !== undefined; - return this.state.hasFile ? ( - <> - - - - - -
    -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex', { - defaultMessage: 'Must be a new index', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.lowercaseOnly', { - defaultMessage: 'Lowercase only', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotInclude', { - defaultMessage: - 'Cannot include \\\\, /, *, ?, ", <, >, |, \ - " " (space character), , (comma), #', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotStartWith', { - defaultMessage: 'Cannot start with -, _, +', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotBe', { - defaultMessage: 'Cannot be . or ..', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.length', { - defaultMessage: - 'Cannot be longer than 255 bytes (note it is bytes, \ - so multi-byte characters will count towards the 255 \ - limit faster)', - })} -
  • -
-
- - ) : null; - } - render() { return ( {this._renderGeoFieldTypeSelect()} - {this._renderIndexNameInput()} + {this.state.hasFile ? ( + + ) : null} ); } diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx new file mode 100644 index 0000000000000..a6e83cfa6f3ab --- /dev/null +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx @@ -0,0 +1,96 @@ +/* + * 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 React, { ChangeEvent, Component } from 'react'; +import { EuiFormRow, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { validateIndexName } from '../../util/indexing_service'; + +export interface Props { + indexName: string; + indexNameError?: string; + onIndexNameChange: (name: string, error?: string) => void; +} + +export class IndexNameForm extends Component { + _onIndexNameChange = async (event: ChangeEvent) => { + const indexName = event.target.value; + const indexNameError = await validateIndexName(indexName); + this.props.onIndexNameChange(indexName, indexNameError); + }; + + render() { + const errors = [...(this.props.indexNameError ? [this.props.indexNameError] : [])]; + + return ( + <> + + + + + +
    +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.mustBeNewIndex', { + defaultMessage: 'Must be a new index', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.lowercaseOnly', { + defaultMessage: 'Lowercase only', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.cannotInclude', { + defaultMessage: + 'Cannot include \\\\, /, *, ?, ", <, >, |, \ + " " (space character), , (comma), #', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.cannotStartWith', { + defaultMessage: 'Cannot start with -, _, +', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.cannotBe', { + defaultMessage: 'Cannot be . or ..', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.length', { + defaultMessage: + 'Cannot be longer than 255 bytes (note it is bytes, \ + so multi-byte characters will count towards the 255 \ + limit faster)', + })} +
  • +
+
+ + ); + } +} diff --git a/x-pack/plugins/file_upload/public/index.ts b/x-pack/plugins/file_upload/public/index.ts index 792568e9c11ad..262e399242291 100644 --- a/x-pack/plugins/file_upload/public/index.ts +++ b/x-pack/plugins/file_upload/public/index.ts @@ -13,5 +13,7 @@ export function plugin() { export * from './importer/types'; +export { Props as IndexNameFormProps } from './components/geojson_upload_form/index_name_form'; + export { FileUploadPluginStart } from './plugin'; export { FileUploadComponentProps, FileUploadGeoResults } from './lazy_load_bundle'; diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index 9d89b6b761e25..c2bc36e3cc450 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -11,6 +11,7 @@ import { HttpStart } from 'src/core/public'; import { IImporter, ImportFactoryOptions } from '../importer'; import { getHttp } from '../kibana_services'; import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; +import { IndexNameFormProps } from '../'; export interface FileUploadGeoResults { indexPatternId: string; @@ -32,6 +33,7 @@ let loadModulesPromise: Promise; interface LazyLoadedFileUploadModules { JsonUploadAndParse: React.ComponentType; + IndexNameForm: React.ComponentType; importerFactory: (format: string, options: ImportFactoryOptions) => IImporter | undefined; getHttp: () => HttpStart; } @@ -42,12 +44,13 @@ export async function lazyLoadModules(): Promise { } loadModulesPromise = new Promise(async (resolve) => { - const { JsonUploadAndParse, importerFactory } = await import('./lazy'); + const { JsonUploadAndParse, importerFactory, IndexNameForm } = await import('./lazy'); resolve({ JsonUploadAndParse, importerFactory, getHttp, + IndexNameForm, }); }); return loadModulesPromise; diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts index 0a28e9e4dfc93..85333227a36d8 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts @@ -6,4 +6,5 @@ */ export { JsonUploadAndParse } from '../../components/json_upload_and_parse'; +export { IndexNameForm } from '../../components/geojson_upload_form/index_name_form'; export { importerFactory } from '../../importer'; diff --git a/x-pack/plugins/file_upload/public/plugin.ts b/x-pack/plugins/file_upload/public/plugin.ts index 19306fadfd61c..6240dbe39a85e 100644 --- a/x-pack/plugins/file_upload/public/plugin.ts +++ b/x-pack/plugins/file_upload/public/plugin.ts @@ -11,6 +11,7 @@ import { getFileUploadComponent, importerFactory, hasImportPermission, + getIndexNameFormComponent, checkIndexExists, getTimeFieldRange, analyzeFile, @@ -42,6 +43,7 @@ export class FileUploadPlugin setStartServices(core, plugins); return { getFileUploadComponent, + getIndexNameFormComponent, importerFactory, getMaxBytes, getMaxBytesFormatted, diff --git a/x-pack/plugins/file_upload/public/util/http_service.js b/x-pack/plugins/file_upload/public/util/http_service.js deleted file mode 100644 index 33afebc514c36..0000000000000 --- a/x-pack/plugins/file_upload/public/util/http_service.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { getHttp } from '../kibana_services'; - -export async function http(options) { - if (!(options && options.url)) { - throw i18n.translate('xpack.fileUpload.httpService.noUrl', { - defaultMessage: 'No URL provided', - }); - } - const url = options.url || ''; - const headers = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - const allHeaders = options.headers === undefined ? headers : { ...options.headers, ...headers }; - const body = options.data === undefined ? null : JSON.stringify(options.data); - - const payload = { - method: options.method || 'GET', - headers: allHeaders, - credentials: 'same-origin', - query: options.query, - }; - - if (body !== null) { - payload.body = body; - } - return await doFetch(url, payload); -} - -async function doFetch(url, payload) { - try { - return await getHttp().fetch(url, payload); - } catch (err) { - return { - failures: [ - i18n.translate('xpack.fileUpload.httpService.fetchError', { - defaultMessage: 'Error performing fetch: {error}', - values: { error: err.message }, - }), - ], - }; - } -} diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.js b/x-pack/plugins/file_upload/public/util/indexing_service.js deleted file mode 100644 index cb9bc9a2e1ce6..0000000000000 --- a/x-pack/plugins/file_upload/public/util/indexing_service.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { http as httpService } from './http_service'; -import { getSavedObjectsClient } from '../kibana_services'; - -export const getExistingIndexNames = async () => { - const indexes = await httpService({ - url: `/api/index_management/indices`, - method: 'GET', - }); - return indexes ? indexes.map(({ name }) => name) : []; -}; - -export const getExistingIndexPatternNames = async () => { - const indexPatterns = await getSavedObjectsClient() - .find({ - type: 'index-pattern', - fields: ['id', 'title', 'type', 'fields'], - perPage: 10000, - }) - .then(({ savedObjects }) => savedObjects.map((savedObject) => savedObject.get('title'))); - return indexPatterns ? indexPatterns.map(({ name }) => name) : []; -}; - -export function checkIndexPatternValid(name) { - const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; - const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); - const indexPatternInvalid = - byteLength > 255 || // name can't be greater than 255 bytes - name !== name.toLowerCase() || // name should be lowercase - name === '.' || - name === '..' || // name can't be . or .. - name.match(/^[-_+]/) !== null || // name can't start with these chars - name.match(reg) !== null; // name can't contain these chars - return !indexPatternInvalid; -} diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.test.js b/x-pack/plugins/file_upload/public/util/indexing_service.test.ts similarity index 100% rename from x-pack/plugins/file_upload/public/util/indexing_service.test.js rename to x-pack/plugins/file_upload/public/util/indexing_service.test.ts diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.ts b/x-pack/plugins/file_upload/public/util/indexing_service.ts new file mode 100644 index 0000000000000..4dcff3dbe7f0e --- /dev/null +++ b/x-pack/plugins/file_upload/public/util/indexing_service.ts @@ -0,0 +1,73 @@ +/* + * 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 _ from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { getIndexPatternService, getHttp } from '../kibana_services'; + +export const getExistingIndexNames = _.debounce( + async () => { + let indexes; + try { + indexes = await getHttp().fetch({ + path: `/api/index_management/indices`, + method: 'GET', + }); + } catch (e) { + // Log to console. Further diagnostics can be made in network request + // eslint-disable-next-line no-console + console.error(e); + } + return indexes ? indexes.map(({ name }: { name: string }) => name) : []; + }, + 10000, + { leading: true } +); + +export function checkIndexPatternValid(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name === '.' || + name === '..' || // name can't be . or .. + name.match(/^[-_+]/) !== null || // name can't start with these chars + name.match(reg) !== null; // name can't contain these chars + return !indexPatternInvalid; +} + +export const validateIndexName = async (indexName: string) => { + if (!checkIndexPatternValid(indexName)) { + return i18n.translate( + 'xpack.fileUpload.util.indexingService.indexNameContainsIllegalCharactersErrorMessage', + { + defaultMessage: 'Index name contains illegal characters.', + } + ); + } + + const indexNames = await getExistingIndexNames(); + const indexPatternNames = await getIndexPatternService().getTitles(); + let indexNameError; + if (indexNames.includes(indexName)) { + indexNameError = i18n.translate( + 'xpack.fileUpload.util.indexingService.indexNameAlreadyExistsErrorMessage', + { + defaultMessage: 'Index name already exists.', + } + ); + } else if (indexPatternNames.includes(indexName)) { + indexNameError = i18n.translate( + 'xpack.fileUpload.util.indexingService.indexPatternAlreadyExistsErrorMessage', + { + defaultMessage: 'Index pattern already exists.', + } + ); + } + return indexNameError; +}; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 439d00695a737..a8e1f6ce584d4 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -21,7 +21,7 @@ export interface NewAgentPolicy { is_default_fleet_server?: boolean; // Optional when creating a policy is_managed?: boolean; // Optional when creating a policy monitoring_enabled?: Array>; - preconfiguration_id?: string; // Uniqifies preconfigured policies by something other than `name` + is_preconfigured?: boolean; } export interface AgentPolicy extends NewAgentPolicy { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx index 0d4f067771be0..7a7e42b9d634f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx @@ -91,6 +91,27 @@ export const ManualInstructions: React.FunctionComponent = ({ }} /> + + + + + + ), + }} + /> + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx index e7fd1da394bb3..cb0b02527f756 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx @@ -14,18 +14,7 @@ import { usePackageIconType } from '../hooks'; export const PackageIcon: React.FunctionComponent< UsePackageIconType & Omit -> = ({ size = 's', packageName, version, icons, tryApi, ...euiIconProps }) => { +> = ({ packageName, version, icons, tryApi, ...euiIconProps }) => { const iconType = usePackageIconType({ packageName, version, icons, tryApi }); - return ( - - // this collides with some EuiText (+img) CSS from the EuiIcon component - // which makes the button large, wide, and poorly layed out - // override those styles until the bug is fixed or we find a better approach - style={{ margin: 'unset', width: 'unset' }} - size={size} - type={iconType} - {...euiIconProps} - /> - ); + return ; }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.test.tsx new file mode 100644 index 0000000000000..e4c5840fd5f62 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { getInstallCommandForPlatform } from './fleet_server_requirement_page'; + +describe('getInstallCommandForPlatform', () => { + describe('without policy id', () => { + it('should return the correct command if the the policyId is not set for linux-mac', () => { + const res = getInstallCommandForPlatform( + 'linux-mac', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo ./elastic-agent install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); + + it('should return the correct command if the the policyId is not set for windows', () => { + const res = getInstallCommandForPlatform( + 'windows', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `".\\\\elastic-agent.exe install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); + + it('should return the correct command if the the policyId is not set for rpm-deb', () => { + const res = getInstallCommandForPlatform( + 'rpm-deb', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo elastic-agent enroll -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); + }); + + describe('with policy id', () => { + it('should return the correct command if the the policyId is set for linux-mac', () => { + const res = getInstallCommandForPlatform( + 'linux-mac', + 'http://elasticsearch:9200', + 'service-token-1', + 'policy-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo ./elastic-agent install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1 --fleet-server-policy=policy-1"` + ); + }); + + it('should return the correct command if the the policyId is set for windows', () => { + const res = getInstallCommandForPlatform( + 'windows', + 'http://elasticsearch:9200', + 'service-token-1', + 'policy-1' + ); + + expect(res).toMatchInlineSnapshot( + `".\\\\elastic-agent.exe install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1 --fleet-server-policy=policy-1"` + ); + }); + + it('should return the correct command if the the policyId is set for rpm-deb', () => { + const res = getInstallCommandForPlatform( + 'rpm-deb', + 'http://elasticsearch:9200', + 'service-token-1', + 'policy-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo elastic-agent enroll -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1 --fleet-server-policy=policy-1"` + ); + }); + }); + + it('should return nothing for an invalid platform', () => { + const res = getInstallCommandForPlatform( + 'rpm-deb', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo elastic-agent enroll -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx index 2e37d9efc7857..3be5d864e80c8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx @@ -184,12 +184,55 @@ export const FleetServerCommandStep = ({ > {installCommand} + + + + + + ), + }} + /> + ) : null, }; }; -export const useFleetServerInstructions = () => { +export function getInstallCommandForPlatform( + platform: PLATFORM_TYPE, + esHost: string, + serviceToken: string, + policyId?: string +) { + const commandArguments = `-f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}${ + policyId ? ` --fleet-server-policy=${policyId}` : '' + }`; + + switch (platform) { + case 'linux-mac': + return `sudo ./elastic-agent install ${commandArguments}`; + case 'windows': + return `.\\elastic-agent.exe install ${commandArguments}`; + case 'rpm-deb': + return `sudo elastic-agent enroll ${commandArguments}`; + default: + return ''; + } +} + +export const useFleetServerInstructions = (policyId?: string) => { const outputsRequest = useGetOutputs(); const { notifications } = useStartServices(); const [serviceToken, setServiceToken] = useState(); @@ -203,17 +246,9 @@ export const useFleetServerInstructions = () => { if (!serviceToken || !esHost) { return ''; } - switch (platform) { - case 'linux-mac': - return `sudo ./elastic-agent install -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; - case 'windows': - return `.\\elastic-agent.exe install --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; - case 'rpm-deb': - return `sudo elastic-agent enroll -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; - default: - return ''; - } - }, [serviceToken, esHost, platform]); + + return getInstallCommandForPlatform(platform, esHost, serviceToken, policyId); + }, [serviceToken, esHost, platform, policyId]); const getServiceToken = useCallback(async () => { setIsLoadingServiceToken(true); @@ -334,7 +369,7 @@ const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploymentUrl fill isLoading={false} type="submit" - href={deploymentUrl} + href={`${deploymentUrl}/edit`} target="_blank" > (({ agentPolicies }) => { const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); const settings = useGetSettings(); - const fleetServerInstructions = useFleetServerInstructions(); + const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); const steps = useMemo(() => { const { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx index dcc87b0032d77..cff0dc55515c4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx @@ -71,7 +71,18 @@ export const AgentPolicyPackageBadges: React.FunctionComponent = ({ - + + // this collides with some EuiText (+img) CSS from the EuiIcon component + // which makes the button large, wide, and poorly layed out + // override those styles until the bug is fixed or we find a better approach + { margin: 'unset', width: '16px' } + } + /> {pkg.title} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx deleted file mode 100644 index 63c6897021f4e..0000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { EuiIcon, EuiPanel } from '@elastic/eui'; - -import type { UsePackageIconType } from '../../../hooks'; -import { usePackageIconType } from '../../../hooks'; -import { Loading } from '../../../components'; - -const PanelWrapper = styled.div` - // NOTE: changes to the width here will impact navigation tabs page layout under integration package details - width: ${(props) => - parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px; - height: 1px; - z-index: 1; -`; - -const Panel = styled(EuiPanel)` - padding: ${(props) => props.theme.eui.spacerSizes.xl}; - margin-bottom: -100%; - svg, - img { - height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; - width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; - } - .euiFlexItem { - height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; - justify-content: center; - } -`; - -export function IconPanel({ - packageName, - version, - icons, -}: Pick) { - const iconType = usePackageIconType({ packageName, version, icons }); - - return ( - - - - - - ); -} - -export function LoadingIconPanel() { - return ( - - - - - - ); -} diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index f55de4b691999..f3cfc76ca5a76 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -177,7 +177,7 @@ const getSavedObjectTypes = ( updated_by: { type: 'keyword' }, revision: { type: 'integer' }, monitoring_enabled: { type: 'keyword', index: false }, - preconfiguration_id: { type: 'keyword' }, + is_preconfigured: { type: 'keyword' }, }, }, migrations: { @@ -366,7 +366,7 @@ const getSavedObjectTypes = ( }, mappings: { properties: { - preconfiguration_id: { type: 'keyword' }, + id: { type: 'keyword' }, }, }, }, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2b9cc4e072304..b575c1de1616d 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -14,6 +14,8 @@ import type { SavedObjectsBulkUpdateResponse, } from 'src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + import type { AuthenticatedUser } from '../../../security/server'; import { AGENT_POLICY_SAVED_OBJECT_TYPE, @@ -113,25 +115,22 @@ class AgentPolicyService { policy?: AgentPolicy; }> { const { id, ...preconfiguredAgentPolicy } = omit(config, 'package_policies'); - const newAgentPolicyDefaults: Partial = { + const newAgentPolicyDefaults: Pick = { namespace: 'default', monitoring_enabled: ['logs', 'metrics'], }; + const newAgentPolicy: NewAgentPolicy = { + ...newAgentPolicyDefaults, + ...preconfiguredAgentPolicy, + is_preconfigured: true, + }; + let searchParams; - let newAgentPolicy; if (id) { - const preconfigurationId = String(id); searchParams = { - searchFields: ['preconfiguration_id'], - search: escapeSearchQueryPhrase(preconfigurationId), + id: String(id), }; - - newAgentPolicy = { - ...newAgentPolicyDefaults, - ...preconfiguredAgentPolicy, - preconfiguration_id: preconfigurationId, - } as NewAgentPolicy; } else if ( preconfiguredAgentPolicy.is_default || preconfiguredAgentPolicy.is_default_fleet_server @@ -144,13 +143,8 @@ class AgentPolicyService { ], search: 'true', }; - - newAgentPolicy = { - ...newAgentPolicyDefaults, - ...preconfiguredAgentPolicy, - } as NewAgentPolicy; } - if (!newAgentPolicy || !searchParams) throw new Error('Missing ID'); + if (!searchParams) throw new Error('Missing ID'); return await this.ensureAgentPolicy(soClient, esClient, newAgentPolicy, searchParams); } @@ -159,14 +153,41 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, newAgentPolicy: NewAgentPolicy, - searchParams: { - searchFields: string[]; - search: string; - } + searchParams: + | { id: string } + | { + searchFields: string[]; + search: string; + } ): Promise<{ created: boolean; policy: AgentPolicy; }> { + // For preconfigured policies with a specified ID + if ('id' in searchParams) { + try { + const agentPolicy = await soClient.get( + AGENT_POLICY_SAVED_OBJECT_TYPE, + searchParams.id + ); + return { + created: false, + policy: { + id: agentPolicy.id, + ...agentPolicy.attributes, + }, + }; + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + return { + created: true, + policy: await this.create(soClient, esClient, newAgentPolicy, { id: searchParams.id }), + }; + } else throw e; + } + } + + // For default policies without a specified ID const agentPolicies = await soClient.find({ type: AGENT_POLICY_SAVED_OBJECT_TYPE, ...searchParams, @@ -571,9 +592,9 @@ class AgentPolicyService { ); } - if (agentPolicy.preconfiguration_id) { + if (agentPolicy.is_preconfigured) { await soClient.create(PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, { - preconfiguration_id: String(agentPolicy.preconfiguration_id), + id: String(id), }); } diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index f7a4c6d9e670f..9b3e9b7a57369 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -7,6 +7,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + import type { PreconfiguredAgentPolicy } from '../../common/types'; import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; @@ -32,12 +34,13 @@ function getPutPreconfiguredPackagesMock() { const soClient = savedObjectsClientMock.create(); soClient.find.mockImplementation(async ({ type, search }) => { if (type === AGENT_POLICY_SAVED_OBJECT_TYPE) { - const attributes = mockConfiguredPolicies.get(search!.replace(/"/g, '')); + const id = search!.replace(/"/g, ''); + const attributes = mockConfiguredPolicies.get(id); if (attributes) { return { saved_objects: [ { - id: `mocked-${attributes.preconfiguration_id}`, + id: `mocked-${id}`, attributes, type: type as string, score: 1, @@ -57,11 +60,22 @@ function getPutPreconfiguredPackagesMock() { per_page: 0, }; }); - soClient.create.mockImplementation(async (type, policy) => { + soClient.get.mockImplementation(async (type, id) => { + const attributes = mockConfiguredPolicies.get(id); + if (!attributes) throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + return { + id: `mocked-${id}`, + attributes, + type: type as string, + references: [], + }; + }); + soClient.create.mockImplementation(async (type, policy, options) => { const attributes = policy as AgentPolicy; - mockConfiguredPolicies.set(attributes.preconfiguration_id, attributes); + const { id } = options!; + mockConfiguredPolicies.set(id, attributes); return { - id: `mocked-${attributes.preconfiguration_id}`, + id: `mocked-${id}`, attributes, type, references: [], diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 77230c01cdcb8..308abece9f4f5 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -107,10 +107,10 @@ export async function ensurePreconfiguredPackagesAndPolicies( const preconfiguredPolicies = await Promise.allSettled( policies.map(async (preconfiguredAgentPolicy) => { if (preconfiguredAgentPolicy.id) { - // Check to see if a preconfigured policy with the same preconfigurationId was already deleted by the user + // Check to see if a preconfigured policy with the same preconfiguration id was already deleted by the user const preconfigurationId = String(preconfiguredAgentPolicy.id); const searchParams = { - searchFields: ['preconfiguration_id'], + searchFields: ['id'], search: escapeSearchQueryPhrase(preconfigurationId), }; const deletionRecords = await soClient.find({ diff --git a/x-pack/plugins/fleet/server/services/settings.test.ts b/x-pack/plugins/fleet/server/services/settings.test.ts new file mode 100644 index 0000000000000..87b3e163c1bb3 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/settings.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { appContextService } from './app_context'; +import { getCloudFleetServersHosts } from './settings'; + +jest.mock('./app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +describe('getCloudFleetServersHosts', () => { + it('should return undefined if cloud is not setup', () => { + expect(getCloudFleetServersHosts()).toBeUndefined(); + }); + + it('should return fleet server hosts if cloud is correctly setup with default port == 443', () => { + mockedAppContextService.getCloud.mockReturnValue({ + cloudId: + 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', + isCloudEnabled: true, + deploymentId: 'deployment-id-1', + apm: {}, + }); + + expect(getCloudFleetServersHosts()).toMatchInlineSnapshot(` + Array [ + "https://deployment-id-1.fleet.us-east-1.aws.found.io", + ] + `); + }); + + it('should return fleet server hosts if cloud is correctly setup with a default port', () => { + mockedAppContextService.getCloud.mockReturnValue({ + cloudId: + 'test:dGVzdC5mcjo5MjQzJGRhM2I2YjNkYWY5ZDRjODE4ZjI4ZmEzNDdjMzgzODViJDgxMmY4NWMxZjNjZTQ2YTliYjgxZjFjMWIxMzRjNmRl', + isCloudEnabled: true, + deploymentId: 'deployment-id-1', + apm: {}, + }); + + expect(getCloudFleetServersHosts()).toMatchInlineSnapshot(` + Array [ + "https://deployment-id-1.fleet.test.fr:9243", + ] + `); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index e0723a8e16306..2046e2571c926 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; import type { SavedObjectsClientContract } from 'kibana/server'; -import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common'; +import { decodeCloudId, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common'; import type { SettingsSOAttributes, Settings, BaseSettings } from '../../common'; import { appContextService } from './app_context'; @@ -65,9 +65,29 @@ export async function saveSettings( } export function createDefaultSettings(): BaseSettings { - const fleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts ?? []; + const configFleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts; + const cloudFleetServerHosts = getCloudFleetServersHosts(); + + const fleetServerHosts = configFleetServerHosts ?? cloudFleetServerHosts ?? []; return { fleet_server_hosts: fleetServerHosts, }; } + +export function getCloudFleetServersHosts() { + const cloudSetup = appContextService.getCloud(); + if (cloudSetup && cloudSetup.isCloudEnabled && cloudSetup.cloudId && cloudSetup.deploymentId) { + const res = decodeCloudId(cloudSetup.cloudId); + if (!res) { + return; + } + + // Fleet Server url are formed like this `https://.fleet. + return [ + `https://${cloudSetup.deploymentId}.fleet.${res.host}${ + res.defaultPort !== '443' ? `:${res.defaultPort}` : '' + }`, + ]; + } +} diff --git a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx index 44f565f98cdb0..4bd9a01380c0e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFilterButton, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; interface Filter { name: string; @@ -65,26 +65,28 @@ export function FilterListButton({ onChange, filters }: Props< ); return ( - -
- {Object.entries(filters).map(([filter, item], index) => ( - toggleFilter(filter as T)} - data-test-subj="filterItem" - > - {(item as Filter).name} - - ))} -
-
+ + +
+ {Object.entries(filters).map(([filter, item], index) => ( + toggleFilter(filter as T)} + data-test-subj="filterItem" + > + {(item as Filter).name} + + ))} +
+
+
); } diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index a481a3897789e..5023f9d5d5fd4 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -7,7 +7,7 @@ import React, { useMemo, useCallback, useEffect } from 'react'; import { noop } from 'lodash'; -import type { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { DataPublicPluginStart, esQuery, Filter } from '../../../../../../src/plugins/data/public'; import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { LogEntryCursor } from '../../../common/log_entry'; @@ -19,6 +19,7 @@ import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration'; import { JsonValue } from '../../../../../../src/plugins/kibana_utils/common'; import { Query } from '../../../../../../src/plugins/data/common'; +import { LogStreamErrorBoundary } from './log_stream_error_boundary'; interface LogStreamPluginDeps { data: DataPublicPluginStart; @@ -57,25 +58,39 @@ type LogColumnDefinition = | MessageColumnDefinition | FieldColumnDefinition; -export interface LogStreamProps { +export interface LogStreamProps extends LogStreamContentProps { + height?: string | number; +} + +interface LogStreamContentProps { sourceId?: string; startTimestamp: number; endTimestamp: number; query?: string | Query | BuiltEsQuery; + filters?: Filter[]; center?: LogEntryCursor; highlight?: string; - height?: string | number; columns?: LogColumnDefinition[]; } -export const LogStream: React.FC = ({ +export const LogStream: React.FC = ({ height = 400, ...contentProps }) => { + return ( + + + + + + ); +}; + +export const LogStreamContent: React.FC = ({ sourceId = 'default', startTimestamp, endTimestamp, query, + filters, center, highlight, - height = '400px', columns, }) => { const customColumns = useMemo( @@ -99,12 +114,21 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re sourceConfiguration, loadSourceConfiguration, isLoadingSourceConfiguration, + derivedIndexPattern, } = useLogSource({ sourceId, fetch: services.http.fetch, indexPatternsService: services.data.indexPatterns, }); + const parsedQuery = useMemo(() => { + if (typeof query === 'object' && 'bool' in query) { + return mergeBoolQueries(query, esQuery.buildEsQuery(derivedIndexPattern, [], filters ?? [])); + } else { + return esQuery.buildEsQuery(derivedIndexPattern, coerceToQueries(query), filters ?? []); + } + }, [derivedIndexPattern, filters, query]); + // Internal state const { entries, @@ -119,7 +143,7 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re sourceId, startTimestamp, endTimestamp, - query, + query: parsedQuery, center, columns: customColumns, }); @@ -138,8 +162,6 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re [entries] ); - const parsedHeight = typeof height === 'number' ? `${height}px` : height; - // Component lifetime useEffect(() => { loadSourceConfiguration(); @@ -170,37 +192,34 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re ); return ( - - - + ); }; -const LogStreamContent = euiStyled.div<{ height: string }>` +const LogStreamContainer = euiStyled.div` display: flex; background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - height: ${(props) => props.height}; `; function convertLogColumnDefinitionToLogSourceColumnDefinition( @@ -227,6 +246,27 @@ function convertLogColumnDefinitionToLogSourceColumnDefinition( }); } +const mergeBoolQueries = (firstQuery: BuiltEsQuery, secondQuery: BuiltEsQuery): BuiltEsQuery => ({ + bool: { + must: [...firstQuery.bool.must, ...secondQuery.bool.must], + filter: [...firstQuery.bool.filter, ...secondQuery.bool.filter], + should: [...firstQuery.bool.should, ...secondQuery.bool.should], + must_not: [...firstQuery.bool.must_not, ...secondQuery.bool.must_not], + }, +}); + +const coerceToQueries = (value: undefined | string | Query): Query[] => { + if (value == null) { + return []; + } else if (typeof value === 'string') { + return [{ language: 'kuery', query: value }]; + } else if ('language' in value && 'query' in value) { + return [value]; + } + + return []; +}; + // Allow for lazy loading // eslint-disable-next-line import/no-default-export export default LogStream; diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx index 1d9edd7289236..e3fc4ca1de565 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx @@ -9,7 +9,7 @@ import { CoreStart } from 'kibana/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { Subscription } from 'rxjs'; -import { Query, TimeRange, esQuery, Filter } from '../../../../../../src/plugins/data/public'; +import { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; import { Embeddable, EmbeddableInput, @@ -69,8 +69,6 @@ export class LogStreamEmbeddable extends Embeddable { return; } - const parsedQuery = esQuery.buildEsQuery(undefined, this.input.query, this.input.filters); - const startTimestamp = datemathToEpochMillis(this.input.timeRange.from); const endTimestamp = datemathToEpochMillis(this.input.timeRange.to, 'up'); @@ -86,7 +84,8 @@ export class LogStreamEmbeddable extends Embeddable { startTimestamp={startTimestamp} endTimestamp={endTimestamp} height="100%" - query={parsedQuery} + query={this.input.query} + filters={this.input.filters} />
diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_error_boundary.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_error_boundary.tsx new file mode 100644 index 0000000000000..c55e6d299127b --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_error_boundary.tsx @@ -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 { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { KQLSyntaxError } from '../../../../../../src/plugins/data/common'; +import { RenderErrorFunc, ResettableErrorBoundary } from '../resettable_error_boundary'; + +export const LogStreamErrorBoundary: React.FC<{ resetOnChange: any }> = ({ + children, + resetOnChange = null, +}) => { + return ( + + {children} + + ); +}; + +const LogStreamErrorContent: React.FC<{ + error: any; +}> = ({ error }) => { + if (error instanceof KQLSyntaxError) { + return ( + + } + body={{error.message}} + /> + ); + } else { + return ( + + } + body={{error.message}} + /> + ); + } +}; + +const renderLogStreamErrorContent: RenderErrorFunc = ({ latestError }) => ( + +); diff --git a/x-pack/plugins/infra/public/components/resettable_error_boundary.tsx b/x-pack/plugins/infra/public/components/resettable_error_boundary.tsx new file mode 100644 index 0000000000000..6e9dc178a4d84 --- /dev/null +++ b/x-pack/plugins/infra/public/components/resettable_error_boundary.tsx @@ -0,0 +1,72 @@ +/* + * 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 equal from 'fast-deep-equal'; +import React from 'react'; + +export interface RenderErrorFuncArgs { + latestError: any; + resetError: () => void; +} + +export type RenderErrorFunc = (renderErrorArgs: RenderErrorFuncArgs) => React.ReactNode; + +interface ResettableErrorBoundaryProps { + renderError: RenderErrorFunc; + resetOnChange: ResetOnChange; +} + +interface ResettableErrorBoundaryState { + latestError: any; +} + +export class ResettableErrorBoundary extends React.Component< + ResettableErrorBoundaryProps, + ResettableErrorBoundaryState +> { + state: ResettableErrorBoundaryState = { + latestError: undefined, + }; + + componentDidUpdate({ + resetOnChange: prevResetOnChange, + }: ResettableErrorBoundaryProps) { + const { resetOnChange } = this.props; + const { latestError } = this.state; + + if (latestError != null && !equal(resetOnChange, prevResetOnChange)) { + this.resetError(); + } + } + + static getDerivedStateFromError(error: any) { + return { + latestError: error, + }; + } + + render() { + const { children, renderError } = this.props; + const { latestError } = this.state; + + if (latestError != null) { + return renderError({ + latestError, + resetError: this.resetError, + }); + } + + return children; + } + + resetError = () => { + this.setState((previousState) => ({ + ...previousState, + latestError: undefined, + })); + }; +} diff --git a/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts b/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts index 5eece62c683e1..6a78d7c6f94bc 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts @@ -5,95 +5,100 @@ * 2.0. */ -import { useState, useMemo } from 'react'; import createContainer from 'constate'; -import { IIndexPattern } from 'src/plugins/data/public'; -import { esKuery } from '../../../../../../../src/plugins/data/public'; -import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; +import { useCallback, useState } from 'react'; +import { useDebounce } from 'react-use'; +import { esQuery, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; -export interface KueryFilterQuery { - kind: 'kuery'; - expression: string; -} - -export interface SerializedFilterQuery { - query: KueryFilterQuery; - serializedQuery: string; -} +type ParsedQuery = ReturnType; -interface LogFilterInternalStateParams { - filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; +interface ILogFilterState { + filterQuery: { + parsedQuery: ParsedQuery; + serializedQuery: string; + originalQuery: Query; + } | null; + filterQueryDraft: Query; + validationErrors: string[]; } -export const logFilterInitialState: LogFilterInternalStateParams = { +const initialLogFilterState: ILogFilterState = { filterQuery: null, - filterQueryDraft: null, + filterQueryDraft: { + language: 'kuery', + query: '', + }, + validationErrors: [], }; -export type LogFilterStateParams = Omit & { - filterQuery: SerializedFilterQuery['serializedQuery'] | null; - filterQueryAsKuery: SerializedFilterQuery['query'] | null; - isFilterQueryDraftValid: boolean; -}; -export interface LogFilterCallbacks { - setLogFilterQueryDraft: (expression: string) => void; - applyLogFilterQuery: (expression: string) => void; -} +const validationDebounceTimeout = 1000; // milliseconds -export const useLogFilterState: (props: { - indexPattern: IIndexPattern; -}) => LogFilterStateParams & LogFilterCallbacks = ({ indexPattern }) => { - const [state, setState] = useState(logFilterInitialState); - const { filterQuery, filterQueryDraft } = state; +export const useLogFilterState = ({ indexPattern }: { indexPattern: IIndexPattern }) => { + const [logFilterState, setLogFilterState] = useState(initialLogFilterState); - const setLogFilterQueryDraft = useMemo(() => { - const setDraft = (payload: KueryFilterQuery) => - setState((prevState) => ({ ...prevState, filterQueryDraft: payload })); - return (expression: string) => - setDraft({ - kind: 'kuery', - expression, - }); + const parseQuery = useCallback( + (filterQuery: Query) => esQuery.buildEsQuery(indexPattern, filterQuery, []), + [indexPattern] + ); + + const setLogFilterQueryDraft = useCallback((filterQueryDraft: Query) => { + setLogFilterState((previousLogFilterState) => ({ + ...previousLogFilterState, + filterQueryDraft, + validationErrors: [], + })); }, []); - const applyLogFilterQuery = useMemo(() => { - const applyQuery = (payload: SerializedFilterQuery) => - setState((prevState) => ({ - ...prevState, - filterQueryDraft: payload.query, - filterQuery: payload, - })); - return (expression: string) => - applyQuery({ - query: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + + const [, cancelPendingValidation] = useDebounce( + () => { + setLogFilterState((previousLogFilterState) => { + try { + parseQuery(logFilterState.filterQueryDraft); + return { + ...previousLogFilterState, + validationErrors: [], + }; + } catch (error) { + return { + ...previousLogFilterState, + validationErrors: [`${error}`], + }; + } }); - }, [indexPattern]); + }, + validationDebounceTimeout, + [logFilterState.filterQueryDraft, parseQuery] + ); - const isFilterQueryDraftValid = useMemo(() => { - if (filterQueryDraft?.kind === 'kuery') { + const applyLogFilterQuery = useCallback( + (filterQuery: Query) => { + cancelPendingValidation(); try { - esKuery.fromKueryExpression(filterQueryDraft.expression); - } catch (err) { - return false; + const parsedQuery = parseQuery(filterQuery); + setLogFilterState((previousLogFilterState) => ({ + ...previousLogFilterState, + filterQuery: { + parsedQuery, + serializedQuery: JSON.stringify(parsedQuery), + originalQuery: filterQuery, + }, + filterQueryDraft: filterQuery, + validationErrors: [], + })); + } catch (error) { + setLogFilterState((previousLogFilterState) => ({ + ...previousLogFilterState, + validationErrors: [`${error}`], + })); } - } - - return true; - }, [filterQueryDraft]); - - const serializedFilterQuery = useMemo(() => (filterQuery ? filterQuery.serializedQuery : null), [ - filterQuery, - ]); + }, + [cancelPendingValidation, parseQuery] + ); return { - ...state, - filterQueryAsKuery: state.filterQuery ? state.filterQuery.query : null, - filterQuery: serializedFilterQuery, - isFilterQueryDraftValid, + filterQuery: logFilterState.filterQuery, + filterQueryDraft: logFilterState.filterQueryDraft, + isFilterQueryDraftValid: logFilterState.validationErrors.length === 0, setLogFilterQueryDraft, applyLogFilterQuery, }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx index 6bc71fc880434..f085a2c7d275b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_filter/with_log_filter_url_state.tsx @@ -5,43 +5,57 @@ * 2.0. */ +import * as rt from 'io-ts'; import React, { useContext } from 'react'; -import { LogFilterState, LogFilterStateParams } from './log_filter_state'; +import { Query } from '../../../../../../../src/plugins/data/public'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; - -type LogFilterUrlState = LogFilterStateParams['filterQueryAsKuery']; +import { LogFilterState } from './log_filter_state'; export const WithLogFilterUrlState: React.FC = () => { - const { filterQueryAsKuery, applyLogFilterQuery } = useContext(LogFilterState.Context); + const { filterQuery, applyLogFilterQuery } = useContext(LogFilterState.Context); + return ( { if (urlState) { - applyLogFilterQuery(urlState.expression); + applyLogFilterQuery(urlState); } }} onInitialize={(urlState) => { if (urlState) { - applyLogFilterQuery(urlState.expression); + applyLogFilterQuery(urlState); } }} /> ); }; -const mapToFilterQuery = (value: any): LogFilterUrlState | undefined => - value?.kind === 'kuery' && typeof value.expression === 'string' - ? { - kind: value.kind, - expression: value.expression, - } - : undefined; +const mapToFilterQuery = (value: any): Query | undefined => { + if (legacyFilterQueryUrlStateRT.is(value)) { + // migrate old url state + return { + language: value.kind, + query: value.expression, + }; + } else if (filterQueryUrlStateRT.is(value)) { + return value; + } else { + return undefined; + } +}; + +export const replaceLogFilterInQueryString = (query: Query) => + replaceStateKeyInQueryString('logFilter', query); + +const filterQueryUrlStateRT = rt.type({ + language: rt.string, + query: rt.string, +}); -export const replaceLogFilterInQueryString = (expression: string) => - replaceStateKeyInQueryString('logFilter', { - kind: 'kuery', - expression, - }); +const legacyFilterQueryUrlStateRT = rt.type({ + kind: rt.literal('kuery'), + expression: rt.string, +}); diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index cd68048e6c94f..021aa8f79fe59 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { useState, useCallback, useEffect, useMemo } from 'react'; import createContainer from 'constate'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import useSetState from 'react-use/lib/useSetState'; -import { esKuery, esQuery, Query } from '../../../../../../../src/plugins/data/public'; +import { esQuery } from '../../../../../../../src/plugins/data/public'; import { LogEntry, LogEntryCursor } from '../../../../common/log_entry'; import { useSubscription } from '../../../utils/use_observable'; import { LogSourceConfigurationProperties } from '../log_source'; @@ -23,7 +23,7 @@ interface LogStreamProps { sourceId: string; startTimestamp: number; endTimestamp: number; - query?: string | Query | BuiltEsQuery; + query?: BuiltEsQuery; center?: LogEntryCursor; columns?: LogSourceConfigurationProperties['logColumns']; } @@ -77,27 +77,15 @@ export function useLogStream({ } }, [prevEndTimestamp, endTimestamp, setState]); - const parsedQuery = useMemo(() => { - if (!query) { - return undefined; - } else if (typeof query === 'string') { - return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)); - } else if ('language' in query) { - return getEsQueryFromQueryObject(query); - } else { - return query; - } - }, [query]); - const commonFetchArguments = useMemo( () => ({ sourceId, startTimestamp, endTimestamp, - query: parsedQuery, + query, columnOverrides: columns, }), - [columns, endTimestamp, parsedQuery, sourceId, startTimestamp] + [columns, endTimestamp, query, sourceId, startTimestamp] ); const { @@ -268,13 +256,4 @@ export function useLogStream({ }; } -function getEsQueryFromQueryObject(query: Query) { - switch (query.language) { - case 'kuery': - return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)); - case 'lucene': - return esQuery.luceneStringToDsl(query.query as string); - } -} - export const [LogStreamProvider, useLogStreamContext] = createContainer(useLogStream); diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index cbd664729d5c2..9204c81816e83 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -7,12 +7,11 @@ import { useContext } from 'react'; import useThrottle from 'react-use/lib/useThrottle'; - import { RendererFunction } from '../../../utils/typed_react'; -import { LogSummaryBuckets, useLogSummary } from './log_summary'; import { LogFilterState } from '../log_filter'; import { LogPositionState } from '../log_position'; import { useLogSourceContext } from '../log_source'; +import { LogSummaryBuckets, useLogSummary } from './log_summary'; const FETCH_THROTTLE_INTERVAL = 3000; @@ -37,7 +36,7 @@ export const WithSummary = ({ sourceId, throttledStartTimestamp, throttledEndTimestamp, - filterQuery + filterQuery?.serializedQuery ?? null ); return children({ buckets, start, end }); diff --git a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx index 0d57f8dad1e72..91f42509d493a 100644 --- a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx @@ -66,7 +66,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'FILTER_FIELD:FILTER_VALUE',kind:kuery)"` + `"(language:kuery,query:'FILTER_FIELD:FILTER_VALUE')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -86,7 +86,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('OTHER_SOURCE'); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(expression:'',kind:kuery)"`); + expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(language:kuery,query:'')"`); expect(searchParams.get('logPosition')).toEqual(null); }); }); @@ -106,7 +106,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'FILTER_FIELD:FILTER_VALUE',kind:kuery)"` + `"(language:kuery,query:'FILTER_FIELD:FILTER_VALUE')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -126,7 +126,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('OTHER_SOURCE'); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(expression:'',kind:kuery)"`); + expect(searchParams.get('logFilter')).toMatchInlineSnapshot(`"(language:kuery,query:'')"`); expect(searchParams.get('logPosition')).toEqual(null); }); }); @@ -146,7 +146,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'HOST_FIELD: HOST_NAME',kind:kuery)"` + `"(language:kuery,query:'HOST_FIELD: HOST_NAME')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -167,7 +167,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'(HOST_FIELD: HOST_NAME) and (FILTER_FIELD:FILTER_VALUE)',kind:kuery)"` + `"(language:kuery,query:'(HOST_FIELD: HOST_NAME) and (FILTER_FIELD:FILTER_VALUE)')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -188,7 +188,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('OTHER_SOURCE'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'HOST_FIELD: HOST_NAME',kind:kuery)"` + `"(language:kuery,query:'HOST_FIELD: HOST_NAME')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -223,7 +223,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'CONTAINER_FIELD: CONTAINER_ID',kind:kuery)"` + `"(language:kuery,query:'CONTAINER_FIELD: CONTAINER_ID')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -244,7 +244,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'(CONTAINER_FIELD: CONTAINER_ID) and (FILTER_FIELD:FILTER_VALUE)',kind:kuery)"` + `"(language:kuery,query:'(CONTAINER_FIELD: CONTAINER_ID) and (FILTER_FIELD:FILTER_VALUE)')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` @@ -281,7 +281,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'POD_FIELD: POD_UID',kind:kuery)"` + `"(language:kuery,query:'POD_FIELD: POD_UID')"` ); expect(searchParams.get('logPosition')).toEqual(null); }); @@ -300,7 +300,7 @@ describe('LinkToLogsPage component', () => { const searchParams = new URLSearchParams(history.location.search); expect(searchParams.get('sourceId')).toEqual('default'); expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(expression:'(POD_FIELD: POD_UID) and (FILTER_FIELD:FILTER_VALUE)',kind:kuery)"` + `"(language:kuery,query:'(POD_FIELD: POD_UID) and (FILTER_FIELD:FILTER_VALUE)')"` ); expect(searchParams.get('logPosition')).toMatchInlineSnapshot( `"(end:'2019-02-20T14:58:09.404Z',position:(tiebreaker:0,time:1550671089404),start:'2019-02-20T12:58:09.404Z',streamLive:!f)"` diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx index a3e261a6bc280..39f276b982d76 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx @@ -20,7 +20,7 @@ describe('RedirectToLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -34,7 +34,7 @@ describe('RedirectToLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -46,7 +46,7 @@ describe('RedirectToLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx index 9606f343dbfdf..4d77077c19a99 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx @@ -26,7 +26,7 @@ export const RedirectToLogs = ({ location, match }: RedirectToLogsProps) => { const sourceId = match.params.sourceId || 'default'; const filter = getFilterFromLocation(location); const searchString = flowRight( - replaceLogFilterInQueryString(filter), + replaceLogFilterInQueryString({ language: 'kuery', query: filter }), replaceLogPositionInQueryString(getTimeFromLocation(location)), replaceSourceIdInQueryString(sourceId) )(''); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index 741fad5a5310e..0df8e639b149b 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -68,7 +68,7 @@ export const RedirectToNodeLogs = ({ const filter = userFilter ? `(${nodeFilter}) and (${userFilter})` : nodeFilter; const searchString = flowRight( - replaceLogFilterInQueryString(filter), + replaceLogFilterInQueryString({ language: 'kuery', query: filter }), replaceLogPositionInQueryString(getTimeFromLocation(location)), replaceSourceIdInQueryString(sourceId) )(''); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index d987cbeb439cc..27235295013e3 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -50,28 +50,30 @@ const LogEntriesStateProvider: React.FC = ({ children }) => { const { startTimestamp, endTimestamp, targetPosition, isInitialized } = useContext( LogPositionState.Context ); - const { filterQueryAsKuery } = useContext(LogFilterState.Context); + const { filterQuery } = useContext(LogFilterState.Context); // Don't render anything if the date range is incorrect. if (!startTimestamp || !endTimestamp) { return null; } - const logStreamProps = { - sourceId, - startTimestamp, - endTimestamp, - query: filterQueryAsKuery?.expression ?? undefined, - center: targetPosition ?? undefined, - }; - // Don't initialize the entries until the position has been fully intialized. // See `` if (!isInitialized) { return null; } - return {children}; + return ( + + {children} + + ); }; const LogHighlightsStateProvider: React.FC = ({ children }) => { @@ -86,7 +88,7 @@ const LogHighlightsStateProvider: React.FC = ({ children }) => { entriesEnd: bottomCursor, centerCursor: entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null, size: entries.length, - filterQuery, + filterQuery: filterQuery?.serializedQuery ?? null, }; return {children}; }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 971eb1b3e486f..fc37cd2e11c1b 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -62,25 +62,18 @@ export const LogsToolbar = () => { iconType="search" indexPatterns={[derivedIndexPattern]} isInvalid={!isFilterQueryDraftValid} - onChange={(expression: Query) => { - if (typeof expression.query === 'string') { - setSurroundingLogsId(null); - setLogFilterQueryDraft(expression.query); - } + onChange={(query: Query) => { + setSurroundingLogsId(null); + setLogFilterQueryDraft(query); }} - onSubmit={(expression: Query) => { - if (typeof expression.query === 'string') { - setSurroundingLogsId(null); - applyLogFilterQuery(expression.query); - } + onSubmit={(query: Query) => { + setSurroundingLogsId(null); + applyLogFilterQuery(query); }} placeholder={i18n.translate('xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder', { defaultMessage: 'Search for log entries… (e.g. host.name:host-1)', })} - query={{ - query: filterQueryDraft?.expression ?? '', - language: filterQueryDraft?.kind ?? 'kuery', - }} + query={filterQueryDraft} /> diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx index 4915f4cc6422a..04772860c9fe7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx @@ -166,7 +166,7 @@ export const NoAnomaliesFound = withTheme(({ theme }) => (

-

+

{ label: i18n.translate('xpack.infra.ml.anomalyFlyout.hostBtn', { defaultMessage: 'Hosts', }), + 'data-test-subj': 'anomaliesHostComboBoxItem', }, { id: `k8s` as JobType, label: i18n.translate('xpack.infra.ml.anomalyFlyout.podsBtn', { defaultMessage: 'Kubernetes Pods', }), + 'data-test-subj': 'anomaliesK8sComboBoxItem', }, ]; const [jobType, setJobType] = useState('hosts'); @@ -364,6 +366,7 @@ export const AnomaliesTable = (props: Props) => { }), width: '25%', render: (jobId: string) => jobId, + 'data-test-subj': 'anomalyRow', }, { field: 'anomalyScore', @@ -471,6 +474,7 @@ export const AnomaliesTable = (props: Props) => { selectedOptions={selectedJobType} onChange={changeJobType} isClearable={false} + data-test-subj="anomaliesComboBoxType" /> diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx index 387e739fab43f..5438209ae9c6b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx @@ -50,7 +50,12 @@ export const AnomalyDetectionFlyout = () => { return ( <> - + { setTab('jobs')}> Jobs - setTab('anomalies')}> + setTab('anomalies')} + data-test-subj="anomalyFlyoutAnomaliesTab" + > Anomalies diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx index 71bf9e50c4bb6..4fa9fdf8cdd4a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useMemo, useState } from 'react'; +import { useThrottle } from 'react-use'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiFieldSearch } from '@elastic/eui'; @@ -26,16 +27,21 @@ const TabComponent = (props: TabProps) => { const { nodeType } = useWaffleOptionsContext(); const { options, node } = props; + const throttledTextQuery = useThrottle(textQuery, textQueryThrottleInterval); + const filter = useMemo(() => { - let query = options.fields - ? `${findInventoryFields(nodeType, options.fields).id}: "${node.id}"` - : ``; + const query = [ + ...(options.fields != null + ? [`${findInventoryFields(nodeType, options.fields).id}: "${node.id}"`] + : []), + ...(throttledTextQuery !== '' ? [throttledTextQuery] : []), + ].join(' and '); - if (textQuery) { - query += ` and message: ${textQuery}`; - } - return query; - }, [options, nodeType, node.id, textQuery]); + return { + language: 'kuery', + query, + }; + }, [options.fields, nodeType, node.id, throttledTextQuery]); const onQueryChange = useCallback((e: React.ChangeEvent) => { setTextQuery(e.target.value); @@ -89,3 +95,5 @@ export const LogsTab = { }), content: TabComponent, }; + +const textQueryThrottleInterval = 1000; // milliseconds diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/page_error.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/page_error.tsx index 4c4fc738b6ddf..6451206f8cc1a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/page_error.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/page_error.tsx @@ -11,7 +11,6 @@ import { IHttpFetchError } from 'src/core/public'; import { InvalidNodeError } from './invalid_node'; import { DocumentTitle } from '../../../../components/document_title'; import { ErrorPageBody } from '../../../error'; - interface Props { name: string; error: IHttpFetchError; @@ -30,13 +29,11 @@ export const PageError = ({ error, name }: Props) => { }) } /> - { - (error.body.statusCode = 404 ? ( - - ) : ( - - )) - } + {error.body?.statusCode === 404 ? ( + + ) : ( + + )} ); }; diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index a5c19911f60b9..bfcc20cc88b81 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -35,6 +35,7 @@ "savedObjects", "kibanaUtils", "kibanaReact", - "embeddable" + "embeddable", + "usageCollection" ] } diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index b2b63015deef3..00245384ec8b4 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -5,19 +5,15 @@ } .lnsApp { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; + flex: 1 1 auto; display: flex; flex-direction: column; height: 100%; overflow: hidden; -} -.lnsApp__header { - border-bottom: $euiBorderThin; + > .kbnTopNavMenu__wrapper { + border-bottom: $euiBorderThin; + } } .lnsApp__frame { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index f137047cfc871..077456423ac4d 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -648,68 +648,66 @@ export function App({ return ( <>
-
- { - const { dateRange, query } = payload; - const currentRange = data.query.timefilter.timefilter.getTime(); - if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) { - data.query.timefilter.timefilter.setTime(dateRange); - trackUiEvent('app_date_change'); - } else { - // Query has changed, renew the session id. - // Time change will be picked up by the time subscription - setState((s) => ({ - ...s, - searchSessionId: startSession(), - })); - trackUiEvent('app_query_change'); - } - setState((s) => ({ - ...s, - query: query || s.query, - })); - }} - onSaved={(savedQuery) => { - setState((s) => ({ ...s, savedQuery })); - }} - onSavedQueryUpdated={(savedQuery) => { - const savedQueryFilters = savedQuery.attributes.filters || []; - const globalFilters = data.query.filterManager.getGlobalFilters(); - data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]); - setState((s) => ({ - ...s, - savedQuery: { ...savedQuery }, // Shallow query for reference issues - query: savedQuery.attributes.query, - })); - }} - onClearSavedQuery={() => { - data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters()); + { + const { dateRange, query } = payload; + const currentRange = data.query.timefilter.timefilter.getTime(); + if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) { + data.query.timefilter.timefilter.setTime(dateRange); + trackUiEvent('app_date_change'); + } else { + // Query has changed, renew the session id. + // Time change will be picked up by the time subscription setState((s) => ({ ...s, - savedQuery: undefined, - filters: data.query.filterManager.getGlobalFilters(), - query: data.query.queryString.getDefaultQuery(), + searchSessionId: startSession(), })); - }} - query={state.query} - dateRangeFrom={fromDate} - dateRangeTo={toDate} - indicateNoData={state.indicateNoData} - /> -
+ trackUiEvent('app_query_change'); + } + setState((s) => ({ + ...s, + query: query || s.query, + })); + }} + onSaved={(savedQuery) => { + setState((s) => ({ ...s, savedQuery })); + }} + onSavedQueryUpdated={(savedQuery) => { + const savedQueryFilters = savedQuery.attributes.filters || []; + const globalFilters = data.query.filterManager.getGlobalFilters(); + data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]); + setState((s) => ({ + ...s, + savedQuery: { ...savedQuery }, // Shallow query for reference issues + query: savedQuery.attributes.query, + })); + }} + onClearSavedQuery={() => { + data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters()); + setState((s) => ({ + ...s, + savedQuery: undefined, + filters: data.query.filterManager.getGlobalFilters(), + query: data.query.queryString.getDefaultQuery(), + })); + }} + query={state.query} + dateRangeFrom={fromDate} + dateRangeTo={toDate} + indicateNoData={state.indicateNoData} + /> {(!state.isLoading || state.persistedDoc) && ( { + let isMounted = true; if (activeDropTarget && activeDropTarget.id !== value.id) { setIsInZone(false); } setTimeout(() => { - if (!activeDropTarget) { + if (!activeDropTarget && isMounted) { setIsInZone(false); } }, 1000); + return () => { + isMounted = false; + }; }, [activeDropTarget, setIsInZone, value.id]); const dragEnter = () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx index a3d5c6fd22fcd..b8a5819d45532 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -74,7 +74,7 @@ export function ColorIndicator({ } return ( - + {indicatorIcon} {children} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss index 5947d62540a0d..91cd706ea77d1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss @@ -6,11 +6,15 @@ @include euiFlyout; // But with custom positioning to keep it within the sidebar contents position: absolute; - right: 0; left: 0; - top: 0; - bottom: 0; animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; + @include euiBreakpoint('l', 'xl') { + top: 0 !important; + height: 100% !important; + } + @include euiBreakpoint('xs', 's', 'm') { + @include euiFlyout; + } } .lnsDimensionContainer__footer { @@ -49,3 +53,7 @@ background-color: transparent; } } + +.lnsBody--overflowHidden { + overflow: hidden; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index f66e8ba87e8e8..b8d3170b3e165 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -61,6 +61,17 @@ export function DimensionContainer({ [closeFlyout] ); + useEffect(() => { + if (isOpen) { + document.body.classList.add('lnsBody--overflowHidden'); + } else { + document.body.classList.remove('lnsBody--overflowHidden'); + } + return () => { + document.body.classList.remove('lnsBody--overflowHidden'); + }; + }); + return isOpen ? ( @@ -68,7 +79,7 @@ export function DimensionContainer({
* { + flex-basis: 100%; + } + > .lnsFrameLayout__sidebar { + min-height: $euiSizeL * 15; + } + } +} + +.visEditor { + @include flexParent(); + + height: 100%; + + @include euiBreakpoint('xs', 's', 'm') { + .visualization { + // While we are on a small screen the visualization is below the + // editor. In this cases it needs a minimum height, since it would otherwise + // maybe end up with 0 height since it just gets the flexbox rest of the screen. + min-height: $euiSizeL * 15; + } + } + + /* 1. Without setting this to 0 you will run into a bug where the filter bar modal is hidden under +a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ + > .visualize { + height: 100%; + flex: 1 1 auto; + display: flex; + z-index: 0; /* 1 */ + } } .lnsFrameLayout__pageBody { @@ -51,6 +88,10 @@ max-width: $euiFormMaxWidth + $euiSizeXXL; max-height: 100%; + @include euiBreakpoint('xs', 's', 'm') { + max-width: 100%; + } + .lnsConfigPanel { @include euiScrollBar; padding: $euiSize $euiSizeXS $euiSize $euiSize; @@ -58,5 +99,10 @@ overflow-y: scroll; padding-left: $euiFormMaxWidth + $euiSize; margin-left: -$euiFormMaxWidth; + + @include euiBreakpoint('xs', 's', 'm') { + padding-left: $euiSize; + margin-left: 0; + } } } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index c3bd6fde27ba3..a31146e500434 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -43,7 +43,6 @@ import { import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; -import { debouncedComponent } from '../../../debounced_component'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { UiActionsStart, @@ -368,7 +367,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ ); }); -export const InnerVisualizationWrapper = ({ +export const VisualizationWrapper = ({ expression, framePublicAPI, timefilter, @@ -619,5 +618,3 @@ export const InnerVisualizationWrapper = ({
); }; - -export const VisualizationWrapper = debouncedComponent(InnerVisualizationWrapper); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index a3316e0083d35..214ce6d11cff2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -24,6 +24,8 @@ import { toExpression, Ast } from '@kbn/interpreter/common'; import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions'; import { map, distinctUntilChanged, skip } from 'rxjs/operators'; import isEqual from 'fast-deep-equal'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { METRIC_TYPE } from '../../../../../../src/plugins/usage_collection/public'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -51,7 +53,7 @@ import { } from '../../types'; import { IndexPatternsContract } from '../../../../../../src/plugins/data/public'; -import { getEditPath, DOC_TYPE } from '../../../common'; +import { getEditPath, DOC_TYPE, PLUGIN_ID } from '../../../common'; import { IBasePath } from '../../../../../../src/core/public'; import { LensAttributeService } from '../../lens_attribute_service'; import type { ErrorMessage } from '../types'; @@ -95,6 +97,7 @@ export interface LensEmbeddableDeps { getTrigger?: UiActionsStart['getTrigger'] | undefined; getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions']; capabilities: { canSaveVisualizations: boolean; canSaveDashboards: boolean }; + usageCollection?: UsageCollectionSetup; } export class Embeddable @@ -113,6 +116,14 @@ export class Embeddable private inputReloadSubscriptions: Subscription[]; private isDestroyed?: boolean; + private logError(type: 'runtime' | 'validation') { + this.deps.usageCollection?.reportUiCounter( + PLUGIN_ID, + METRIC_TYPE.COUNT, + type === 'runtime' ? 'embeddable_runtime_error' : 'embeddable_validation_error' + ); + } + private externalSearchContext: { timeRange?: TimeRange; query?: Query; @@ -255,6 +266,9 @@ export class Embeddable const { ast, errors } = await this.deps.documentToExpression(this.savedVis); this.errors = errors; this.expression = ast ? toExpression(ast) : null; + if (errors) { + this.logError('validation'); + } await this.initializeOutput(); this.isInitialized = true; } @@ -326,6 +340,9 @@ export class Embeddable className={input.className} style={input.style} canEdit={this.getIsEditable() && input.viewMode === 'edit'} + onRuntimeError={() => { + this.logError('runtime'); + }} />, domNode ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 1a4962bd1fe8e..095e18e3fb5eb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter/target/common'; import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { IndexPatternsContract, TimefilterContract, @@ -34,6 +35,7 @@ export interface LensEmbeddableStartServices { expressionRenderer: ReactExpressionRendererType; indexPatternService: IndexPatternsContract; uiActions?: UiActionsStart; + usageCollection?: UsageCollectionSetup; documentToExpression: ( doc: Document ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; @@ -87,6 +89,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { attributeService, indexPatternService, capabilities, + usageCollection, } = await this.getStartServices(); const { Embeddable } = await import('../../async_services'); @@ -105,6 +108,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { canSaveDashboards: Boolean(capabilities.dashboard?.showWriteControls), canSaveVisualizations: Boolean(capabilities.visualize.save), }, + usageCollection, }, input, parent diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index f4d0c85ecbbce..15d168465ec71 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -38,6 +38,7 @@ export interface ExpressionWrapperProps { style?: React.CSSProperties; className?: string; canEdit: boolean; + onRuntimeError: () => void; } interface VisualizationErrorProps { @@ -106,6 +107,7 @@ export function ExpressionWrapper({ className, errors, canEdit, + onRuntimeError, }: ExpressionWrapperProps) { return ( @@ -123,20 +125,23 @@ export function ExpressionWrapper({ onData$={onData$} renderMode={renderMode} syncColors={syncColors} - renderError={(errorMessage, error) => ( -
- - - - - - {(getOriginalRequestErrorMessages(error) || [errorMessage]).map((message) => ( - {message} - ))} - - -
- )} + renderError={(errorMessage, error) => { + onRuntimeError(); + return ( +
+ + + + + + {(getOriginalRequestErrorMessages(error) || [errorMessage]).map((message) => ( + {message} + ))} + + +
+ ); + }} onEvent={handleEvent} hasCompatibleActions={hasCompatibleActions} /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 8769aceca3bfd..849baa93652cc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreSetup, CoreStart } from 'kibana/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public'; import { EmbeddableSetup, EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { @@ -35,6 +36,7 @@ export interface EditorFrameSetupPlugins { embeddable?: EmbeddableSetup; expressions: ExpressionsSetup; charts: ChartsPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface EditorFrameStartPlugins { @@ -101,6 +103,7 @@ export class EditorFrameService { documentToExpression: this.documentToExpression, indexPatternService: deps.data.indexPatterns, uiActions: deps.uiActions, + usageCollection: plugins.usageCollection, }; }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 2ee4fe24a06fc..0a41e7e65212a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -59,7 +59,7 @@ export function ChangeIndexPattern({ return ( <> setPopoverIsOpen(false)} @@ -67,7 +67,7 @@ export function ChangeIndexPattern({ panelPaddingSize="s" ownFocus > -
+
{i18n.translate('xpack.lens.indexPattern.changeIndexPatternTitle', { defaultMessage: 'Change index pattern', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index bfb1106f5080e..d3320714a65cd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -42,3 +42,7 @@ margin-right: $euiSizeS; } } + +.lnsChangeIndexPatternPopover { + width: 320px; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 9fd389d4e65d3..4839d9388253b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -604,6 +604,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ gutterSize="s" alignItems="center" className="lnsInnerIndexPatternDataPanel__header" + responsive={false} > { }); }); + it('respects groups on moving operations if some columns are not listed in groups', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // col5, col6 not in visualization groups + // dragging col3 onto col1 in group a + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: { + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'], + columns: { + ...testState.layers.first.columns, + col5: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + col6: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + }, + }, + }, + }, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col4', 'col5', 'col6'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col4: testState.layers.first.columns.col4, + col5: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + col6: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + }, + }, + }, + }); + }); + it('respects groups on duplicating operations between compatible groups with overwrite', () => { // config: // a: col1, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index f0ad797a81b9f..08632171ee4f7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -147,9 +147,9 @@ function onMoveCompatible( columns: newColumns, }; - const updatedColumnOrder = getColumnOrder(newLayer); + let updatedColumnOrder = getColumnOrder(newLayer); - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); // Time to replace setState( @@ -342,8 +342,8 @@ function onSwapCompatible({ newColumns[targetId] = sourceColumn; newColumns[sourceId] = targetColumn; - const updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + let updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); + updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); // Time to replace setState( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 2da7902038345..fce4fcda14cfc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -662,7 +662,12 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { const formatted = formatter.convert(topValue.key); return (
- + {formatted === '' ? ( @@ -702,7 +707,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { })} {otherCount ? ( <> - + {i18n.translate('xpack.lens.indexPattern.otherDocsLabel', { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 0ea533e22e4d9..c291c7ab3eac0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -860,6 +860,44 @@ describe('IndexPattern Data Source', () => { expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled(); expect(ast.chain[2]).toEqual('mock'); }); + + it('should keep correct column mapping keys with reference columns present', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col2', 'col1'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'unique_count', + }, + col2: { + label: 'Reference', + dataType: 'number', + isBucketed: false, + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + 'col-0-col1': expect.objectContaining({ + id: 'col1', + }), + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index ccae659934ba7..864a3a6f089db 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1106,11 +1106,11 @@ describe('IndexPattern Data Source suggestions', () => { operation: expect.objectContaining({ dataType: 'date', isBucketed: true }), }, { - columnId: 'newid', + columnId: 'ref', operation: expect.objectContaining({ dataType: 'number', isBucketed: false }), }, { - columnId: 'ref', + columnId: 'newid', operation: expect.objectContaining({ dataType: 'number', isBucketed: false }), }, ], @@ -1159,21 +1159,21 @@ describe('IndexPattern Data Source suggestions', () => { changeType: 'extended', columns: [ { - columnId: 'newid', + columnId: 'ref', operation: { dataType: 'number', isBucketed: false, - label: 'Count of records', - scale: 'ratio', + label: '', + scale: undefined, }, }, { - columnId: 'ref', + columnId: 'newid', operation: { dataType: 'number', isBucketed: false, - label: '', - scale: undefined, + label: 'Count of records', + scale: 'ratio', }, }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 35f334d5bd743..297fa4af2bc3f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -712,7 +712,12 @@ function addBucket( // they already had, with an extra level of detail. updatedColumnOrder = [...buckets, addedColumnId, ...metrics, ...references]; } - reorderByGroups(visualizationGroups, targetGroup, updatedColumnOrder, addedColumnId); + updatedColumnOrder = reorderByGroups( + visualizationGroups, + targetGroup, + updatedColumnOrder, + addedColumnId + ); const tempLayer = { ...resetIncomplete(layer, addedColumnId), columns: { ...layer.columns, [addedColumnId]: column }, @@ -749,16 +754,24 @@ export function reorderByGroups( }); const columnGroupIndex: Record = {}; updatedColumnOrder.forEach((columnId) => { - columnGroupIndex[columnId] = orderedVisualizationGroups.findIndex( + const groupIndex = orderedVisualizationGroups.findIndex( (group) => (columnId === addedColumnId && group.groupId === targetGroup) || group.accessors.some((acc) => acc.columnId === columnId) ); + if (groupIndex !== -1) { + columnGroupIndex[columnId] = groupIndex; + } else { + // referenced columns won't show up in visualization groups - put them in the back of the list. This will work as they are always metrics + columnGroupIndex[columnId] = updatedColumnOrder.length; + } }); - updatedColumnOrder.sort((a, b) => { + return [...updatedColumnOrder].sort((a, b) => { return columnGroupIndex[a] - columnGroupIndex[b]; }); + } else { + return updatedColumnOrder; } } @@ -899,12 +912,8 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { } }); - const [direct, referenceBased] = _.partition( - entries, - ([, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference' - ); // If a reference has another reference as input, put it last in sort order - referenceBased.sort(([idA, a], [idB, b]) => { + entries.sort(([idA, a], [idB, b]) => { if ('references' in a && a.references.includes(idB)) { return 1; } @@ -913,12 +922,9 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { } return 0; }); - const [aggregations, metrics] = _.partition(direct, ([, col]) => col.isBucketed); + const [aggregations, metrics] = _.partition(entries, ([, col]) => col.isBucketed); - return aggregations - .map(([id]) => id) - .concat(metrics.map(([id]) => id)) - .concat(referenceBased.map(([id]) => id)); + return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id)); } // Splits existing columnOrder into the three categories diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index b272e5476aa63..4f596aa282510 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -6,6 +6,7 @@ */ import type { IUiSettingsClient } from 'kibana/public'; +import { partition } from 'lodash'; import { AggFunctionsMapping, EsaggsExpressionFunctionDefinition, @@ -57,14 +58,24 @@ function getExpressionForLayer( const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); - if (columnEntries.length) { + const [referenceEntries, esAggEntries] = partition( + columnEntries, + ([, col]) => operationDefinitionMap[col.operationType]?.input === 'fullReference' + ); + + if (referenceEntries.length || esAggEntries.length) { const aggs: ExpressionAstExpressionBuilder[] = []; const expressions: ExpressionAstFunction[] = []; - columnEntries.forEach(([colId, col]) => { + referenceEntries.forEach(([colId, col]) => { const def = operationDefinitionMap[col.operationType]; if (def.input === 'fullReference') { expressions.push(...def.toExpression(layer, colId, indexPattern)); - } else { + } + }); + + esAggEntries.forEach(([colId, col]) => { + const def = operationDefinitionMap[col.operationType]; + if (def.input !== 'fullReference') { const wrapInFilter = Boolean(def.filterable && col.filter); let aggAst = def.toEsAggsFn( col, @@ -101,8 +112,8 @@ function getExpressionForLayer( } }); - const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => { - const esAggsId = `col-${columnEntries.length === 1 ? 0 : index}-${colId}`; + const idMap = esAggEntries.reduce((currentIdMap, [colId, column], index) => { + const esAggsId = `col-${index}-${colId}`; return { ...currentIdMap, [esAggsId]: { diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index e3bd54032a93c..aa48986832056 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -125,7 +125,7 @@ export function PieToolbar(props: VisualizationToolbarProps + { @@ -153,6 +156,7 @@ export class LensPlugin { embeddable, charts, expressions, + usageCollection, }, this.attributeService ); diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index cb82cc5b52a01..aa22bbb0c15c6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -74,6 +74,10 @@ exports[`xy_expression XYChart component it renders area 1`] = ` tickFormat={[Function]} title="a" /> + + + + + + + { false ); }); + + it('hides the endzone visibility flag if no setter is passed in', () => { + const component = shallow(); + expect(component.find('[data-test-subj="lnsshowEndzones"]').length).toBe(0); + }); + + it('shows the switch if setter is present', () => { + const component = shallow( + {}} /> + ); + expect(component.find('[data-test-subj="lnsshowEndzones"]').prop('checked')).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx index 2a40f6204c44d..d9c60ae666484 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx @@ -71,6 +71,14 @@ export interface AxisSettingsPopoverProps { * Toggles the axis title visibility */ toggleAxisTitleVisibility: (axis: AxesSettingsConfigKeys, checked: boolean) => void; + /** + * Set endzone visibility + */ + setEndzoneVisibility?: (checked: boolean) => void; + /** + * Flag whether endzones are visible + */ + endzonesVisible?: boolean; } const popoverConfig = ( axis: AxesSettingsConfigKeys, @@ -138,6 +146,8 @@ export const AxisSettingsPopover: React.FunctionComponent { const [title, setTitle] = useState(axisTitle); @@ -212,6 +222,20 @@ export const AxisSettingsPopover: React.FunctionComponent toggleGridlinesVisibility(axis)} checked={areGridlinesVisible} /> + {setEndzoneVisibility && ( + <> + + setEndzoneVisibility(!Boolean(endzonesVisible))} + checked={Boolean(endzonesVisible)} + /> + + )} ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index e1dbd4da4b902..fe0513caa08a8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -44,6 +44,7 @@ import { createMockExecutionContext } from '../../../../../src/plugins/expressio import { mountWithIntl } from '@kbn/test/jest'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../shared_components/empty_placeholder'; +import { XyEndzones } from './x_domain'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -549,6 +550,135 @@ describe('xy_expression', () => { } `); }); + + describe('endzones', () => { + const { args } = sampleArgs(); + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: new Date('2021-04-22').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-23').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-24').valueOf(), d: 'Foo' }, + ]), + }, + dateRange: { + // first and last bucket are partial + fromDate: new Date('2021-04-22T12:00:00.000Z'), + toDate: new Date('2021-04-24T12:00:00.000Z'), + }, + }; + const timeArgs: XYArgs = { + ...args, + layers: [ + { + ...args.layers[0], + seriesType: 'line', + xScaleType: 'time', + isHistogram: true, + splitAccessor: undefined, + }, + ], + }; + + test('it extends interval if data is exceeding it', () => { + const component = shallow( + + ); + + expect(component.find(Settings).prop('xDomain')).toEqual({ + // shortened to 24th midnight (elastic-charts automatically adds one min interval) + max: new Date('2021-04-24').valueOf(), + // extended to 22nd midnight because of first bucket + min: new Date('2021-04-22').valueOf(), + minInterval: 24 * 60 * 60 * 1000, + }); + }); + + test('it renders endzone component bridging gap between domain and extended domain', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + domainStart: new Date('2021-04-22T12:00:00.000Z').valueOf(), + domainEnd: new Date('2021-04-24T12:00:00.000Z').valueOf(), + domainMin: new Date('2021-04-22').valueOf(), + domainMax: new Date('2021-04-24').valueOf(), + }) + ); + }); + + test('should pass enabled histogram mode and min interval to endzones component', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: false, + }) + ); + }); + + test('should pass disabled histogram mode and min interval to endzones component', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: true, + }) + ); + }); + + test('it does not render endzones if disabled via settings', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).length).toEqual(0); + }); + }); }); test('it has xDomain undefined if the x is not a time scale or a histogram', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 47b8dbfc15f53..5416c8eda0aa9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -57,6 +57,7 @@ import { desanitizeFilterContext } from '../utils'; import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; +import { getXDomain, XyEndzones } from './x_domain'; declare global { interface Window { @@ -183,6 +184,13 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Define how curve type is rendered for a line chart', }), }, + hideEndzones: { + types: ['boolean'], + default: false, + help: i18n.translate('xpack.lens.xyChart.hideEndzones.help', { + defaultMessage: 'Hide endzone markers for partial data', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -330,9 +338,17 @@ export function XYChart({ renderMode, syncColors, }: XYChartRenderProps) { - const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args; + const { + legend, + layers, + fittingFunction, + gridlinesVisibilitySettings, + valueLabels, + hideEndzones, + } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const darkMode = chartsThemeService.useDarkMode(); const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) { @@ -387,15 +403,13 @@ export function XYChart({ const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); const isHistogramViz = filteredLayers.every((l) => l.isHistogram); - const xDomain = isTimeViz - ? { - min: data.dateRange?.fromDate.getTime(), - max: data.dateRange?.toDate.getTime(), - minInterval, - } - : isHistogramViz - ? { minInterval } - : undefined; + const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( + layers, + data, + minInterval, + Boolean(isTimeViz), + Boolean(isHistogramViz) + ); const getYAxesTitles = ( axisSeries: Array<{ layer: string; accessor: string }>, @@ -602,6 +616,22 @@ export function XYChart({ /> ))} + {!hideEndzones && ( + + layer.isHistogram && + (layer.seriesType.includes('stacked') || !layer.splitAccessor) && + (layer.seriesType.includes('stacked') || + !layer.seriesType.includes('bar') || + !chartHasMoreThanOneBarSeries) + )} + /> + )} + {filteredLayers.flatMap((layer, layerIndex) => layer.accessors.map((accessor, accessorIndex) => { const { diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index b726869743312..89dca6e8a3944 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -51,6 +51,7 @@ describe('#toExpression', () => { fittingFunction: 'Carry', tickLabelsVisibilitySettings: { x: false, yLeft: true, yRight: true }, gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + hideEndzones: true, layers: [ { layerId: 'first', diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 6a1882edde949..02c5f3773d813 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -198,6 +198,7 @@ export const buildExpression = ( }, ], valueLabels: [state?.valueLabels || 'hide'], + hideEndzones: [state?.hideEndzones || false], layers: validLayers.map((layer) => { const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layer.layerId]); diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 6f1a01acd6e76..0622f1c43f1c3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -414,6 +414,7 @@ export interface XYArgs { tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; curveType?: XYCurveType; + hideEndzones?: boolean; } export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; @@ -432,6 +433,7 @@ export interface XYState { tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; curveType?: XYCurveType; + hideEndzones?: boolean; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 27ef827c138ca..aa4b91b840db3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -818,6 +818,60 @@ describe('xy_visualization', () => { }, ]); }); + + it('should return an error if two incompatible xAccessors (multiple layers) are used', () => { + // current incompatibility is only for date and numeric histograms as xAccessors + const datasourceLayers = { + first: mockDatasource.publicAPIMock, + second: createMockDatasource('testDatasource').publicAPIMock, + }; + datasourceLayers.first.getOperationForColumnId = jest.fn((id: string) => + id === 'a' + ? (({ + dataType: 'date', + scale: 'interval', + } as unknown) as Operation) + : null + ); + datasourceLayers.second.getOperationForColumnId = jest.fn((id: string) => + id === 'e' + ? (({ + dataType: 'number', + scale: 'interval', + } as unknown) as Operation) + : null + ); + expect( + xyVisualization.getErrorMessages( + { + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'second', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'e', + accessors: ['b'], + }, + ], + }, + datasourceLayers + ) + ).toEqual([ + { + shortMessage: 'Wrong data type for Horizontal axis.', + longMessage: + 'Data type mismatch for the Horizontal axis. Cannot mix date and number interval types.', + }, + ]); + }); }); describe('#getWarningMessages', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index a6df995513fdf..dda1a444f4544 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -15,8 +15,14 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; -import { State, SeriesType, visualizationTypes, XYLayerConfig } from './types'; +import { + Visualization, + OperationMetadata, + VisualizationType, + AccessorConfig, + DatasourcePublicAPI, +} from '../types'; +import { State, SeriesType, visualizationTypes, XYLayerConfig, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; @@ -374,6 +380,9 @@ export const getXyVisualization = ({ } if (datasourceLayers && state) { + // temporary fix for #87068 + errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); + for (const layer of state.layers) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { @@ -517,3 +526,47 @@ function newLayerState(seriesType: SeriesType, layerId: string): XYLayerConfig { accessors: [], }; } + +// min requirement for the bug: +// * 2 or more layers +// * at least one with date histogram +// * at least one with interval function +function checkXAccessorCompatibility( + state: XYState, + datasourceLayers: Record +) { + const errors = []; + const hasDateHistogramSet = state.layers.some(checkIntervalOperation('date', datasourceLayers)); + const hasNumberHistogram = state.layers.some(checkIntervalOperation('number', datasourceLayers)); + if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { + defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + return errors; +} + +function checkIntervalOperation( + dataType: 'date' | 'number', + datasourceLayers: Record +) { + return (layer: XYLayerConfig) => { + const datasourceAPI = datasourceLayers[layer.layerId]; + if (!layer.xAccessor) { + return false; + } + const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); + return Boolean(operation?.dataType === dataType && operation.scale === 'interval'); + }; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx new file mode 100644 index 0000000000000..369063644a754 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx @@ -0,0 +1,103 @@ +/* + * 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 { uniq } from 'lodash'; +import React from 'react'; +import { Endzones } from '../../../../../src/plugins/charts/public'; +import { LensMultiTable } from '../types'; +import { LayerArgs } from './types'; + +export interface XDomain { + min?: number; + max?: number; + minInterval?: number; +} + +export const getXDomain = ( + layers: LayerArgs[], + data: LensMultiTable, + minInterval: number | undefined, + isTimeViz: boolean, + isHistogram: boolean +) => { + const baseDomain = isTimeViz + ? { + min: data.dateRange?.fromDate.getTime(), + max: data.dateRange?.toDate.getTime(), + minInterval, + } + : isHistogram + ? { minInterval } + : undefined; + + if (isHistogram && isFullyQualified(baseDomain)) { + const xValues = uniq( + layers + .flatMap((layer) => + data.tables[layer.layerId].rows.map((row) => row[layer.xAccessor!].valueOf() as number) + ) + .sort() + ); + + const [firstXValue] = xValues; + const lastXValue = xValues[xValues.length - 1]; + + const domainMin = Math.min(firstXValue, baseDomain.min); + const domainMaxValue = baseDomain.max - baseDomain.minInterval; + const domainMax = Math.max(domainMaxValue, lastXValue); + + return { + extendedDomain: { + min: domainMin, + max: domainMax, + minInterval: baseDomain.minInterval, + }, + baseDomain, + }; + } + + return { + baseDomain, + extendedDomain: baseDomain, + }; +}; + +function isFullyQualified( + xDomain: XDomain | undefined +): xDomain is { min: number; max: number; minInterval: number } { + return Boolean( + xDomain && + typeof xDomain.min === 'number' && + typeof xDomain.max === 'number' && + xDomain.minInterval + ); +} + +export const XyEndzones = function ({ + baseDomain, + extendedDomain, + histogramMode, + darkMode, +}: { + baseDomain?: XDomain; + extendedDomain?: XDomain; + histogramMode: boolean; + darkMode: boolean; +}) { + return isFullyQualified(baseDomain) && isFullyQualified(extendedDomain) ? ( + + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index f965140a48ca0..e3e8c6e93e3aa 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -138,6 +138,29 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); + + it('should pass in endzone visibility setter and current sate for time chart', () => { + (frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({ + dataType: 'date', + }); + const state = testState(); + const component = shallow( + + ); + + expect(component.find(AxisSettingsPopover).at(0).prop('setEndzoneVisibility')).toBeFalsy(); + expect(component.find(AxisSettingsPopover).at(1).prop('setEndzoneVisibility')).toBeTruthy(); + expect(component.find(AxisSettingsPopover).at(1).prop('endzonesVisible')).toBe(false); + expect(component.find(AxisSettingsPopover).at(2).prop('setEndzoneVisibility')).toBeFalsy(); + }); }); describe('Dimension Editor', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 6f3017b80be1c..eccf4d9b64345 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -8,7 +8,7 @@ import './xy_config_panel.scss'; import React, { useMemo, useState, memo } from 'react'; import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; +import { Position, ScaleType } from '@elastic/charts'; import { debounce } from 'lodash'; import { EuiButtonGroup, @@ -37,7 +37,7 @@ import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; -import { getSortedAccessors } from './to_expression'; +import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray = T extends Array ? P : T; @@ -187,6 +187,23 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp }); }; + // only allow changing endzone visibility if it could show up theoretically (if it's a time viz) + const onChangeEndzoneVisiblity = state?.layers.every( + (layer) => + layer.xAccessor && + getScaleType( + props.frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor), + ScaleType.Linear + ) === 'time' + ) + ? (checked: boolean): void => { + setState({ + ...state, + hideEndzones: !checked, + }); + } + : undefined; + const legendMode = state?.legend.isVisible && !state?.legend.showSingleSeries ? 'auto' @@ -195,7 +212,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp : 'show'; return ( - + - `/_license${acknowledge ? '?acknowledge=true' : ''}`; +import { IScopedClusterClient } from 'kibana/server'; +import { LicensingPluginStart } from '../../../licensing/server'; interface PutLicenseArg { acknowledge: boolean; - callAsCurrentUser: CallAsCurrentUser; - licensing: LicensingPluginSetup; + client: IScopedClusterClient; + licensing: LicensingPluginStart; license: { [key: string]: any }; } -export async function putLicense({ - acknowledge, - callAsCurrentUser, - licensing, - license, -}: PutLicenseArg) { - const options = { - method: 'POST', - path: getLicensePath(acknowledge), - body: license, - }; - +export async function putLicense({ acknowledge, client, licensing, license }: PutLicenseArg) { try { - const response = await callAsCurrentUser('transport.request', options); + const { body: response } = await client.asCurrentUser.license.post({ + body: license, + acknowledge, + }); const { acknowledged, license_status: licenseStatus } = response; if (acknowledged && licenseStatus === 'valid') { diff --git a/x-pack/plugins/license_management/server/lib/permissions.ts b/x-pack/plugins/license_management/server/lib/permissions.ts index e165ebb36bc82..517854fad8e83 100644 --- a/x-pack/plugins/license_management/server/lib/permissions.ts +++ b/x-pack/plugins/license_management/server/lib/permissions.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { CallAsCurrentUser } from '../types'; +import { IScopedClusterClient } from 'src/core/server'; interface GetPermissionsArg { isSecurityEnabled: boolean; - callAsCurrentUser: CallAsCurrentUser; + client: IScopedClusterClient; } -export async function getPermissions({ isSecurityEnabled, callAsCurrentUser }: GetPermissionsArg) { +export async function getPermissions({ isSecurityEnabled, client }: GetPermissionsArg) { if (!isSecurityEnabled) { // If security isn't enabled, let the user use license management return { @@ -21,15 +21,13 @@ export async function getPermissions({ isSecurityEnabled, callAsCurrentUser }: G } const options = { - method: 'POST', - path: '/_security/user/_has_privileges', body: { cluster: ['manage'], // License management requires "manage" cluster privileges }, }; try { - const response = await callAsCurrentUser('transport.request', options); + const { body: response } = await client.asCurrentUser.security.hasPrivileges(options); return { hasPermission: response.cluster.manage, }; diff --git a/x-pack/plugins/license_management/server/lib/start_basic.ts b/x-pack/plugins/license_management/server/lib/start_basic.ts index e203983c6eb88..e45c758b304de 100644 --- a/x-pack/plugins/license_management/server/lib/start_basic.ts +++ b/x-pack/plugins/license_management/server/lib/start_basic.ts @@ -5,25 +5,18 @@ * 2.0. */ -import { LicensingPluginSetup } from '../../../licensing/server'; -import { CallAsCurrentUser } from '../types'; - -const getStartBasicPath = (acknowledge: boolean) => - `/_license/start_basic${acknowledge ? '?acknowledge=true' : ''}`; +import { IScopedClusterClient } from 'src/core/server'; +import { LicensingPluginStart } from '../../../licensing/server'; interface StartBasicArg { acknowledge: boolean; - callAsCurrentUser: CallAsCurrentUser; - licensing: LicensingPluginSetup; + client: IScopedClusterClient; + licensing: LicensingPluginStart; } -export async function startBasic({ acknowledge, callAsCurrentUser, licensing }: StartBasicArg) { - const options = { - method: 'POST', - path: getStartBasicPath(acknowledge), - }; +export async function startBasic({ acknowledge, client, licensing }: StartBasicArg) { try { - const response = await callAsCurrentUser('transport.request', options); + const { body: response } = await client.asCurrentUser.license.postStartBasic({ acknowledge }); const { basic_was_started: basicWasStarted } = response; if (basicWasStarted) { await licensing.refresh(); diff --git a/x-pack/plugins/license_management/server/lib/start_trial.ts b/x-pack/plugins/license_management/server/lib/start_trial.ts index 7bd771bd678fd..c1558f54b39b2 100644 --- a/x-pack/plugins/license_management/server/lib/start_trial.ts +++ b/x-pack/plugins/license_management/server/lib/start_trial.ts @@ -5,16 +5,12 @@ * 2.0. */ -import { LicensingPluginSetup } from '../../../licensing/server'; -import { CallAsCurrentUser } from '../types'; +import { IScopedClusterClient } from 'src/core/server'; +import { LicensingPluginStart } from '../../../licensing/server'; -export async function canStartTrial(callAsCurrentUser: CallAsCurrentUser) { - const options = { - method: 'GET', - path: '/_license/trial_status', - }; +export async function canStartTrial(client: IScopedClusterClient) { try { - const response = await callAsCurrentUser('transport.request', options); + const { body: response } = await client.asCurrentUser.license.getTrialStatus(); return response.eligible_to_start_trial; } catch (error) { return error.body; @@ -22,17 +18,15 @@ export async function canStartTrial(callAsCurrentUser: CallAsCurrentUser) { } interface StartTrialArg { - callAsCurrentUser: CallAsCurrentUser; - licensing: LicensingPluginSetup; + client: IScopedClusterClient; + licensing: LicensingPluginStart; } -export async function startTrial({ callAsCurrentUser, licensing }: StartTrialArg) { - const options = { - method: 'POST', - path: '/_license/start_trial?acknowledge=true', - }; +export async function startTrial({ client, licensing }: StartTrialArg) { try { - const response = await callAsCurrentUser('transport.request', options); + const { body: response } = await client.asCurrentUser.license.postStartTrial({ + acknowledge: true, + }); const { trial_was_started: trialWasStarted } = response; if (trialWasStarted) { diff --git a/x-pack/plugins/license_management/server/plugin.ts b/x-pack/plugins/license_management/server/plugin.ts index 553d2e3ff69a9..5772b8d9a6518 100644 --- a/x-pack/plugins/license_management/server/plugin.ts +++ b/x-pack/plugins/license_management/server/plugin.ts @@ -8,13 +8,17 @@ import { Plugin, CoreSetup } from 'kibana/server'; import { ApiRoutes } from './routes'; -import { isEsError } from './shared_imports'; -import { Dependencies } from './types'; +import { handleEsError } from './shared_imports'; +import { SetupDependencies, StartDependencies } from './types'; -export class LicenseManagementServerPlugin implements Plugin { +export class LicenseManagementServerPlugin + implements Plugin { private readonly apiRoutes = new ApiRoutes(); - setup({ http }: CoreSetup, { licensing, features, security }: Dependencies) { + setup( + { http, getStartServices }: CoreSetup, + { features, security }: SetupDependencies + ) { const router = http.createRouter(); features.registerElasticsearchFeature({ @@ -30,17 +34,19 @@ export class LicenseManagementServerPlugin implements Plugin { + this.apiRoutes.setup({ + router, + plugins: { + licensing, + }, + lib: { + handleEsError, + }, + config: { + isSecurityEnabled: security !== undefined, + }, + }); }); } diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts b/x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts index 86f87506dfc2c..03e57b1a12cd9 100644 --- a/x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts +++ b/x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts @@ -10,7 +10,11 @@ import { putLicense } from '../../../lib/license'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../helpers'; -export function registerLicenseRoute({ router, plugins: { licensing } }: RouteDependencies) { +export function registerLicenseRoute({ + router, + lib: { handleEsError }, + plugins: { licensing }, +}: RouteDependencies) { router.put( { path: addBasePath(''), @@ -22,15 +26,19 @@ export function registerLicenseRoute({ router, plugins: { licensing } }: RouteDe }, }, async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; - return res.ok({ - body: await putLicense({ - acknowledge: Boolean(req.query.acknowledge), - callAsCurrentUser, - licensing, - license: req.body, - }), - }); + const { client } = ctx.core.elasticsearch; + try { + return res.ok({ + body: await putLicense({ + acknowledge: Boolean(req.query.acknowledge), + client, + licensing, + license: req.body, + }), + }); + } catch (error) { + return handleEsError({ error, response: res }); + } } ); } diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts b/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts index dd441051872d2..01aae5cd6d441 100644 --- a/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts +++ b/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts @@ -11,13 +11,18 @@ import { addBasePath } from '../../helpers'; export function registerPermissionsRoute({ router, + lib: { handleEsError }, config: { isSecurityEnabled }, }: RouteDependencies) { router.post({ path: addBasePath('/permissions'), validate: false }, async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client } = ctx.core.elasticsearch; - return res.ok({ - body: await getPermissions({ callAsCurrentUser, isSecurityEnabled }), - }); + try { + return res.ok({ + body: await getPermissions({ client, isSecurityEnabled }), + }); + } catch (error) { + return handleEsError({ error, response: res }); + } }); } diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts b/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts index bc5fb70f7dadd..60e72d297b2ed 100644 --- a/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts +++ b/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts @@ -10,21 +10,29 @@ import { startBasic } from '../../../lib/start_basic'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../helpers'; -export function registerStartBasicRoute({ router, plugins: { licensing } }: RouteDependencies) { +export function registerStartBasicRoute({ + router, + lib: { handleEsError }, + plugins: { licensing }, +}: RouteDependencies) { router.post( { path: addBasePath('/start_basic'), validate: { query: schema.object({ acknowledge: schema.string() }) }, }, async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; - return res.ok({ - body: await startBasic({ - acknowledge: Boolean(req.query.acknowledge), - callAsCurrentUser, - licensing, - }), - }); + const { client } = ctx.core.elasticsearch; + try { + return res.ok({ + body: await startBasic({ + acknowledge: Boolean(req.query.acknowledge), + client, + licensing, + }), + }); + } catch (error) { + return handleEsError({ error, response: res }); + } } ); } diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts b/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts index 6986e85e7d280..43ab7c5eafdb5 100644 --- a/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts +++ b/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts @@ -9,16 +9,28 @@ import { canStartTrial, startTrial } from '../../../lib/start_trial'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../helpers'; -export function registerStartTrialRoutes({ router, plugins: { licensing } }: RouteDependencies) { +export function registerStartTrialRoutes({ + router, + lib: { handleEsError }, + plugins: { licensing }, +}: RouteDependencies) { router.get({ path: addBasePath('/start_trial'), validate: false }, async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; - return res.ok({ body: await canStartTrial(callAsCurrentUser) }); + const { client } = ctx.core.elasticsearch; + try { + return res.ok({ body: await canStartTrial(client) }); + } catch (error) { + return handleEsError({ error, response: res }); + } }); router.post({ path: addBasePath('/start_trial'), validate: false }, async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; - return res.ok({ - body: await startTrial({ callAsCurrentUser, licensing }), - }); + const { client } = ctx.core.elasticsearch; + try { + return res.ok({ + body: await startTrial({ client, licensing }), + }); + } catch (error) { + return handleEsError({ error, response: res }); + } }); } diff --git a/x-pack/plugins/license_management/server/shared_imports.ts b/x-pack/plugins/license_management/server/shared_imports.ts index df9b3dd53cc1f..7f55d189457c7 100644 --- a/x-pack/plugins/license_management/server/shared_imports.ts +++ b/x-pack/plugins/license_management/server/shared_imports.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { isEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { handleEsError } from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/license_management/server/types.ts b/x-pack/plugins/license_management/server/types.ts index d2f32b47a9973..68219380c6e83 100644 --- a/x-pack/plugins/license_management/server/types.ts +++ b/x-pack/plugins/license_management/server/types.ts @@ -5,32 +5,35 @@ * 2.0. */ -import { LegacyScopedClusterClient, IRouter } from 'kibana/server'; +import { IScopedClusterClient, IRouter } from 'kibana/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { LicensingPluginSetup } from '../../licensing/server'; +import { LicensingPluginStart } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; -import { isEsError } from './shared_imports'; +import { handleEsError } from './shared_imports'; -export interface Dependencies { - licensing: LicensingPluginSetup; +export interface SetupDependencies { features: FeaturesPluginSetup; security?: SecurityPluginSetup; } +export interface StartDependencies { + licensing: LicensingPluginStart; +} + export interface RouteDependencies { router: IRouter; plugins: { - licensing: LicensingPluginSetup; + licensing: LicensingPluginStart; }; lib: { - isEsError: typeof isEsError; + handleEsError: typeof handleEsError; }; config: { isSecurityEnabled: boolean; }; } -export type CallAsCurrentUser = LegacyScopedClusterClient['callAsCurrentUser']; +export type CallAsCurrentUser = IScopedClusterClient['asCurrentUser']; -export type CallAsInternalUser = LegacyScopedClusterClient['callAsInternalUser']; +export type CallAsInternalUser = IScopedClusterClient['asInternalUser']; diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts index 898cf16423f15..1e1afc33394f3 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { Cluster } from './cluster'; describe('cluster', () => { @@ -12,7 +13,7 @@ describe('cluster', () => { describe('fromUpstreamJSON factory method', () => { const upstreamJSON = { cluster_uuid: 'S-S4NNZDRV-g9c-JrIhx6A', - }; + } as estypes.RootNodeInfoResponse; it('returns correct Cluster instance', () => { const cluster = Cluster.fromUpstreamJSON(upstreamJSON); diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.ts index e089eef623069..88789a2d29c89 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.ts @@ -5,28 +5,27 @@ * 2.0. */ -import { get } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; /** * This model deals with a cluster object from ES and converts it to Kibana downstream */ export class Cluster { public readonly uuid: string; + constructor({ uuid }: { uuid: string }) { this.uuid = uuid; } public get downstreamJSON() { - const json = { + return { uuid: this.uuid, }; - - return json; } // generate Pipeline object from elasticsearch response - static fromUpstreamJSON(upstreamCluster: Record) { - const uuid = get(upstreamCluster, 'cluster_uuid') as string; + static fromUpstreamJSON(upstreamCluster: estypes.RootNodeInfoResponse) { + const uuid = upstreamCluster.cluster_uuid; return new Cluster({ uuid }); } } diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts index 1a94a25647342..f40e500671fc3 100644 --- a/x-pack/plugins/logstash/server/plugin.ts +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -5,20 +5,11 @@ * 2.0. */ -import { - CoreSetup, - CoreStart, - ILegacyCustomClusterClient, - Logger, - Plugin, - PluginInitializerContext, -} from 'src/core/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; - import { registerRoutes } from './routes'; -import type { LogstashRequestHandlerContext } from './types'; interface SetupDeps { licensing: LicensingPluginSetup; @@ -28,8 +19,7 @@ interface SetupDeps { export class LogstashPlugin implements Plugin { private readonly logger: Logger; - private esClient?: ILegacyCustomClusterClient; - private coreSetup?: CoreSetup; + constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); } @@ -37,7 +27,6 @@ export class LogstashPlugin implements Plugin { setup(core: CoreSetup, deps: SetupDeps) { this.logger.debug('Setting up Logstash plugin'); - this.coreSetup = core; registerRoutes(core.http.createRouter(), deps.security); deps.features.registerElasticsearchFeature({ @@ -55,19 +44,5 @@ export class LogstashPlugin implements Plugin { }); } - start(core: CoreStart) { - const esClient = core.elasticsearch.legacy.createClient('logstash'); - - this.coreSetup!.http.registerRouteHandlerContext( - 'logstash', - async (context, request) => { - return { esClient: esClient.asScoped(request) }; - } - ); - } - stop() { - if (this.esClient) { - this.esClient.close(); - } - } + start(core: CoreStart) {} } diff --git a/x-pack/plugins/logstash/server/routes/cluster/load.ts b/x-pack/plugins/logstash/server/routes/cluster/load.ts index ac7bc245e51eb..1b8dc7880e8dc 100644 --- a/x-pack/plugins/logstash/server/routes/cluster/load.ts +++ b/x-pack/plugins/logstash/server/routes/cluster/load.ts @@ -18,8 +18,8 @@ export function registerClusterLoadRoute(router: LogstashPluginRouter) { }, wrapRouteWithLicenseCheck(checkLicense, async (context, request, response) => { try { - const client = context.logstash!.esClient; - const info = await client.callAsCurrentUser('info'); + const { client } = context.core.elasticsearch; + const { body: info } = await client.asCurrentUser.info(); return response.ok({ body: { cluster: Cluster.fromUpstreamJSON(info).downstreamJSON, diff --git a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts index 77706051d1cd1..59aaaef63786e 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts @@ -23,14 +23,18 @@ export function registerPipelineDeleteRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash!.esClient; + const { id } = request.params; + const { client } = context.core.elasticsearch; - await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(request.params.id), - method: 'DELETE', - }); - - return response.noContent(); + try { + await client.asCurrentUser.logstash.deletePipeline({ id }); + return response.noContent(); + } catch (e) { + if (e.statusCode === 404) { + return response.notFound(); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/load.ts b/x-pack/plugins/logstash/server/routes/pipeline/load.ts index f729a40f1abad..33f24a4ad6e26 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/load.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/load.ts @@ -25,13 +25,13 @@ export function registerPipelineLoadRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash!.esClient; + const { id } = request.params; + const { client } = context.core.elasticsearch; - const result = await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(request.params.id), - method: 'GET', - ignore: [404], - }); + const { body: result } = await client.asCurrentUser.logstash.getPipeline( + { id }, + { ignore: [404] } + ); if (result[request.params.id] === undefined) { return response.notFound(); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index b533f210f1cd7..48a62f83c91ca 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/save.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.ts @@ -42,12 +42,11 @@ export function registerPipelineSaveRoute( username = user?.username; } - const client = context.logstash!.esClient; + const { client } = context.core.elasticsearch; const pipeline = Pipeline.fromDownstreamJSON(request.body, request.params.id, username); - await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(pipeline.id), - method: 'PUT', + await client.asCurrentUser.logstash.putPipeline({ + id: pipeline.id, body: pipeline.upstreamJSON, }); diff --git a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts index 84dcfef4f67fd..3609ac1520683 100644 --- a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts +++ b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts @@ -6,19 +6,19 @@ */ import { schema } from '@kbn/config-schema'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; import { checkLicense } from '../../lib/check_license'; import type { LogstashPluginRouter } from '../../types'; -async function deletePipelines(callWithRequest: LegacyAPICaller, pipelineIds: string[]) { +async function deletePipelines(client: ElasticsearchClient, pipelineIds: string[]) { const deletePromises = pipelineIds.map((pipelineId) => { - return callWithRequest('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(pipelineId), - method: 'DELETE', - }) - .then((success) => ({ success })) + return client.logstash + .deletePipeline({ + id: pipelineId, + }) + .then((response) => ({ success: response.body })) .catch((error) => ({ error })); }); @@ -45,8 +45,8 @@ export function registerPipelinesDeleteRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash.esClient; - const results = await deletePipelines(client.callAsCurrentUser, request.body.pipelineIds); + const client = context.core.elasticsearch.client.asCurrentUser; + const results = await deletePipelines(client, request.body.pipelineIds); return response.ok({ body: { results } }); }) diff --git a/x-pack/plugins/logstash/server/routes/pipelines/list.ts b/x-pack/plugins/logstash/server/routes/pipelines/list.ts index 42ff528364777..2ce57d18d3118 100644 --- a/x-pack/plugins/logstash/server/routes/pipelines/list.ts +++ b/x-pack/plugins/logstash/server/routes/pipelines/list.ts @@ -6,21 +6,22 @@ */ import { i18n } from '@kbn/i18n'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import type { LogstashPluginRouter } from '../../types'; import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; import { PipelineListItem } from '../../models/pipeline_list_item'; import { checkLicense } from '../../lib/check_license'; -async function fetchPipelines(callWithRequest: LegacyAPICaller) { - const params = { - path: '/_logstash/pipeline', - method: 'GET', - ignore: [404], - }; - - return await callWithRequest('transport.request', params); +async function fetchPipelines(client: ElasticsearchClient) { + const { body } = await client.transport.request( + { + method: 'GET', + path: '/_logstash/pipeline', + }, + { ignore: [404] } + ); + return body; } export function registerPipelinesListRoute(router: LogstashPluginRouter) { @@ -33,8 +34,8 @@ export function registerPipelinesListRoute(router: LogstashPluginRouter) { checkLicense, router.handleLegacyErrors(async (context, request, response) => { try { - const client = context.logstash!.esClient; - const pipelinesRecord = (await fetchPipelines(client.callAsCurrentUser)) as Record< + const { client } = context.core.elasticsearch; + const pipelinesRecord = (await fetchPipelines(client.asCurrentUser)) as Record< string, any >; diff --git a/x-pack/plugins/logstash/server/types.ts b/x-pack/plugins/logstash/server/types.ts index aef14b98c9f06..2177ae9f17f39 100644 --- a/x-pack/plugins/logstash/server/types.ts +++ b/x-pack/plugins/logstash/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ILegacyScopedClusterClient, IRouter, RequestHandlerContext } from 'src/core/server'; +import type { IRouter, RequestHandlerContext } from 'src/core/server'; import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; export interface PipelineListItemOptions { @@ -19,9 +19,6 @@ export interface PipelineListItemOptions { * @internal */ export interface LogstashRequestHandlerContext extends RequestHandlerContext { - logstash: { - esClient: ILegacyScopedClusterClient; - }; licensing: LicensingApiRequestHandlerContext; } diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 44e5f9d445c3d..0d8930bdb75b8 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -54,6 +54,8 @@ export const KBN_TOO_MANY_FEATURES_IMAGE_ID = '__kbn_too_many_features_image_id_ // Centroids are a single point for representing lines, multiLines, polygons, and multiPolygons export const KBN_IS_CENTROID_FEATURE = '__kbn_is_centroid_feature__'; +export const MVT_TOKEN_PARAM_NAME = 'token'; + const MAP_BASE_URL = `/${MAPS_APP_PATH}/${MAP_PATH}`; export function getNewMapPath() { return MAP_BASE_URL; @@ -293,7 +295,7 @@ export enum DATA_MAPPING_FUNCTION { } export const DEFAULT_PERCENTILES = [50, 75, 90, 95, 99]; -export type RawValue = string | number | boolean | undefined | null; +export type RawValue = string | string[] | number | boolean | undefined | null; export type FieldFormatter = (value: RawValue) => string | number; diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts index 197b7f49eda0a..c18a79fa9dcbc 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts @@ -371,7 +371,7 @@ export function createSpatialFilterWithGeometry({ geoFieldName, relation = ES_SPATIAL_RELATIONS.INTERSECTS, }: { - preIndexedShape?: PreIndexedShape; + preIndexedShape?: PreIndexedShape | null; geometry: Polygon; geometryLabel: string; indexPatternId: string; diff --git a/x-pack/plugins/maps/public/classes/fields/mvt_field.ts b/x-pack/plugins/maps/public/classes/fields/mvt_field.ts index 2a837f831198a..ed2955a1cc16f 100644 --- a/x-pack/plugins/maps/public/classes/fields/mvt_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/mvt_field.ts @@ -7,7 +7,8 @@ import { AbstractField, IField } from './field'; import { FIELD_ORIGIN, MVT_FIELD_TYPE } from '../../../common/constants'; -import { ITiledSingleLayerVectorSource, IVectorSource } from '../sources/vector_source'; +import { IVectorSource } from '../sources/vector_source'; +import { ITiledSingleLayerVectorSource } from '../sources/tiled_single_layer_vector_source'; import { MVTFieldDescriptor } from '../../../common/descriptor_types'; export class MVTField extends AbstractField implements IField { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 5786b5fb194b8..59edaa8ed1b95 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -18,6 +18,7 @@ import { DataRequest } from '../util/data_request'; import { AGG_TYPE, FIELD_ORIGIN, + LAYER_TYPE, MAX_ZOOM, MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, @@ -81,7 +82,7 @@ export interface ILayer { isInitialDataLoadComplete(): boolean; getIndexPatternIds(): string[]; getQueryableIndexPatternIds(): string[]; - getType(): string | undefined; + getType(): LAYER_TYPE | undefined; isVisible(): boolean; cloneDescriptor(): Promise; renderStyleEditor( @@ -483,8 +484,8 @@ export class AbstractLayer implements ILayer { mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none'); } - getType(): string | undefined { - return this._descriptor.type; + getType(): LAYER_TYPE | undefined { + return this._descriptor.type as LAYER_TYPE; } areLabelsOnTop(): boolean { diff --git a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx index 408c2ec18164d..e71d32669a564 100644 --- a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx @@ -7,6 +7,7 @@ import { MockSyncContext } from '../__fixtures__/mock_sync_context'; import sinon from 'sinon'; +import url from 'url'; jest.mock('../../../kibana_services', () => { return { @@ -38,7 +39,8 @@ const defaultConfig = { function createLayer( layerOptions: Partial = {}, sourceOptions: Partial = {}, - isTimeAware: boolean = false + isTimeAware: boolean = false, + includeToken: boolean = false ): TiledVectorLayer { const sourceDescriptor: TiledSingleLayerVectorSourceDescriptor = { type: SOURCE_TYPES.MVT_SINGLE_LAYER, @@ -57,6 +59,19 @@ function createLayer( }; } + if (includeToken) { + mvtSource.getUrlTemplateWithMeta = async (...args) => { + const superReturn = await MVTSingleLayerVectorSource.prototype.getUrlTemplateWithMeta.call( + mvtSource, + ...args + ); + return { + ...superReturn, + refreshTokenParamName: 'token', + }; + }; + } + const defaultLayerOptions = { ...layerOptions, sourceDescriptor, @@ -115,7 +130,7 @@ describe('syncData', () => { expect(call.args[2]!.minSourceZoom).toEqual(defaultConfig.minSourceZoom); expect(call.args[2]!.maxSourceZoom).toEqual(defaultConfig.maxSourceZoom); expect(call.args[2]!.layerName).toEqual(defaultConfig.layerName); - expect(call.args[2]!.urlTemplate!.startsWith(defaultConfig.urlTemplate)).toEqual(true); + expect(call.args[2]!.urlTemplate).toEqual(defaultConfig.urlTemplate); }); it('Should not resync when no changes to source params', async () => { @@ -193,8 +208,34 @@ describe('syncData', () => { expect(call.args[2]!.minSourceZoom).toEqual(newMeta.minSourceZoom); expect(call.args[2]!.maxSourceZoom).toEqual(newMeta.maxSourceZoom); expect(call.args[2]!.layerName).toEqual(newMeta.layerName); - expect(call.args[2]!.urlTemplate!.startsWith(newMeta.urlTemplate)).toEqual(true); + expect(call.args[2]!.urlTemplate).toEqual(newMeta.urlTemplate); }); }); }); + + describe('refresh token', () => { + const uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/; + + it(`should add token in url`, async () => { + const layer: TiledVectorLayer = createLayer({}, {}, false, true); + + const syncContext = new MockSyncContext({ dataFilters: {} }); + + await layer.syncData(syncContext); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.startLoading); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.stopLoading); + + // @ts-expect-error + const call = syncContext.stopLoading.getCall(0); + expect(call.args[2]!.minSourceZoom).toEqual(defaultConfig.minSourceZoom); + expect(call.args[2]!.maxSourceZoom).toEqual(defaultConfig.maxSourceZoom); + expect(call.args[2]!.layerName).toEqual(defaultConfig.layerName); + expect(call.args[2]!.urlTemplate.startsWith(defaultConfig.urlTemplate)).toBe(true); + + const parsedUrl = url.parse(call.args[2]!.urlTemplate, true); + expect(!!(parsedUrl.query.token! as string).match(uuidRegex)).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx index 90c4896f2a287..d452096250576 100644 --- a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx @@ -14,10 +14,11 @@ import { import { EuiIcon } from '@elastic/eui'; import { Feature } from 'geojson'; import uuid from 'uuid/v4'; +import { parse as parseUrl } from 'url'; import { IVectorStyle, VectorStyle } from '../../styles/vector/vector_style'; import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE } from '../../../../common/constants'; import { VectorLayer, VectorLayerArguments } from '../vector_layer'; -import { ITiledSingleLayerVectorSource } from '../../sources/vector_source'; +import { ITiledSingleLayerVectorSource } from '../../sources/tiled_single_layer_vector_source'; import { DataRequestContext } from '../../../actions'; import { VectorLayerDescriptor, @@ -103,10 +104,20 @@ export class TiledVectorLayer extends VectorLayer { : prevData.urlToken; const newUrlTemplateAndMeta = await this._source.getUrlTemplateWithMeta(searchFilters); + + let urlTemplate; + if (newUrlTemplateAndMeta.refreshTokenParamName) { + const parsedUrl = parseUrl(newUrlTemplateAndMeta.urlTemplate, true); + const separator = !parsedUrl.query || Object.keys(parsedUrl.query).length === 0 ? '?' : '&'; + urlTemplate = `${newUrlTemplateAndMeta.urlTemplate}${separator}${newUrlTemplateAndMeta.refreshTokenParamName}=${urlToken}`; + } else { + urlTemplate = newUrlTemplateAndMeta.urlTemplate; + } + const urlTemplateAndMetaWithToken = { ...newUrlTemplateAndMeta, urlToken, - urlTemplate: newUrlTemplateAndMeta.urlTemplate + `&token=${urlToken}`, + urlTemplate, }; stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, urlTemplateAndMetaWithToken, {}); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx index c19ded6c2593e..22b873a94d1f7 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx @@ -12,13 +12,8 @@ import { Adapters } from 'src/plugins/inspector/public'; import { FileLayer } from '@elastic/ems-client'; import { Attribution, ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { AbstractVectorSource, GeoJsonWithMeta, IVectorSource } from '../vector_source'; -import { - SOURCE_TYPES, - FIELD_ORIGIN, - VECTOR_SHAPE_TYPE, - FORMAT_TYPE, -} from '../../../../common/constants'; -import { fetchGeoJson, getEmsFileLayers } from '../../../util'; +import { SOURCE_TYPES, FIELD_ORIGIN, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { getEmsFileLayers } from '../../../util'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { UpdateSourceEditor } from './update_source_editor'; import { EMSFileField } from '../../fields/ems_file_field'; @@ -122,24 +117,26 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc } async getGeoJsonWithMeta(): Promise { - const emsFileLayer = await this.getEMSFileLayer(); - const featureCollection = await fetchGeoJson( - emsFileLayer.getDefaultFormatUrl(), - emsFileLayer.getDefaultFormatType() as FORMAT_TYPE, - 'data' - ); + try { + const emsFileLayer = await this.getEMSFileLayer(); + const featureCollection = await emsFileLayer.getGeoJson(); - const emsIdField = emsFileLayer.getFields().find((field) => { - return field.type === 'id'; - }); - featureCollection.features.forEach((feature: Feature, index: number) => { - feature.id = emsIdField ? feature!.properties![emsIdField.id] : index; - }); + if (!featureCollection) throw new Error('No features found'); - return { - data: featureCollection, - meta: {}, - }; + const emsIdField = emsFileLayer.getFields().find((field) => { + return field.type === 'id'; + }); + featureCollection.features.forEach((feature: Feature, index: number) => { + feature.id = emsIdField ? feature!.properties![emsIdField.id] : index; + }); + + return { + data: featureCollection, + meta: {}, + }; + } catch (error) { + throw new Error(`${getErrorInfo(this._descriptor.id)} - ${error.message}`); + } } async getImmutableProperties(): Promise { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index e9cf62d8f4089..7bca22df9b870 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -27,6 +27,7 @@ import { GRID_RESOLUTION, MVT_GETGRIDTILE_API_PATH, MVT_SOURCE_LAYER_NAME, + MVT_TOKEN_PARAM_NAME, RENDER_AS, SOURCE_TYPES, VECTOR_SHAPE_TYPE, @@ -38,7 +39,8 @@ import { registerSource } from '../source_registry'; import { LICENSED_FEATURES } from '../../../licensed_features'; import { getHttp } from '../../../kibana_services'; -import { GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; +import { GeoJsonWithMeta } from '../vector_source'; +import { ITiledSingleLayerVectorSource } from '../tiled_single_layer_vector_source'; import { ESGeoGridSourceDescriptor, MapExtent, @@ -50,6 +52,7 @@ import { ISearchSource } from '../../../../../../../src/plugins/data/common/sear import { IndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { isValidStringConfig } from '../../util/valid_string_config'; +import { ITiledSingleLayerMvtParams } from '../tiled_single_layer_vector_source/tiled_single_layer_vector_source'; export const MAX_GEOTILE_LEVEL = 29; @@ -420,12 +423,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle async getUrlTemplateWithMeta( searchFilters: VectorSourceRequestMeta - ): Promise<{ - layerName: string; - urlTemplate: string; - minSourceZoom: number; - maxSourceZoom: number; - }> { + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); @@ -453,6 +451,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle &geoFieldType=${geoField.type}`; return { + refreshTokenParamName: MVT_TOKEN_PARAM_NAME, layerName: this.getLayerName(), minSourceZoom: this.getMinZoom(), maxSourceZoom: this.getMaxZoom(), diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index ff4675413985c..3de98fd545827 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -31,6 +31,7 @@ import { GIS_API_PATH, MVT_GETTILE_API_PATH, MVT_SOURCE_LAYER_NAME, + MVT_TOKEN_PARAM_NAME, SCALING_TYPES, SOURCE_TYPES, VECTOR_SHAPE_TYPE, @@ -51,17 +52,15 @@ import { import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { IField } from '../../fields/field'; -import { - GeoJsonWithMeta, - ITiledSingleLayerVectorSource, - SourceTooltipConfig, -} from '../vector_source'; +import { GeoJsonWithMeta, SourceTooltipConfig } from '../vector_source'; +import { ITiledSingleLayerVectorSource } from '../tiled_single_layer_vector_source'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; import { DataRequest } from '../../util/data_request'; import { SortDirection, SortDirectionNumeric } from '../../../../../../../src/plugins/data/common'; import { isValidStringConfig } from '../../util/valid_string_config'; import { TopHitsUpdateSourceEditor } from './top_hits'; import { getDocValueAndSourceFields, ScriptField } from './get_docvalue_source_fields'; +import { ITiledSingleLayerMvtParams } from '../tiled_single_layer_vector_source/tiled_single_layer_vector_source'; export const sourceTitle = i18n.translate('xpack.maps.source.esSearchTitle', { defaultMessage: 'Documents', @@ -674,12 +673,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye async getUrlTemplateWithMeta( searchFilters: VectorSourceRequestMeta - ): Promise<{ - layerName: string; - urlTemplate: string; - minSourceZoom: number; - maxSourceZoom: number; - }> { + ): Promise { const indexPattern = await this.getIndexPattern(); const indexSettings = await loadIndexSettings(indexPattern.title); @@ -722,6 +716,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye &geoFieldType=${geoField.type}`; return { + refreshTokenParamName: MVT_TOKEN_PARAM_NAME, layerName: this.getLayerName(), minSourceZoom: this.getMinZoom(), maxSourceZoom: this.getMaxZoom(), diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index 4e4d9e9eee5d2..92b643643ba2a 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -10,7 +10,8 @@ import uuid from 'uuid/v4'; import React from 'react'; import { GeoJsonProperties } from 'geojson'; import { AbstractSource, ImmutableSourceProperty, SourceEditorArgs } from '../source'; -import { BoundsFilters, GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; +import { BoundsFilters, GeoJsonWithMeta } from '../vector_source'; +import { ITiledSingleLayerVectorSource } from '../tiled_single_layer_vector_source'; import { FIELD_ORIGIN, MAX_ZOOM, @@ -30,6 +31,7 @@ import { MVTField } from '../../fields/mvt_field'; import { UpdateSourceEditor } from './update_source_editor'; import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; +import { ITiledSingleLayerMvtParams } from '../tiled_single_layer_vector_source/tiled_single_layer_vector_source'; export const sourceTitle = i18n.translate( 'xpack.maps.source.MVTSingleLayerVectorSource.sourceTitle', @@ -154,7 +156,7 @@ export class MVTSingleLayerVectorSource return this.getLayerName(); } - async getUrlTemplateWithMeta() { + async getUrlTemplateWithMeta(): Promise { return { urlTemplate: this._descriptor.urlTemplate, layerName: this._descriptor.layerName, diff --git a/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts new file mode 100644 index 0000000000000..30177751a8d55 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { ITiledSingleLayerVectorSource } from './tiled_single_layer_vector_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts new file mode 100644 index 0000000000000..013c3f9f0d7e1 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { VectorSourceRequestMeta } from '../../../../common/descriptor_types'; +import { IVectorSource } from '../vector_source'; + +export interface ITiledSingleLayerMvtParams { + layerName: string; + urlTemplate: string; + minSourceZoom: number; + maxSourceZoom: number; + refreshTokenParamName?: string; +} + +export interface ITiledSingleLayerVectorSource extends IVectorSource { + getUrlTemplateWithMeta( + searchFilters: VectorSourceRequestMeta + ): Promise; + getMinZoom(): number; + getMaxZoom(): number; + getLayerName(): string; +} diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx index e86e459851c70..b28cd7365d69e 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx @@ -66,20 +66,6 @@ export interface IVectorSource extends ISource { getSourceTooltipContent(sourceDataRequest?: DataRequest): SourceTooltipConfig; } -export interface ITiledSingleLayerVectorSource extends IVectorSource { - getUrlTemplateWithMeta( - searchFilters: VectorSourceRequestMeta - ): Promise<{ - layerName: string; - urlTemplate: string; - minSourceZoom: number; - maxSourceZoom: number; - }>; - getMinZoom(): number; - getMaxZoom(): number; - getLayerName(): string; -} - export class AbstractVectorSource extends AbstractSource implements IVectorSource { getFieldNames(): string[] { return []; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap index be8c9b0750b94..64da5777988d1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap @@ -384,6 +384,546 @@ exports[`should render 1`] = ` `; +exports[`should render line-style with label properties when ES-source is rendered as mvt 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`should render polygon-style without label properties when 3rd party mvt 1`] = ` + + + + + + + + + + + +`; + exports[`should render with no style fields 1`] = ` { class MockField extends AbstractField {} -function createLayerMock(numFields: number, supportedShapeTypes: VECTOR_SHAPE_TYPE[]) { +function createLayerMock( + numFields: number, + supportedShapeTypes: VECTOR_SHAPE_TYPE[], + layerType: LAYER_TYPE = LAYER_TYPE.VECTOR, + isESSource: boolean = false +) { const fields: IField[] = []; for (let i = 0; i < numFields; i++) { fields.push(new MockField({ fieldName: `field${i}`, origin: FIELD_ORIGIN.SOURCE })); @@ -39,11 +45,17 @@ function createLayerMock(numFields: number, supportedShapeTypes: VECTOR_SHAPE_TY getStyleEditorFields: async () => { return fields; }, + getType() { + return layerType; + }, getSource: () => { return ({ getSupportedShapeTypes: async () => { return supportedShapeTypes; }, + isESSource() { + return isESSource; + }, } as unknown) as IVectorSource; }, } as unknown) as IVectorLayer; @@ -99,3 +111,35 @@ test('should render with no style fields', async () => { expect(component).toMatchSnapshot(); }); + +test('should render polygon-style without label properties when 3rd party mvt', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); + +test('should render line-style with label properties when ES-source is rendered as mvt', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx index 91bcc2dc06859..4fb2887c52876 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx @@ -9,7 +9,7 @@ import _ from 'lodash'; import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { EuiButtonGroup, EuiFormRow, EuiSpacer, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; // @ts-expect-error @@ -25,9 +25,10 @@ import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; import { LABEL_BORDER_SIZES, - VECTOR_STYLES, + LAYER_TYPE, STYLE_TYPE, VECTOR_SHAPE_TYPE, + VECTOR_STYLES, } from '../../../../../common/constants'; import { createStyleFieldsHelper, StyleField, StyleFieldsHelper } from '../style_fields_helper'; import { @@ -257,7 +258,18 @@ export class VectorStyleEditor extends Component { ); } - _renderLabelProperties() { + _renderLabelProperties(isPoints: boolean) { + if ( + !isPoints && + this.props.layer.getType() === LAYER_TYPE.TILED_VECTOR && + !this.props.layer.getSource().isESSource() + ) { + // This handles and edge-case + // 3rd party lines and polygons from mvt sources cannot be labeled, because they do not have label-centroid geometries inside the tile. + // These label-centroids are only added for ES-sources + return; + } + const hasLabel = this._hasLabel(); const hasLabelBorder = this._hasLabelBorder(); return ( @@ -456,7 +468,7 @@ export class VectorStyleEditor extends Component { /> - {this._renderLabelProperties()} + {this._renderLabelProperties(true)} ); } @@ -470,7 +482,7 @@ export class VectorStyleEditor extends Component { {this._renderLineWidth()} - {this._renderLabelProperties()} + {this._renderLabelProperties(false)} ); } @@ -487,7 +499,7 @@ export class VectorStyleEditor extends Component { {this._renderLineWidth()} - {this._renderLabelProperties()} + {this._renderLabelProperties(false)} ); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts index f77a73e531029..41877406f7489 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts @@ -59,6 +59,8 @@ export class AbstractStyleProperty implements IStyleProperty { return ''; } else if (typeof value === 'boolean') { return value.toString(); + } else if (Array.isArray(value)) { + return value.join(', '); } else { return value; } diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index a1a65796dc94a..2a6e1a8982e63 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -1,6 +1,6 @@ @import 'map_container/map_container'; @import 'layer_panel/index'; -@import 'widget_overlay/index'; +@import 'right_side_controls/index'; @import 'toolbar_overlay/index'; @import 'mb_map/features_tooltip/index'; @import 'mb_map/scale_control/index'; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 525ba394ed503..e0cfe978bf45c 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -14,8 +14,7 @@ import uuid from 'uuid/v4'; import { Filter } from 'src/plugins/data/public'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; import { MBMap } from '../mb_map'; -// @ts-expect-error -import { WidgetOverlay } from '../widget_overlay'; +import { RightSideControls } from '../right_side_controls'; import { ToolbarOverlay } from '../toolbar_overlay'; // @ts-expect-error import { LayerPanel } from '../layer_panel'; @@ -263,7 +262,7 @@ export class MapContainer extends Component { getActionContext={getActionContext} /> )} - + void; + geometry: Geometry; + geoFields: GeoFieldWithIndex[]; + addFilters: (filters: Filter[], actionId: string) => Promise; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; + loadPreIndexedShape: () => Promise; +} + +interface State { + isLoading: boolean; + errorMsg: string | undefined; +} + +export class FeatureGeometryFilterForm extends Component { + private _isMounted = false; + state: State = { isLoading: false, errorMsg: undefined, }; @@ -52,7 +75,17 @@ export class FeatureGeometryFilterForm extends Component { return preIndexedShape; }; - _createFilter = async ({ geometryLabel, indexPatternId, geoFieldName, relation }) => { + _createFilter = async ({ + geometryLabel, + indexPatternId, + geoFieldName, + relation, + }: { + geometryLabel: string; + indexPatternId: string; + geoFieldName: string; + relation: ES_SPATIAL_RELATIONS; + }) => { this.setState({ errorMsg: undefined }); const preIndexedShape = await this._loadPreIndexedShape(); if (!this._isMounted) { @@ -62,7 +95,7 @@ export class FeatureGeometryFilterForm extends Component { const filter = createSpatialFilterWithGeometry({ preIndexedShape, - geometry: this.props.geometry, + geometry: this.props.geometry as Polygon, geometryLabel, indexPatternId, geoFieldName, @@ -72,7 +105,7 @@ export class FeatureGeometryFilterForm extends Component { // Ensure filter will not overflow URL. Filters that contain geometry can be extremely large. // No elasticsearch support for pre-indexed shapes and geo_point spatial queries. if ( - window.location.href.length + rison.encode(filter).length + META_OVERHEAD > + window.location.href.length + rison.encode(filter as RisonObject).length + META_OVERHEAD > URL_MAX_LENGTH ) { this.setState({ diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.tsx similarity index 72% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.tsx index e7a2024afb98a..c999e9e6705cc 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.tsx @@ -9,9 +9,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { FeatureProperties } from './feature_properties'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; +import { ITooltipProperty } from '../../../classes/tooltips/tooltip_property'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; class MockTooltipProperty { - constructor(key, value, isFilterable) { + private _key: string; + private _value: string; + private _isFilterable: boolean; + + constructor(key: string, value: string, isFilterable: boolean) { this._key = key; this._value = value; this._isFilterable = isFilterable; @@ -31,21 +37,27 @@ class MockTooltipProperty { } const defaultProps = { - loadFeatureProperties: () => { + loadFeatureProperties: async () => { return []; }, featureId: `feature`, layerId: `layer`, + mbProperties: {}, onCloseTooltip: () => {}, showFilterButtons: false, - getFilterActions: () => { - return [{ id: ACTION_GLOBAL_APPLY_FILTER }]; + addFilters: async () => {}, + getActionContext: () => { + return ({} as unknown) as ActionExecutionContext; + }, + getFilterActions: async () => { + return [({ id: ACTION_GLOBAL_APPLY_FILTER } as unknown) as Action]; }, + showFilterActions: () => {}, }; const mockTooltipProperties = [ - new MockTooltipProperty('prop1', 'foobar1', true), - new MockTooltipProperty('prop2', 'foobar2', false), + (new MockTooltipProperty('prop1', 'foobar1', true) as unknown) as ITooltipProperty, + (new MockTooltipProperty('prop2', 'foobar2', false) as unknown) as ITooltipProperty, ]; describe('FeatureProperties', () => { @@ -53,7 +65,7 @@ describe('FeatureProperties', () => { const component = shallow( { + loadFeatureProperties={async () => { return mockTooltipProperties; }} /> @@ -72,7 +84,7 @@ describe('FeatureProperties', () => { { + loadFeatureProperties={async () => { return mockTooltipProperties; }} /> @@ -91,11 +103,11 @@ describe('FeatureProperties', () => { { + loadFeatureProperties={async () => { return mockTooltipProperties; }} - getFilterActions={() => { - return [{ id: 'drilldown1' }]; + getFilterActions={async () => { + return [({ id: 'drilldown1' } as unknown) as Action]; }} /> ); @@ -113,7 +125,7 @@ describe('FeatureProperties', () => { { + loadFeatureProperties={async () => { throw new Error('Simulated load properties error'); }} /> diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.tsx similarity index 65% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.tsx index 2bd1d5c9cacf5..d221d4d5b1ca5 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { Component, CSSProperties, RefObject, ReactNode } from 'react'; import { EuiCallOut, EuiLoadingSpinner, @@ -15,11 +15,51 @@ import { EuiContextMenu, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { GeoJsonProperties } from 'geojson'; +import { Filter } from 'src/plugins/data/public'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; import { isUrlDrilldown } from '../../../trigger_actions/trigger_utils'; +import { RawValue } from '../../../../common/constants'; +import { ITooltipProperty } from '../../../classes/tooltips/tooltip_property'; -export class FeatureProperties extends React.Component { - state = { +interface Props { + featureId?: string | number; + layerId: string; + mbProperties: GeoJsonProperties; + loadFeatureProperties: ({ + layerId, + featureId, + mbProperties, + }: { + layerId: string; + featureId?: string | number; + mbProperties: GeoJsonProperties; + }) => Promise; + showFilterButtons: boolean; + onCloseTooltip: () => void; + addFilters: ((filters: Filter[], actionId: string) => Promise) | null; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; + onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void; + showFilterActions: (view: ReactNode) => void; +} + +interface State { + properties: ITooltipProperty[] | null; + actions: Action[]; + loadPropertiesErrorMsg: string | null; + prevWidth: number | null; + prevHeight: number | null; +} + +export class FeatureProperties extends Component { + private _isMounted = false; + private _prevLayerId: string = ''; + private _prevFeatureId?: string | number = ''; + private readonly _tableRef: RefObject = React.createRef(); + + state: State = { properties: null, actions: [], loadPropertiesErrorMsg: null, @@ -29,8 +69,6 @@ export class FeatureProperties extends React.Component { componentDidMount() { this._isMounted = true; - this.prevLayerId = undefined; - this.prevFeatureId = undefined; this._loadProperties(); this._loadActions(); } @@ -61,28 +99,42 @@ export class FeatureProperties extends React.Component { }); }; - _showFilterActions = (tooltipProperty) => { - this.props.showFilterActions(this._renderFilterActions(tooltipProperty)); + _showFilterActions = ( + tooltipProperty: ITooltipProperty, + getActionContext: () => ActionExecutionContext, + addFilters: (filters: Filter[], actionId: string) => Promise + ) => { + this.props.showFilterActions( + this._renderFilterActions(tooltipProperty, getActionContext, addFilters) + ); }; - _fetchProperties = async ({ nextLayerId, nextFeatureId, mbProperties }) => { - if (this.prevLayerId === nextLayerId && this.prevFeatureId === nextFeatureId) { + _fetchProperties = async ({ + nextLayerId, + nextFeatureId, + mbProperties, + }: { + nextLayerId: string; + nextFeatureId?: string | number; + mbProperties: GeoJsonProperties; + }) => { + if (this._prevLayerId === nextLayerId && this._prevFeatureId === nextFeatureId) { // do not reload same feature properties return; } - this.prevLayerId = nextLayerId; - this.prevFeatureId = nextFeatureId; + this._prevLayerId = nextLayerId; + this._prevFeatureId = nextFeatureId; this.setState({ - properties: undefined, - loadPropertiesErrorMsg: undefined, + properties: null, + loadPropertiesErrorMsg: null, }); // Preserve current properties width/height so they can be used while rendering loading indicator. - if (this.state.properties && this._node) { + if (this.state.properties && this._tableRef.current) { this.setState({ - prevWidth: this._node.clientWidth, - prevHeight: this._node.clientHeight, + prevWidth: this._tableRef.current.clientWidth, + prevHeight: this._tableRef.current.clientHeight, }); } @@ -91,7 +143,7 @@ export class FeatureProperties extends React.Component { properties = await this.props.loadFeatureProperties({ layerId: nextLayerId, featureId: nextFeatureId, - mbProperties: mbProperties, + mbProperties, }); } catch (error) { if (this._isMounted) { @@ -103,7 +155,7 @@ export class FeatureProperties extends React.Component { return; } - if (this.prevLayerId !== nextLayerId && this.prevFeatureId !== nextFeatureId) { + if (this._prevLayerId !== nextLayerId && this._prevFeatureId !== nextFeatureId) { // ignore results for old request return; } @@ -113,7 +165,11 @@ export class FeatureProperties extends React.Component { } }; - _renderFilterActions(tooltipProperty) { + _renderFilterActions( + tooltipProperty: ITooltipProperty, + getActionContext: () => ActionExecutionContext, + addFilters: (filters: Filter[], actionId: string) => Promise + ) { const panel = { id: 0, items: this.state.actions @@ -124,24 +180,24 @@ export class FeatureProperties extends React.Component { return true; }) .map((action) => { - const actionContext = this.props.getActionContext(); + const actionContext = getActionContext(); const iconType = action.getIconType(actionContext); const name = action.getDisplayName(actionContext); return { name: name ? name : action.id, - icon: iconType ? : null, + icon: iconType ? : undefined, onClick: async () => { this.props.onCloseTooltip(); if (isUrlDrilldown(action)) { - this.props.onSingleValueTrigger( + this.props.onSingleValueTrigger!( action.id, tooltipProperty.getPropertyKey(), tooltipProperty.getRawValue() ); } else { const filters = await tooltipProperty.getESFilters(); - this.props.addFilters(filters, action.id); + addFilters(filters, action.id); } }, ['data-test-subj']: `mapFilterActionButton__${name}`, @@ -151,10 +207,7 @@ export class FeatureProperties extends React.Component { return (
- (this._node = node)} - > +
@@ -178,8 +231,12 @@ export class FeatureProperties extends React.Component { ); } - _renderFilterCell(tooltipProperty) { - if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) { + _renderFilterCell(tooltipProperty: ITooltipProperty) { + if ( + !this.props.showFilterButtons || + !tooltipProperty.isFilterable() || + this.props.addFilters === undefined + ) { return @@ -217,7 +275,11 @@ export class FeatureProperties extends React.Component { defaultMessage: 'View filter actions', })} onClick={() => { - this._showFilterActions(tooltipProperty); + this._showFilterActions( + tooltipProperty, + this.props.getActionContext!, + this.props.addFilters! + ); }} aria-label={i18n.translate('xpack.maps.tooltip.viewActionsTitle', { defaultMessage: 'View filter actions', @@ -253,7 +315,7 @@ export class FeatureProperties extends React.Component { }); // Use width/height of last viewed properties while displaying loading status // to avoid resizing component during loading phase and bouncing tooltip container around - const style = {}; + const style: CSSProperties = {}; if (this.state.prevWidth && this.state.prevHeight) { style.width = this.state.prevWidth; style.height = this.state.prevHeight; @@ -279,7 +341,7 @@ export class FeatureProperties extends React.Component { * Since these formatters produce raw HTML, this component needs to be able to render them as-is, relying * on the field formatter to only produce safe HTML. */ - dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} //eslint-disable-line react/no-danger + dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} // eslint-disable-line react/no-danger /> {this._renderFilterCell(tooltipProperty)} @@ -287,10 +349,7 @@ export class FeatureProperties extends React.Component { }); return ( -
@@ -168,7 +221,7 @@ export class FeatureProperties extends React.Component { * Since these formatters produce raw HTML, this component needs to be able to render them as-is, relying * on the field formatter to only produce safe HTML. */ - dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} //eslint-disable-line react/no-danger + dangerouslySetInnerHTML={{ __html: tooltipProperty.getHtmlDisplayValue() }} // eslint-disable-line react/no-danger />
; } @@ -192,7 +249,7 @@ export class FeatureProperties extends React.Component { onClick={async () => { this.props.onCloseTooltip(); const filters = await tooltipProperty.getESFilters(); - this.props.addFilters(filters, ACTION_GLOBAL_APPLY_FILTER); + this.props.addFilters!(filters, ACTION_GLOBAL_APPLY_FILTER); }} aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { defaultMessage: 'Filter on property', @@ -203,7 +260,8 @@ export class FeatureProperties extends React.Component { ); - return this.state.actions.length === 0 || + return this.props.getActionContext === undefined || + this.state.actions.length === 0 || (this.state.actions.length === 1 && this.state.actions[0].id === ACTION_GLOBAL_APPLY_FILTER) ? ( {applyFilterButton}
(this._node = node)} - > +
{rows}
); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.tsx similarity index 66% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.tsx index be8e960471efa..41a2b98ab4b28 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.tsx @@ -5,26 +5,82 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component, Fragment, ReactNode } from 'react'; import { EuiIcon, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { GeoJsonProperties, Geometry } from 'geojson'; +import { Filter } from 'src/plugins/data/public'; import { FeatureProperties } from './feature_properties'; -import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE } from '../../../../common/constants'; +import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE, RawValue } from '../../../../common/constants'; import { FeatureGeometryFilterForm } from './feature_geometry_filter_form'; import { Footer } from './footer'; import { Header } from './header'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { PreIndexedShape } from '../../../../common/elasticsearch_util'; +import { GeoFieldWithIndex } from '../../../components/geo_field_with_index'; +import { TooltipFeature } from '../../../../common/descriptor_types'; +import { ITooltipProperty } from '../../../classes/tooltips/tooltip_property'; +import { ILayer } from '../../../classes/layers/layer'; -const VIEWS = { - PROPERTIES_VIEW: 'PROPERTIES_VIEW', - GEOMETRY_FILTER_VIEW: 'GEOMETRY_FILTER_VIEW', - FILTER_ACTIONS_VIEW: 'FILTER_ACTIONS_VIEW', -}; +enum VIEWS { + PROPERTIES_VIEW = 'PROPERTIES_VIEW', + GEOMETRY_FILTER_VIEW = 'GEOMETRY_FILTER_VIEW', + FILTER_ACTIONS_VIEW = 'FILTER_ACTIONS_VIEW', +} -export class FeaturesTooltip extends Component { - state = {}; +interface Props { + addFilters: ((filters: Filter[], actionId: string) => Promise) | null; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; + onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void; + closeTooltip: () => void; + features: TooltipFeature[]; + isLocked: boolean; + loadFeatureProperties: ({ + layerId, + featureId, + mbProperties, + }: { + layerId: string; + featureId?: string | number; + mbProperties: GeoJsonProperties; + }) => Promise; + loadFeatureGeometry: ({ + layerId, + featureId, + }: { + layerId: string; + featureId?: string | number; + }) => Geometry | null; + getLayerName: (layerId: string) => Promise; + findLayerById: (layerId: string) => ILayer | undefined; + geoFields: GeoFieldWithIndex[]; + loadPreIndexedShape: ({ + layerId, + featureId, + }: { + layerId: string; + featureId?: string | number; + }) => Promise; +} - static getDerivedStateFromProps(nextProps, prevState) { +interface State { + currentFeature: TooltipFeature | null; + filterView: ReactNode | null; + prevFeatures: TooltipFeature[]; + view: VIEWS; +} + +export class FeaturesTooltip extends Component { + state: State = { + currentFeature: null, + filterView: null, + prevFeatures: [], + view: VIEWS.PROPERTIES_VIEW, + }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { if (nextProps.features !== prevState.prevFeatures) { return { currentFeature: nextProps.features ? nextProps.features[0] : null, @@ -36,7 +92,7 @@ export class FeaturesTooltip extends Component { return null; } - _setCurrentFeature = (feature) => { + _setCurrentFeature = (feature: TooltipFeature) => { this.setState({ currentFeature: feature }); }; @@ -48,11 +104,11 @@ export class FeaturesTooltip extends Component { this.setState({ view: VIEWS.PROPERTIES_VIEW, filterView: null }); }; - _showFilterActionsView = (filterView) => { + _showFilterActionsView = (filterView: ReactNode) => { this.setState({ view: VIEWS.FILTER_ACTIONS_VIEW, filterView }); }; - _renderActions(geoFields) { + _renderActions(geoFields: GeoFieldWithIndex[]) { if (!this.props.isLocked || geoFields.length === 0) { return null; } @@ -67,7 +123,7 @@ export class FeaturesTooltip extends Component { ); } - _filterGeoFields(featureGeometry) { + _filterGeoFields(featureGeometry: Geometry | null) { if (!featureGeometry) { return []; } @@ -93,9 +149,9 @@ export class FeaturesTooltip extends Component { return this.props.geoFields; } - _loadCurrentFeaturePreIndexedShape = () => { + _loadCurrentFeaturePreIndexedShape = async () => { if (!this.state.currentFeature) { - return; + return null; } return this.props.loadPreIndexedShape({ @@ -104,7 +160,7 @@ export class FeaturesTooltip extends Component { }); }; - _renderBackButton(label) { + _renderBackButton(label: string) { return (

@@ -121,22 +170,8 @@ export function ApmPanel(props) {

- - + + {link}

diff --git a/x-pack/plugins/monitoring/public/components/cluster/overview/beats_panel.js b/x-pack/plugins/monitoring/public/components/cluster/overview/beats_panel.js index c907a11d45874..c882497ea04ea 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/overview/beats_panel.js +++ b/x-pack/plugins/monitoring/public/components/cluster/overview/beats_panel.js @@ -48,7 +48,7 @@ export function BeatsPanel(props) { /> ) : null; - const beatTypes = props.beats.types.map((beat, index) => { + const beatTypes = get(props, 'beats.types', []).map((beat, index) => { return [ @@ -437,8 +437,16 @@ export function ElasticsearchPanel(props) { @@ -489,7 +497,7 @@ export function ElasticsearchPanel(props) { data-test-subj="esDocumentsCount" className="eui-textBreakWord" > - {formatNumber(get(indices, 'docs.count'), 'int_commas')} + {formatNumber(get(indices, 'docs.total', get(indices, 'docs.count')), 'int_commas')} @@ -499,7 +507,10 @@ export function ElasticsearchPanel(props) { /> - {formatNumber(get(indices, 'store.size_in_bytes'), 'byte')} + {formatNumber( + get(indices, 'store.size.bytes', get(indices, 'store.size_in_bytes')), + 'byte' + )} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap b/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap index 81398c1d8e836..72ff704916e28 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap @@ -47,13 +47,7 @@ exports[`CcrShard that is renders an exception properly 1`] = ` `; exports[`CcrShard that it renders normally 1`] = ` - + + - + type="button" + > +
@@ -78,13 +84,19 @@ exports[`Node Listing Metric Cell should format a percentage metric 1`] = `
- + type="button" + > +
diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js index 465e9f1e49a5a..528b3bed3df7b 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js @@ -11,10 +11,9 @@ import { formatMetric } from '../../../lib/format_number'; import { EuiText, EuiPopover, - EuiIcon, + EuiButtonIcon, EuiDescriptionList, EuiSpacer, - EuiKeyboardAccessible, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; @@ -40,7 +39,7 @@ const getDirection = (slope) => { const getIcon = (slope) => { if (slope || slope === 0) { - return slope > 0 ? 'arrowUp' : 'arrowDown'; + return slope > 0 ? 'sortUp' : 'sortDown'; } return null; }; @@ -83,17 +82,22 @@ function MetricCell({ isOnline, metric = {}, isPercent, ...props }) { }, ]; + const iconLabel = i18n.translate( + 'xpack.monitoring.elasticsearch.node.cells.tooltip.iconLabel', + { + defaultMessage: 'More information about this metric', + } + ); + const button = ( - - - + ); return ( diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/assigned.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/assigned.js index 768da085718dd..6815d0f441d49 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/assigned.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/assigned.js @@ -31,8 +31,16 @@ function sortByName(item) { export class Assigned extends React.Component { createShard = (shard) => { - const type = shard.primary ? 'primary' : 'replica'; - const key = `${shard.index}.${shard.node}.${type}.${shard.state}.${shard.shard}`; + const type = get(shard, 'shard.primary', shard.primary) ? 'primary' : 'replica'; + const key = `${get(shard, 'index.name', shard.index)}.${get( + shard, + 'node.name', + shard.node + )}.${type}.${get(shard, 'shard.state', shard.state)}.${get( + shard, + 'shard.number', + shard.shard + )}`; return ; }; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/shard.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/shard.js index ae82a28d0508d..a81e1f8db5ec8 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/shard.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/shard.js @@ -6,6 +6,7 @@ */ import React from 'react'; +import { get } from 'lodash'; import { calculateClass } from '../lib/calculate_class'; import { vents } from '../lib/vents'; import { i18n } from '@kbn/i18n'; @@ -65,9 +66,11 @@ export class Shard extends React.Component { generateKey = (relocating) => { const shard = this.props.shard; - const shardType = shard.primary ? 'primary' : 'replica'; - const additionId = shard.state === 'UNASSIGNED' ? Math.random() : ''; - const node = relocating ? shard.relocating_node : shard.node; + const shardType = get(shard, 'shard.primary', shard.primary) ? 'primary' : 'replica'; + const additionId = get(shard, 'shard.state', shard.state) === 'UNASSIGNED' ? Math.random() : ''; + const node = relocating + ? get(shard, 'relocation_node.uuid', shard.relocating_node) + : get(shard, 'shard.name', shard.node); return shard.index + '.' + node + '.' + shardType + '.' + shard.shard + additionId; }; @@ -93,9 +96,9 @@ export class Shard extends React.Component { const shard = this.props.shard; const classes = calculateClass(shard); const color = getColor(classes); - const classification = classes + ' ' + shard.shard; + const classification = classes + ' ' + get(shard, 'shard.number', shard.shard); - let shardUi = {shard.shard}; + let shardUi = {get(shard, 'shard.number', shard.shard)}; const tooltipContent = shard.tooltip_message || i18n.translate('xpack.monitoring.elasticsearch.shardAllocation.shardDisplayName', { diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/calculate_class.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/calculate_class.js index bb08661b788df..f15460421fd8f 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/calculate_class.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/calculate_class.js @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { get } from 'lodash'; export function calculateClass(item, initial) { const classes = [item.type]; @@ -12,9 +13,16 @@ export function calculateClass(item, initial) { } if (item.type === 'shard') { classes.push('monShard'); - classes.push((item.primary && 'primary') || 'replica'); - classes.push(item.state.toLowerCase()); - if (item.state === 'UNASSIGNED' && item.primary) { + if (get(item, 'shard.primary', item.primary)) { + classes.push('primary'); + } else { + classes.push('replica'); + } + classes.push(get(item, 'shard.state', item.state).toLowerCase()); + if ( + get(item, 'shard.state', item.state) === 'UNASSIGNED' && + get(item, 'shard.primary', item.primary) + ) { classes.push('emergency'); } } diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/decorate_shards.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/decorate_shards.js index 56b06cf1dfe6b..aedf9416d6ff8 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/decorate_shards.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/lib/decorate_shards.js @@ -41,7 +41,8 @@ export function decorateShards(shards, nodes) { ); } } - return upperFirst(shard.state.toLowerCase()); + const state = get(shard, 'state', get(shard, 'shard.state')); + return upperFirst(state.toLowerCase()); } return shards.map((shard) => { diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.js index 46ed71d90ee7f..7e6833d233361 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.js @@ -5,7 +5,7 @@ * 2.0. */ -import { find, some, reduce, values, sortBy } from 'lodash'; +import { find, some, reduce, values, sortBy, get } from 'lodash'; import { hasPrimaryChildren } from '../lib/has_primary_children'; import { decorateShards } from '../lib/decorate_shards'; @@ -28,8 +28,8 @@ export function nodesByIndices() { } function createIndexAddShard(obj, shard) { - const node = shard.node || 'unassigned'; - const index = shard.index; + const node = get(shard, 'node.name', shard.node || 'unassigned'); + const index = get(shard, 'index.name', shard.index); if (!obj[node]) { createNode(obj, nodes[node], node); } diff --git a/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js b/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js index 4477d59adffd2..69579cb831c06 100644 --- a/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js +++ b/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js @@ -111,7 +111,17 @@ export function monitoringMlListingProvider() { } ); - scope.$watch('jobs', (jobs = []) => { + scope.$watch('jobs', (_jobs = []) => { + const jobs = _jobs.map((job) => { + if (job.ml) { + return { + ...job.ml.job, + node: job.node, + job_id: job.ml.job.id, + }; + } + return job; + }); const mlTable = ( diff --git a/x-pack/plugins/monitoring/public/index.scss b/x-pack/plugins/monitoring/public/index.scss index e25885debebdd..99b8d1ecfd337 100644 --- a/x-pack/plugins/monitoring/public/index.scss +++ b/x-pack/plugins/monitoring/public/index.scss @@ -6,9 +6,3 @@ // monChart__legend // monChart__legend--small // monChart__legend-isLoading - -.monApplicationWrapper { - display: flex; - flex-direction: column; - flex-grow: 1; -} diff --git a/x-pack/plugins/monitoring/public/lib/apm_agent.ts b/x-pack/plugins/monitoring/public/lib/apm_agent.ts new file mode 100644 index 0000000000000..8884557782126 --- /dev/null +++ b/x-pack/plugins/monitoring/public/lib/apm_agent.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Legacy } from '../legacy_shims'; + +/** + * Possible temporary work arround to establish if APM might also be monitoring fleet: + * https://github.com/elastic/kibana/pull/95129/files#r604815886 + */ +export const checkAgentTypeMetric = (versions?: string[]) => { + if (!Legacy.shims.isCloud || !versions) { + return false; + } + versions.forEach((version) => { + const [major, minor] = version.split('.'); + const majorInt = Number(major); + if (majorInt > 7 || (majorInt === 7 && Number(minor) >= 13)) { + return true; + } + }); + return false; +}; diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js index 28aeedb024fd9..21633bd036227 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js @@ -75,8 +75,16 @@ uiRoutes.when('/elasticsearch/ccr/:index/shard/:shardId', { i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.pageTitle', { defaultMessage: 'Elasticsearch Ccr Shard - Index: {followerIndex} Shard: {shardId}', values: { - followerIndex: get(pageData, 'stat.follower_index'), - shardId: get(pageData, 'stat.shard_id'), + followerIndex: get( + pageData, + 'stat.follower.index', + get(pageData, 'stat.follower_index') + ), + shardId: get( + pageData, + 'stat.follower.shard.number', + get(pageData, 'stat.shard_id') + ), }, }) ); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts index ddbf4e3d4b3c1..f622418c77910 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts @@ -6,7 +6,7 @@ */ import { ElasticsearchClient } from 'kibana/server'; import { AlertCluster, AlertClusterHealth } from '../../../common/types/alerts'; -import { ElasticsearchSource } from '../../../common/types/es'; +import { ElasticsearchSource, ElasticsearchResponse } from '../../../common/types/es'; export async function fetchClusterHealth( esClient: ElasticsearchClient, @@ -59,8 +59,9 @@ export async function fetchClusterHealth( }, }; - const { body: response } = await esClient.search(params); - return response.hits.hits.map((hit) => { + const result = await esClient.search(params); + const response: ElasticsearchResponse = result.body as ElasticsearchResponse; + return (response.hits?.hits ?? []).map((hit) => { return { health: hit._source!.cluster_state?.status, clusterUuid: hit._source!.cluster_uuid, diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts index 111ef5b0c120d..f25f1dbe594db 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts @@ -6,7 +6,7 @@ */ import { ElasticsearchClient } from 'kibana/server'; import { AlertCluster, AlertVersions } from '../../../common/types/alerts'; -import { ElasticsearchSource } from '../../../common/types/es'; +import { ElasticsearchSource, ElasticsearchResponse } from '../../../common/types/es'; export async function fetchElasticsearchVersions( esClient: ElasticsearchClient, @@ -60,8 +60,9 @@ export async function fetchElasticsearchVersions( }, }; - const { body: response } = await esClient.search(params); - return response.hits.hits.map((hit) => { + const result = await esClient.search(params); + const response: ElasticsearchResponse = result.body as ElasticsearchResponse; + return (response.hits?.hits ?? []).map((hit) => { const versions = hit._source!.cluster_stats?.nodes?.versions ?? []; return { versions, diff --git a/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js b/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js index ffd4ae77fa0f2..0dfcbfff834d8 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js +++ b/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js @@ -24,6 +24,7 @@ export const apmAggFilterPath = [ 'aggregations.min_mem_rss_total.value', 'aggregations.max_mem_rss_total.value', 'aggregations.max_mem_total_total.value', + 'aggregations.versions.buckets', ]; export const apmUuidsAgg = (maxBucketSize) => ({ @@ -33,6 +34,11 @@ export const apmUuidsAgg = (maxBucketSize) => ({ precision_threshold: 10000, }, }, + versions: { + terms: { + field: 'beats_stats.beat.version', + }, + }, ephemeral_ids: { terms: { field: 'beats_stats.metrics.beat.info.ephemeral_id', @@ -94,18 +100,20 @@ export const apmUuidsAgg = (maxBucketSize) => ({ }); export const apmAggResponseHandler = (response) => { - const apmTotal = get(response, 'aggregations.total.value', null); + const apmTotal = get(response, 'aggregations.total.value', 0); - const eventsTotalMax = get(response, 'aggregations.max_events_total.value', null); - const eventsTotalMin = get(response, 'aggregations.min_events_total.value', null); - const memRssMax = get(response, 'aggregations.max_mem_rss_total.value', null); - const memRssMin = get(response, 'aggregations.min_mem_rss_total.value', null); - const memTotal = get(response, 'aggregations.max_mem_total_total.value', null); + const eventsTotalMax = get(response, 'aggregations.max_events_total.value', 0); + const eventsTotalMin = get(response, 'aggregations.min_events_total.value', 0); + const memRssMax = get(response, 'aggregations.max_mem_rss_total.value', 0); + const memRssMin = get(response, 'aggregations.min_mem_rss_total.value', 0); + const memTotal = get(response, 'aggregations.max_mem_total_total.value', 0); + const versions = get(response, 'aggregations.versions.buckets', []).map(({ key }) => key); return { apmTotal, totalEvents: getDiffCalculation(eventsTotalMax, eventsTotalMin), memRss: getDiffCalculation(memRssMax, memRssMin), memTotal, + versions, }; }; diff --git a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts index 07a3ca9332115..93ff966b5def5 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts @@ -32,7 +32,7 @@ export async function getTimeOfLastEvent({ size: 1, ignoreUnavailable: true, body: { - _source: ['timestamp'], + _source: ['beats_stats.timestamp', '@timestamp'], sort: [ { timestamp: { @@ -60,5 +60,8 @@ export async function getTimeOfLastEvent({ }; const response = await callWithRequest(req, 'search', params); - return response.hits?.hits.length ? response.hits?.hits[0]?._source.timestamp : undefined; + return response.hits?.hits.length + ? response.hits?.hits[0]?._source.beats_stats?.timestamp ?? + response.hits?.hits[0]?._source['@timestamp'] + : undefined; } diff --git a/x-pack/plugins/monitoring/server/lib/apm/create_apm_query.js b/x-pack/plugins/monitoring/server/lib/apm/create_apm_query.js index f9818cb7d0cde..1680fcdfdb228 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/create_apm_query.js +++ b/x-pack/plugins/monitoring/server/lib/apm/create_apm_query.js @@ -18,7 +18,7 @@ export function createApmQuery(options = {}) { options = defaults(options, { filters: [], metric: ApmMetric.getMetricFields(), - type: 'beats_stats', + types: ['stats', 'beats_stats'], }); options.filters.push({ diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts index df74d6b609f9c..53a4aeb06bcc1 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts @@ -29,36 +29,40 @@ export function handleResponse( const firstHit = response.hits.hits[0]; - let firstStats = null; - const stats = firstHit._source.beats_stats ?? {}; - + let firstStatsMetrics = null; if ( firstHit.inner_hits?.first_hit?.hits?.hits && - firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 && - firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats + firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 ) { - firstStats = firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats; + firstStatsMetrics = + firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats?.metrics ?? + firstHit.inner_hits.first_hit.hits.hits[0]._source.beat?.stats; } - const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total; - const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published; - const eventsDroppedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.dropped; - const bytesWrittenFirst = firstStats?.metrics?.libbeat?.output?.write?.bytes; + const stats = firstHit._source.beats_stats ?? firstHit._source?.beat?.stats; + const statsMetrics = firstHit._source.beats_stats?.metrics ?? firstHit._source?.beat?.stats; + + const eventsTotalFirst = firstStatsMetrics?.libbeat?.pipeline?.events?.total ?? null; + const eventsEmittedFirst = firstStatsMetrics?.libbeat?.pipeline?.events?.published ?? null; + const eventsDroppedFirst = firstStatsMetrics?.libbeat?.pipeline?.events?.dropped ?? null; + const bytesWrittenFirst = firstStatsMetrics?.libbeat?.output?.write?.bytes ?? null; - const eventsTotalLast = stats.metrics?.libbeat?.pipeline?.events?.total; - const eventsEmittedLast = stats.metrics?.libbeat?.pipeline?.events?.published; - const eventsDroppedLast = stats.metrics?.libbeat?.pipeline?.events?.dropped; - const bytesWrittenLast = stats.metrics?.libbeat?.output?.write?.bytes; + const eventsTotalLast = statsMetrics?.libbeat?.pipeline?.events?.total ?? null; + const eventsEmittedLast = statsMetrics?.libbeat?.pipeline?.events?.published ?? null; + const eventsDroppedLast = statsMetrics?.libbeat?.pipeline?.events?.dropped ?? null; + const bytesWrittenLast = statsMetrics?.libbeat?.output?.write?.bytes ?? null; return { uuid: apmUuid, - transportAddress: stats.beat?.host, - version: stats.beat?.version, - name: stats.beat?.name, - type: upperFirst(stats.beat?.type) || null, - output: upperFirst(stats.metrics?.libbeat?.output?.type) || null, - configReloads: stats.metrics?.libbeat?.config?.reloads, - uptime: stats.metrics?.beat?.info?.uptime?.ms, + transportAddress: stats?.beat?.host, + version: stats?.beat?.version, + name: stats?.beat?.name, + type: upperFirst(stats?.beat?.type) || null, + output: upperFirst(statsMetrics?.libbeat?.output?.type) ?? null, + configReloads: statsMetrics?.libbeat?.config?.reloads ?? null, + uptime: + firstHit._source.beats_stats?.metrics?.beat?.info?.uptime?.ms ?? + firstHit._source.beat?.stats?.info?.uptime?.ms, eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst), eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst), eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst), @@ -110,6 +114,22 @@ export async function getApmInfo( 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.dropped', 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', + + 'hits.hits._source.beat.stats.beat.host', + 'hits.hits._source.beat.stats.beat.version', + 'hits.hits._source.beat.stats.beat.name', + 'hits.hits._source.beat.stats.beat.type', + 'hits.hits._source.beat.stats.libbeat.output.type', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.published', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.total', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.dropped', + 'hits.hits._source.beat.stats.libbeat.output.write.bytes', + 'hits.hits._source.beat.stats.libbeat.config.reloads', + 'hits.hits._source.beat.stats.info.uptime.ms', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.pipeline.events.published', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.pipeline.events.total', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.pipeline.events.dropped', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.output.write.bytes', ], body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, @@ -125,7 +145,10 @@ export async function getApmInfo( inner_hits: { name: 'first_hit', size: 1, - sort: { 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }, + sort: [ + { 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }, + { '@timestamp': { order: 'asc', unmapped_type: 'long' } }, + ], }, }, }, diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts index 7a577d475579c..05c52a56da930 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts @@ -21,18 +21,21 @@ import { ElasticsearchResponse, ElasticsearchResponseHit } from '../../../common export function handleResponse(response: ElasticsearchResponse, start: number, end: number) { const initial = { ids: new Set(), beats: [] }; const { beats } = response.hits?.hits.reduce((accum: any, hit: ElasticsearchResponseHit) => { - const stats = hit._source.beats_stats; + const stats = hit._source.beats_stats ?? hit._source.beat?.stats; + const statsMetrics = hit._source.beats_stats?.metrics ?? hit._source.beat?.stats; if (!stats) { return accum; } let earliestStats = null; - if ( - hit.inner_hits?.earliest?.hits?.hits && - hit.inner_hits?.earliest?.hits?.hits.length > 0 && - hit.inner_hits.earliest.hits.hits[0]._source.beats_stats - ) { - earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; + let earliestStatsMetrics = null; + if (hit.inner_hits?.earliest?.hits?.hits && hit.inner_hits?.earliest?.hits?.hits.length > 0) { + earliestStats = + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats ?? + hit.inner_hits.earliest.hits.hits[0]._source.beat?.stats; + earliestStatsMetrics = + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats?.metrics ?? + hit.inner_hits.earliest.hits.hits[0]._source.beat?.stats; } const uuid = stats?.beat?.uuid; @@ -46,44 +49,47 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e // add the beat const rateOptions = { - hitTimestamp: stats.timestamp, - earliestHitTimestamp: earliestStats?.timestamp, + hitTimestamp: stats?.timestamp ?? hit._source['@timestamp'], + earliestHitTimestamp: + earliestStats?.timestamp ?? hit.inner_hits?.earliest.hits?.hits[0]._source['@timestamp'], timeWindowMin: start, timeWindowMax: end, }; const { rate: bytesSentRate } = calculateRate({ - latestTotal: stats.metrics?.libbeat?.output?.write?.bytes, - earliestTotal: earliestStats?.metrics?.libbeat?.output?.write?.bytes, + latestTotal: statsMetrics?.libbeat?.output?.write?.bytes, + earliestTotal: earliestStatsMetrics?.libbeat?.output?.write?.bytes, ...rateOptions, }); const { rate: totalEventsRate } = calculateRate({ - latestTotal: stats.metrics?.libbeat?.pipeline?.events?.total, - earliestTotal: earliestStats?.metrics?.libbeat?.pipeline?.events?.total, + latestTotal: statsMetrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStatsMetrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); - const errorsWrittenLatest = stats.metrics?.libbeat?.output?.write?.errors ?? 0; - const errorsWrittenEarliest = earliestStats?.metrics?.libbeat?.output?.write?.errors ?? 0; - const errorsReadLatest = stats.metrics?.libbeat?.output?.read?.errors ?? 0; - const errorsReadEarliest = earliestStats?.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsWrittenLatest = statsMetrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStatsMetrics?.libbeat?.output?.write?.errors ?? 0; + const errorsReadLatest = statsMetrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStatsMetrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest ); accum.beats.push({ - uuid: stats.beat?.uuid, - name: stats.beat?.name, - type: upperFirst(stats.beat?.type), - output: upperFirst(stats.metrics?.libbeat?.output?.type), + uuid: stats?.beat?.uuid, + name: stats?.beat?.name, + type: upperFirst(stats?.beat?.type), + output: upperFirst(statsMetrics?.libbeat?.output?.type), total_events_rate: totalEventsRate, bytes_sent_rate: bytesSentRate, errors, - memory: stats.metrics?.beat?.memstats?.memory_alloc, - version: stats.beat?.version, - time_of_last_event: hit._source.timestamp, + memory: + hit._source.beats_stats?.metrics?.beat?.memstats?.memory_alloc ?? + hit._source.beat?.stats?.memstats?.memory?.alloc, + version: stats?.beat?.version, + time_of_last_event: hit._source.beats_stats?.timestamp ?? hit._source['@timestamp'], }); return accum; @@ -106,6 +112,7 @@ export async function getApms(req: LegacyRequest, apmIndexPattern: string, clust filterPath: [ // only filter path can filter for inner_hits 'hits.hits._source.timestamp', + 'hits.hits._source.@timestamp', 'hits.hits._source.beats_stats.beat.uuid', 'hits.hits._source.beats_stats.beat.name', 'hits.hits._source.beats_stats.beat.host', @@ -115,20 +122,36 @@ export async function getApms(req: LegacyRequest, apmIndexPattern: string, clust 'hits.hits._source.beats_stats.metrics.libbeat.output.read.errors', 'hits.hits._source.beats_stats.metrics.libbeat.output.write.errors', 'hits.hits._source.beats_stats.metrics.beat.memstats.memory_alloc', + 'hits.hits._source.beat.stats.beat.uuid', + 'hits.hits._source.beat.stats.beat.name', + 'hits.hits._source.beat.stats.beat.host', + 'hits.hits._source.beat.stats.beat.type', + 'hits.hits._source.beat.stats.beat.version', + 'hits.hits._source.beat.stats.libbeat.output.type', + 'hits.hits._source.beat.stats.libbeat.output.read.errors', + 'hits.hits._source.beat.stats.libbeat.output.write.errors', + 'hits.hits._source.beat.stats.memstats.memory.alloc', // latest hits for calculating metrics 'hits.hits._source.beats_stats.timestamp', 'hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', 'hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', + 'hits.hits._source.beat.stats.libbeat.output.write.bytes', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.total', // earliest hits for calculating metrics 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.timestamp', 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', + 'hits.hits.inner_hits.earliest.hits.hits._source.@timestamp', + 'hits.hits.inner_hits.earliest.hits.hits._source.beat.stats.libbeat.output.write.bytes', + 'hits.hits.inner_hits.earliest.hits.hits._source.beat.stats.libbeat.pipeline.events.total', // earliest hits for calculating diffs 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.read.errors', 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.write.errors', + 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.read.errors', + 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.write.errors', ], body: { query: createApmQuery({ @@ -141,7 +164,10 @@ export async function getApms(req: LegacyRequest, apmIndexPattern: string, clust inner_hits: { name: 'earliest', size: 1, - sort: [{ 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }], + sort: [ + { 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }, + { '@timestamp': { order: 'asc', unmapped_type: 'long' } }, + ], }, }, sort: [ diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js b/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js index 446cf19adf2a0..3ece0af0369fd 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js @@ -5,6 +5,7 @@ * 2.0. */ +import { get } from 'lodash'; import { checkParam } from '../error_missing_required'; import { createApmQuery } from './create_apm_query'; import { ApmMetric } from '../metrics'; @@ -12,7 +13,7 @@ import { apmAggResponseHandler, apmUuidsAgg, apmAggFilterPath } from './_apm_sta import { getTimeOfLastEvent } from './_get_time_of_last_event'; export function handleResponse(clusterUuid, response) { - const { apmTotal, totalEvents, memRss, memTotal } = apmAggResponseHandler(response); + const { apmTotal, totalEvents, memRss, memTotal, versions } = apmAggResponseHandler(response); // combine stats const stats = { @@ -22,6 +23,7 @@ export function handleResponse(clusterUuid, response) { apms: { total: apmTotal, }, + versions, }; return { @@ -40,7 +42,7 @@ export function getApmsForClusters(req, apmIndexPattern, clusters) { return Promise.all( clusters.map(async (cluster) => { - const clusterUuid = cluster.cluster_uuid; + const clusterUuid = get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid); const params = { index: apmIndexPattern, size: 0, diff --git a/x-pack/plugins/monitoring/server/lib/beats/_beats_stats.js b/x-pack/plugins/monitoring/server/lib/beats/_beats_stats.js index 753aaeea330be..0d4dc0ba59d18 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/_beats_stats.js +++ b/x-pack/plugins/monitoring/server/lib/beats/_beats_stats.js @@ -101,7 +101,7 @@ export const beatsUuidsAgg = (maxBucketSize) => ({ export const beatsAggResponseHandler = (response) => { // beat types stat const buckets = get(response, 'aggregations.types.buckets', []); - const beatTotal = get(response, 'aggregations.total.value', null); + const beatTotal = get(response, 'aggregations.total.value', 0); const beatTypes = buckets.reduce((types, typeBucket) => { return [ ...types, @@ -112,10 +112,10 @@ export const beatsAggResponseHandler = (response) => { ]; }, []); - const eventsTotalMax = get(response, 'aggregations.max_events_total.value', null); - const eventsTotalMin = get(response, 'aggregations.min_events_total.value', null); - const bytesSentMax = get(response, 'aggregations.max_bytes_sent_total.value', null); - const bytesSentMin = get(response, 'aggregations.min_bytes_sent_total.value', null); + const eventsTotalMax = get(response, 'aggregations.max_events_total.value', 0); + const eventsTotalMin = get(response, 'aggregations.min_events_total.value', 0); + const bytesSentMax = get(response, 'aggregations.max_bytes_sent_total.value', 0); + const bytesSentMin = get(response, 'aggregations.min_bytes_sent_total.value', 0); return { beatTotal, diff --git a/x-pack/plugins/monitoring/server/lib/beats/create_beats_query.js b/x-pack/plugins/monitoring/server/lib/beats/create_beats_query.js index 6418e2af72a70..c6ec39ed3ba2b 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/create_beats_query.js +++ b/x-pack/plugins/monitoring/server/lib/beats/create_beats_query.js @@ -21,7 +21,7 @@ export function createBeatsQuery(options = {}) { options = defaults(options, { filters: [], metric: BeatsMetric.getMetricFields(), - type: 'beats_stats', + types: ['stats', 'beats_stats'], }); // avoid showing APM Server stats alongside other Beats because APM Server will have its own UI diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts index 3864fd51dec36..13b4f0041c99b 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts @@ -22,28 +22,36 @@ export function handleResponse(response: ElasticsearchResponse, beatUuid: string const firstHit = response.hits.hits[0]; - let firstStats = null; + let firstStatsMetrics = null; if ( firstHit.inner_hits?.first_hit?.hits?.hits && - firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 && - firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats + firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 ) { - firstStats = firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats; + firstStatsMetrics = + firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats?.metrics ?? + firstHit.inner_hits.first_hit.hits.hits[0]._source.beat?.stats; } - const stats = firstHit._source.beats_stats ?? {}; + const stats = firstHit._source.beats_stats ?? firstHit._source?.beat?.stats; + const statsMetrics = firstHit._source.beats_stats?.metrics ?? firstHit._source?.beat?.stats; - const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total ?? null; - const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published ?? null; - const eventsDroppedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.dropped ?? null; - const bytesWrittenFirst = firstStats?.metrics?.libbeat?.output?.write?.bytes ?? null; + const eventsTotalFirst = firstStatsMetrics?.libbeat?.pipeline?.events?.total ?? null; + const eventsEmittedFirst = firstStatsMetrics?.libbeat?.pipeline?.events?.published ?? null; + const eventsDroppedFirst = firstStatsMetrics?.libbeat?.pipeline?.events?.dropped ?? null; + const bytesWrittenFirst = firstStatsMetrics?.libbeat?.output?.write?.bytes ?? null; - const eventsTotalLast = stats?.metrics?.libbeat?.pipeline?.events?.total ?? null; - const eventsEmittedLast = stats?.metrics?.libbeat?.pipeline?.events?.published ?? null; - const eventsDroppedLast = stats?.metrics?.libbeat?.pipeline?.events?.dropped ?? null; - const bytesWrittenLast = stats?.metrics?.libbeat?.output?.write?.bytes ?? null; - const handlesHardLimit = stats?.metrics?.beat?.handles?.limit?.hard ?? null; - const handlesSoftLimit = stats?.metrics?.beat?.handles?.limit?.soft ?? null; + const eventsTotalLast = statsMetrics?.libbeat?.pipeline?.events?.total ?? null; + const eventsEmittedLast = statsMetrics?.libbeat?.pipeline?.events?.published ?? null; + const eventsDroppedLast = statsMetrics?.libbeat?.pipeline?.events?.dropped ?? null; + const bytesWrittenLast = statsMetrics?.libbeat?.output?.write?.bytes ?? null; + const handlesHardLimit = + firstHit._source.beats_stats?.metrics?.beat?.handles?.limit?.hard ?? + firstHit._source.beat?.stats?.handles?.limit?.hard ?? + null; + const handlesSoftLimit = + firstHit._source.beats_stats?.metrics?.beat?.handles?.limit?.soft ?? + firstHit._source.beat?.stats?.handles?.limit?.soft ?? + null; return { uuid: beatUuid, @@ -51,9 +59,11 @@ export function handleResponse(response: ElasticsearchResponse, beatUuid: string version: stats?.beat?.version ?? null, name: stats?.beat?.name ?? null, type: upperFirst(stats?.beat?.type) ?? null, - output: upperFirst(stats?.metrics?.libbeat?.output?.type) ?? null, - configReloads: stats?.metrics?.libbeat?.config?.reloads ?? null, - uptime: stats?.metrics?.beat?.info?.uptime?.ms ?? null, + output: upperFirst(statsMetrics?.libbeat?.output?.type) ?? null, + configReloads: statsMetrics?.libbeat?.config?.reloads ?? null, + uptime: + firstHit._source.beats_stats?.metrics?.beat?.info?.uptime?.ms ?? + firstHit._source.beat?.stats?.info?.uptime?.ms, eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst) ?? null, eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst) ?? null, eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst) ?? null, @@ -82,22 +92,39 @@ export async function getBeatSummary( ignoreUnavailable: true, filterPath: [ 'hits.hits._source.beats_stats.beat.host', + 'hits.hits._source.beat.stats.beat.host', 'hits.hits._source.beats_stats.beat.version', + 'hits.hits._source.beat.stats.beat.version', 'hits.hits._source.beats_stats.beat.name', + 'hits.hits._source.beat.stats.beat.name', 'hits.hits._source.beats_stats.beat.type', + 'hits.hits._source.beat.stats.beat.type', 'hits.hits._source.beats_stats.metrics.libbeat.output.type', + 'hits.hits._source.beat.stats.libbeat.output.type', 'hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.published', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.published', 'hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.total', 'hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.dropped', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.dropped', 'hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', + 'hits.hits._source.beat.stats.libbeat.output.write.bytes', 'hits.hits._source.beats_stats.metrics.libbeat.config.reloads', + 'hits.hits._source.beat.stats.libbeat.config.reloads', 'hits.hits._source.beats_stats.metrics.beat.info.uptime.ms', - 'hits.hits._source.beats_stats.metrics.beat.handles.limit.hard', + 'hits.hits._source.beat.stats.info.uptime.ms', + 'hits.hits._source.beats_stats.metrics.beat.handles.limit.s', + 'hits.hits._source.beat.stats.handles.limit.hard', 'hits.hits._source.beats_stats.metrics.beat.handles.limit.soft', + 'hits.hits._source.beat.stats.handles.limit.soft', 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.published', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.pipeline.events.published', 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.pipeline.events.total', 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.dropped', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.pipeline.events.dropped', 'hits.hits.inner_hits.first_hit.hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', + 'hits.hits.inner_hits.first_hit.hits.hits._source.beat.stats.libbeat.output.write.bytes', ], body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, @@ -112,7 +139,10 @@ export async function getBeatSummary( inner_hits: { name: 'first_hit', size: 1, - sort: { 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }, + sort: [ + { 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }, + { '@timestamp': { order: 'asc', unmapped_type: 'long' } }, + ], }, }, }, diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts b/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts index 7c5ef5c452285..641039331bbcb 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts @@ -34,7 +34,8 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e const hits = response.hits?.hits ?? []; const initial: { ids: Set; beats: Beat[] } = { ids: new Set(), beats: [] }; const { beats } = hits.reduce((accum, hit) => { - const stats = hit._source.beats_stats; + const stats = hit._source.beats_stats ?? hit._source.beat?.stats; + const statsMetrics = hit._source.beats_stats?.metrics ?? hit._source.beat?.stats; const uuid = stats?.beat?.uuid; if (!uuid) { @@ -50,38 +51,41 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e accum.ids.add(uuid); let earliestStats = null; - if ( - hit.inner_hits?.earliest?.hits?.hits && - hit.inner_hits?.earliest?.hits?.hits.length > 0 && - hit.inner_hits.earliest.hits.hits[0]._source.beats_stats - ) { - earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; + let earliestStatsMetrics = null; + if (hit.inner_hits?.earliest?.hits?.hits && hit.inner_hits?.earliest?.hits?.hits.length > 0) { + earliestStats = + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats ?? + hit.inner_hits.earliest.hits.hits[0]._source.beat?.stats; + earliestStatsMetrics = + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats?.metrics ?? + hit.inner_hits.earliest.hits.hits[0]._source.beat?.stats; } // add the beat const rateOptions = { - hitTimestamp: stats?.timestamp, - earliestHitTimestamp: earliestStats?.timestamp, + hitTimestamp: stats?.timestamp ?? hit._source['@timestamp'], + earliestHitTimestamp: + earliestStats?.timestamp ?? hit.inner_hits?.earliest.hits?.hits[0]._source['@timestamp'], timeWindowMin: start, timeWindowMax: end, }; const { rate: bytesSentRate } = calculateRate({ - latestTotal: stats?.metrics?.libbeat?.output?.write?.bytes, - earliestTotal: earliestStats?.metrics?.libbeat?.output?.write?.bytes, + latestTotal: statsMetrics?.libbeat?.output?.write?.bytes, + earliestTotal: earliestStatsMetrics?.libbeat?.output?.write?.bytes, ...rateOptions, }); const { rate: totalEventsRate } = calculateRate({ - latestTotal: stats?.metrics?.libbeat?.pipeline?.events?.total, - earliestTotal: earliestStats?.metrics?.libbeat?.pipeline?.events?.total, + latestTotal: statsMetrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStatsMetrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); - const errorsWrittenLatest = stats?.metrics?.libbeat?.output?.write?.errors ?? 0; - const errorsWrittenEarliest = earliestStats?.metrics?.libbeat?.output?.write?.errors ?? 0; - const errorsReadLatest = stats?.metrics?.libbeat?.output?.read?.errors ?? 0; - const errorsReadEarliest = earliestStats?.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsWrittenLatest = statsMetrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStatsMetrics?.libbeat?.output?.write?.errors ?? 0; + const errorsReadLatest = statsMetrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStatsMetrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest @@ -91,11 +95,13 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e uuid: stats?.beat?.uuid, name: stats?.beat?.name, type: upperFirst(stats?.beat?.type), - output: upperFirst(stats?.metrics?.libbeat?.output?.type), + output: upperFirst(statsMetrics?.libbeat?.output?.type), total_events_rate: totalEventsRate, bytes_sent_rate: bytesSentRate, errors, - memory: stats?.metrics?.beat?.memstats?.memory_alloc, + memory: + hit._source.beats_stats?.metrics?.beat?.memstats?.memory_alloc ?? + hit._source.beat?.stats?.memstats?.memory?.alloc, version: stats?.beat?.version, }); @@ -119,28 +125,45 @@ export async function getBeats(req: LegacyRequest, beatsIndexPattern: string, cl filterPath: [ // only filter path can filter for inner_hits 'hits.hits._source.beats_stats.beat.uuid', + 'hits.hits._source.beat.stats.beat.uuid', 'hits.hits._source.beats_stats.beat.name', + 'hits.hits._source.beat.stats.beat.name', 'hits.hits._source.beats_stats.beat.host', + 'hits.hits._source.beat.stats.beat.host', 'hits.hits._source.beats_stats.beat.type', + 'hits.hits._source.beat.stats.beat.type', 'hits.hits._source.beats_stats.beat.version', + 'hits.hits._source.beat.stats.beat.version', 'hits.hits._source.beats_stats.metrics.libbeat.output.type', + 'hits.hits._source.beat.stats.libbeat.output.type', 'hits.hits._source.beats_stats.metrics.libbeat.output.read.errors', + 'hits.hits._source.beat.stats.libbeat.output.read.errors', 'hits.hits._source.beats_stats.metrics.libbeat.output.write.errors', + 'hits.hits._source.beat.stats.libbeat.output.write.errors', 'hits.hits._source.beats_stats.metrics.beat.memstats.memory_alloc', + 'hits.hits._source.beat.stats.memstats.memory.alloc', // latest hits for calculating metrics 'hits.hits._source.beats_stats.timestamp', + 'hits.hits._source.@timestamp', 'hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', + 'hits.hits._source.beat.stats.libbeat.output.write.bytes', 'hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', + 'hits.hits._source.beat.stats.libbeat.pipeline.events.total', // earliest hits for calculating metrics 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.timestamp', + 'hits.hits.inner_hits.earliest.hits.hits._source.@timestamp', + 'hits.hits.inner_hits.earliest.hits.hits._source.beat.stats.libbeat.output.write.bytes', 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.write.bytes', + 'hits.hits.inner_hits.earliest.hits.hits._source.beat.stats.libbeat.pipeline.events.total', 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.pipeline.events.total', // earliest hits for calculating diffs 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.read.errors', + 'hits.hits.inner_hits.earliest.hits.hits._source.beat.stats.libbeat.output.read.errors', 'hits.hits.inner_hits.earliest.hits.hits._source.beats_stats.metrics.libbeat.output.write.errors', + 'hits.hits.inner_hits.earliest.hits.hits._source.beat.stats.libbeat.output.write.errors', ], body: { query: createBeatsQuery({ @@ -153,7 +176,10 @@ export async function getBeats(req: LegacyRequest, beatsIndexPattern: string, cl inner_hits: { name: 'earliest', size: 1, - sort: [{ 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }], + sort: [ + { 'beats_stats.timestamp': { order: 'asc', unmapped_type: 'long' } }, + { '@timestamp': { order: 'asc', unmapped_type: 'long' } }, + ], }, }, sort: [ diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.js b/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.js index 0f214eedc4689..5ceca7a02d89b 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.js @@ -5,6 +5,7 @@ * 2.0. */ +import { get } from 'lodash'; import { checkParam } from '../error_missing_required'; import { BeatsClusterMetric } from '../metrics'; import { createBeatsQuery } from './create_beats_query'; @@ -39,7 +40,7 @@ export function getBeatsForClusters(req, beatsIndexPattern, clusters) { return Promise.all( clusters.map(async (cluster) => { - const clusterUuid = cluster.cluster_uuid; + const clusterUuid = get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid); const params = { index: beatsIndexPattern, size: 0, diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.test.js b/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.test.js index 9f33c4c78d347..b9abfd3ed172b 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.test.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beats_for_clusters.test.js @@ -14,10 +14,10 @@ describe('get_beats_for_clusters', () => { expect(handleResponse(clusterUuid, response)).toEqual({ clusterUuid: 'foo_uuid', stats: { - totalEvents: null, - bytesSent: null, + totalEvents: 0, + bytesSent: 0, beats: { - total: null, + total: 0, types: [], }, }, diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_stats.test.js b/x-pack/plugins/monitoring/server/lib/beats/get_stats.test.js index 80f6e3a9262ad..dbef41180d3ed 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_stats.test.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_stats.test.js @@ -11,10 +11,10 @@ describe('beats/get_stats', () => { it('Handle empty response', () => { expect(handleResponse()).toEqual({ stats: { - bytesSent: null, - totalEvents: null, + bytesSent: 0, + totalEvents: 0, }, - total: null, + total: 0, types: [], }); }); diff --git a/x-pack/plugins/monitoring/server/lib/ccs_utils.js b/x-pack/plugins/monitoring/server/lib/ccs_utils.js index 37cc7f9c07af6..08ed8d5a7b35a 100644 --- a/x-pack/plugins/monitoring/server/lib/ccs_utils.js +++ b/x-pack/plugins/monitoring/server/lib/ccs_utils.js @@ -7,7 +7,7 @@ import { isFunction, get } from 'lodash'; -export function appendMetricbeatIndex(config, indexPattern, bypass = false) { +export function appendMetricbeatIndex(config, indexPattern, ccs, bypass = false) { if (bypass) { return indexPattern; } @@ -21,6 +21,10 @@ export function appendMetricbeatIndex(config, indexPattern, bypass = false) { mbIndex = get(config, 'ui.metricbeat.index'); } + if (ccs) { + mbIndex = `${mbIndex},${ccs}:${mbIndex}`; + } + return `${indexPattern},${mbIndex}`; } @@ -46,7 +50,12 @@ export function prefixIndexPattern(config, indexPattern, ccs, monitoringIndicesO } if (!ccsEnabled || !ccs) { - return appendMetricbeatIndex(config, indexPattern, monitoringIndicesOnly); + return appendMetricbeatIndex( + config, + indexPattern, + ccsEnabled ? ccs : undefined, + monitoringIndicesOnly + ); } const patterns = indexPattern.split(','); @@ -57,11 +66,12 @@ export function prefixIndexPattern(config, indexPattern, ccs, monitoringIndicesO return appendMetricbeatIndex( config, `${prefixedPattern},${indexPattern}`, + ccs, monitoringIndicesOnly ); } - return appendMetricbeatIndex(config, prefixedPattern, monitoringIndicesOnly); + return appendMetricbeatIndex(config, prefixedPattern, ccs, monitoringIndicesOnly); } /** diff --git a/x-pack/plugins/monitoring/server/lib/cluster/__fixtures__/clusters.json b/x-pack/plugins/monitoring/server/lib/cluster/__fixtures__/clusters.json index b8ba0a569ad7c..49a82492a2301 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/__fixtures__/clusters.json +++ b/x-pack/plugins/monitoring/server/lib/cluster/__fixtures__/clusters.json @@ -340,10 +340,10 @@ "versions": [] }, "beats": { - "totalEvents": null, - "bytesSent": null, + "totalEvents": 0, + "bytesSent": 0, "beats": { - "total": null, + "total": 0, "types": [] } } @@ -679,10 +679,10 @@ "versions": [] }, "beats": { - "totalEvents": null, - "bytesSent": null, + "totalEvents": 0, + "bytesSent": 0, "beats": { - "total": null, + "total": 0, "types": [] } } diff --git a/x-pack/plugins/monitoring/server/lib/cluster/__snapshots__/get_clusters_summary.test.js.snap b/x-pack/plugins/monitoring/server/lib/cluster/__snapshots__/get_clusters_summary.test.js.snap index 71e8b28ce1dcf..12964129b518d 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/__snapshots__/get_clusters_summary.test.js.snap +++ b/x-pack/plugins/monitoring/server/lib/cluster/__snapshots__/get_clusters_summary.test.js.snap @@ -11,11 +11,11 @@ Array [ "apm": undefined, "beats": Object { "beats": Object { - "total": null, + "total": 0, "types": Array [], }, - "bytesSent": null, - "totalEvents": null, + "bytesSent": 0, + "totalEvents": 0, }, "ccs": "proddy1", "cluster_name": "Custom name", @@ -29,25 +29,7 @@ Array [ "deleted": 0, }, "shards": Object { - "index": Object { - "primaries": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - "replication": Object { - "avg": 0, - "max": 0, - "min": 0, - }, - "shards": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - }, "primaries": 1, - "replication": 0, "total": 1, }, "store": Object { @@ -60,7 +42,6 @@ Array [ }, "fs": Object { "available_in_bytes": 224468717568, - "free_in_bytes": 228403855360, "total_in_bytes": 499963170816, }, "jvm": Object { @@ -115,11 +96,11 @@ Array [ "apm": undefined, "beats": Object { "beats": Object { - "total": null, + "total": 0, "types": Array [], }, - "bytesSent": null, - "totalEvents": null, + "bytesSent": 0, + "totalEvents": 0, }, "ccs": undefined, "cluster_name": "monitoring-one", @@ -133,25 +114,7 @@ Array [ "deleted": 1, }, "shards": Object { - "index": Object { - "primaries": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - "replication": Object { - "avg": 0, - "max": 0, - "min": 0, - }, - "shards": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - }, "primaries": 6, - "replication": 0, "total": 6, }, "store": Object { @@ -164,7 +127,6 @@ Array [ }, "fs": Object { "available_in_bytes": 224468783104, - "free_in_bytes": 228403920896, "total_in_bytes": 499963170816, }, "jvm": Object { @@ -224,11 +186,11 @@ Array [ "apm": undefined, "beats": Object { "beats": Object { - "total": null, + "total": 0, "types": Array [], }, - "bytesSent": null, - "totalEvents": null, + "bytesSent": 0, + "totalEvents": 0, }, "ccs": "proddy1", "cluster_name": "Custom name", @@ -242,25 +204,7 @@ Array [ "deleted": 0, }, "shards": Object { - "index": Object { - "primaries": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - "replication": Object { - "avg": 0, - "max": 0, - "min": 0, - }, - "shards": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - }, "primaries": 1, - "replication": 0, "total": 1, }, "store": Object { @@ -273,7 +217,6 @@ Array [ }, "fs": Object { "available_in_bytes": 224468717568, - "free_in_bytes": 228403855360, "total_in_bytes": 499963170816, }, "jvm": Object { @@ -328,11 +271,11 @@ Array [ "apm": undefined, "beats": Object { "beats": Object { - "total": null, + "total": 0, "types": Array [], }, - "bytesSent": null, - "totalEvents": null, + "bytesSent": 0, + "totalEvents": 0, }, "ccs": undefined, "cluster_name": "monitoring-one", @@ -346,25 +289,7 @@ Array [ "deleted": 1, }, "shards": Object { - "index": Object { - "primaries": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - "replication": Object { - "avg": 0, - "max": 0, - "min": 0, - }, - "shards": Object { - "avg": 1, - "max": 1, - "min": 1, - }, - }, "primaries": 6, - "replication": 0, "total": 6, }, "store": Object { @@ -377,7 +302,6 @@ Array [ }, "fs": Object { "available_in_bytes": 224468783104, - "free_in_bytes": 228403920896, "total_in_bytes": 499963170816, }, "jvm": Object { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts index 25fbeb20fd1eb..42041832cfee5 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts @@ -31,13 +31,20 @@ async function findSupportedBasicLicenseCluster( index: kbnIndexPattern, size: 1, ignoreUnavailable: true, - filterPath: 'hits.hits._source.cluster_uuid', + filterPath: ['hits.hits._source.cluster_uuid', 'hits.hits._source.cluster.id'], body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: { bool: { filter: [ - { term: { type: 'kibana_stats' } }, + { + bool: { + should: [ + { term: { type: 'kibana_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, { term: { 'kibana_stats.kibana.uuid': kibanaUuid } }, { range: { timestamp: { gte, lte, format: 'strict_date_optional_time' } } }, ], @@ -80,7 +87,7 @@ export function flagSupportedClusters(req: LegacyRequest, kbnIndexPattern: strin const serverLog = (message: string) => req.getLogger('supported-clusters').debug(message); const flagAllSupported = (clusters: ElasticsearchModifiedSource[]) => { clusters.forEach((cluster) => { - if (cluster.license) { + if (cluster.license || cluster.elasticsearch?.cluster?.stats?.license) { cluster.isSupported = true; } }); @@ -100,7 +107,9 @@ export function flagSupportedClusters(req: LegacyRequest, kbnIndexPattern: strin } if (linkedClusterCount > 1) { const basicLicenseCount = clusters.reduce((accumCount, cluster) => { - if (cluster.license && cluster.license.type === 'basic') { + const licenseType = + cluster.license?.type ?? cluster.elasticsearch?.cluster?.stats?.license?.type; + if (licenseType === 'basic') { accumCount++; } return accumCount; @@ -129,7 +138,10 @@ export function flagSupportedClusters(req: LegacyRequest, kbnIndexPattern: strin 'Found some basic license clusters in monitoring data. Only non-basic will be supported.' ); clusters.forEach((cluster) => { - if (cluster.license && cluster.license.type !== 'basic') { + if ( + cluster.license?.type !== 'basic' && + cluster.elasticsearch?.cluster?.stats?.license?.type !== 'basic' + ) { cluster.isSupported = true; } }); diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_status.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_status.ts index e36dc8a31318e..3c993fc8e3922 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_status.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_cluster_status.ts @@ -14,11 +14,11 @@ import { ElasticsearchSource } from '../../../common/types/es'; * @return top-level cluster summary data */ export function getClusterStatus(cluster: ElasticsearchSource, shardStats: unknown) { - const clusterStats = cluster.cluster_stats ?? {}; - const clusterNodes = clusterStats.nodes ?? {}; - const clusterIndices = clusterStats.indices ?? {}; + const clusterStatsLegacy = cluster.cluster_stats; + const clusterStatsMB = cluster.elasticsearch?.cluster?.stats; - const clusterTotalShards = clusterIndices.shards?.total ?? 0; + const clusterTotalShards = + clusterStatsLegacy?.indices?.shards?.total ?? clusterStatsMB?.indices?.shards?.count ?? 0; let unassignedShardsTotal = 0; const unassignedShards = get(shardStats, 'indicesTotals.unassigned'); if (unassignedShards !== undefined) { @@ -28,17 +28,31 @@ export function getClusterStatus(cluster: ElasticsearchSource, shardStats: unkno const totalShards = clusterTotalShards + unassignedShardsTotal; return { - status: cluster.cluster_state?.status ?? 'unknown', + status: + cluster.elasticsearch?.cluster?.stats?.status ?? cluster.cluster_state?.status ?? 'unknown', // index-based stats - indicesCount: clusterIndices.count ?? 0, - documentCount: clusterIndices.docs?.count ?? 0, - dataSize: clusterIndices.store?.size_in_bytes ?? 0, + indicesCount: clusterStatsLegacy?.indices?.count ?? clusterStatsMB?.indices?.total ?? 0, + documentCount: + clusterStatsLegacy?.indices?.docs?.count ?? clusterStatsMB?.indices?.docs?.total ?? 0, + dataSize: + clusterStatsMB?.indices?.store?.size?.bytes ?? + clusterStatsLegacy?.indices?.store?.size_in_bytes ?? + 0, // node-based stats - nodesCount: clusterNodes.count?.total ?? 0, - upTime: clusterNodes.jvm?.max_uptime_in_millis ?? 0, - version: clusterNodes.versions ?? null, - memUsed: clusterNodes.jvm?.mem?.heap_used_in_bytes ?? 0, - memMax: clusterNodes.jvm?.mem?.heap_max_in_bytes ?? 0, + nodesCount: clusterStatsLegacy?.nodes?.count?.total ?? clusterStatsMB?.nodes?.count ?? 0, + upTime: + clusterStatsMB?.nodes?.jvm?.max_uptime?.ms ?? + clusterStatsLegacy?.nodes?.jvm?.max_uptime_in_millis ?? + 0, + version: clusterStatsMB?.nodes?.versions ?? clusterStatsLegacy?.nodes?.versions ?? null, + memUsed: + clusterStatsMB?.nodes?.jvm?.memory?.heap?.used?.bytes ?? + clusterStatsLegacy?.nodes?.jvm?.mem?.heap_used_in_bytes ?? + 0, + memMax: + clusterStatsMB?.nodes?.jvm?.memory?.heap?.max?.bytes ?? + clusterStatsLegacy?.nodes?.jvm?.mem?.heap_max_in_bytes ?? + 0, unassignedShards: unassignedShardsTotal, totalShards, }; diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js index 5143613a25b9c..77b71d3e92f4c 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js @@ -7,7 +7,7 @@ import { notFound } from '@hapi/boom'; import { set } from '@elastic/safer-lodash-set'; -import { findIndex } from 'lodash'; +import { get } from 'lodash'; import { getClustersStats } from './get_clusters_stats'; import { flagSupportedClusters } from './flag_supported_clusters'; import { getMlJobsForCluster } from '../elasticsearch'; @@ -98,7 +98,7 @@ export async function getClustersFromRequest( cluster.logs = isInCodePath(codePaths, [CODE_PATH_LOGS]) ? await getLogTypes(req, filebeatIndexPattern, { - clusterUuid: cluster.cluster_uuid, + clusterUuid: get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid), start, end, }) @@ -122,7 +122,7 @@ export async function getClustersFromRequest( alertsClient, req.server.plugins.monitoring.info, undefined, - clusters.map((cluster) => cluster.cluster_uuid) + clusters.map((cluster) => get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid)) ); for (const cluster of clusters) { @@ -142,7 +142,9 @@ export async function getClustersFromRequest( accum[alertName] = { ...value, states: value.states.filter( - (state) => state.state.cluster.clusterUuid === cluster.cluster_uuid + (state) => + state.state.cluster.clusterUuid === + get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid) ), }; } else { @@ -177,7 +179,10 @@ export async function getClustersFromRequest( : []; // add the kibana data to each cluster kibanas.forEach((kibana) => { - const clusterIndex = findIndex(clusters, { cluster_uuid: kibana.clusterUuid }); + const clusterIndex = clusters.findIndex( + (cluster) => + get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid) === kibana.clusterUuid + ); set(clusters[clusterIndex], 'kibana', kibana.stats); }); @@ -186,8 +191,10 @@ export async function getClustersFromRequest( const logstashes = await getLogstashForClusters(req, lsIndexPattern, clusters); const pipelines = await getLogstashPipelineIds(req, lsIndexPattern, { clusterUuid }, 1); logstashes.forEach((logstash) => { - const clusterIndex = findIndex(clusters, { cluster_uuid: logstash.clusterUuid }); - + const clusterIndex = clusters.findIndex( + (cluster) => + get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid) === logstash.clusterUuid + ); // withhold LS overview stats until there is at least 1 pipeline if (logstash.clusterUuid === clusterUuid && !pipelines.length) { logstash.stats = {}; @@ -201,7 +208,10 @@ export async function getClustersFromRequest( ? await getBeatsForClusters(req, beatsIndexPattern, clusters) : []; beatsByCluster.forEach((beats) => { - const clusterIndex = findIndex(clusters, { cluster_uuid: beats.clusterUuid }); + const clusterIndex = clusters.findIndex( + (cluster) => + get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid) === beats.clusterUuid + ); set(clusters[clusterIndex], 'beats', beats.stats); }); @@ -210,12 +220,17 @@ export async function getClustersFromRequest( ? await getApmsForClusters(req, apmIndexPattern, clusters) : []; apmsByCluster.forEach((apm) => { - const clusterIndex = findIndex(clusters, { cluster_uuid: apm.clusterUuid }); - const { stats, config } = apm; - clusters[clusterIndex].apm = { - ...stats, - config, - }; + const clusterIndex = clusters.findIndex( + (cluster) => + get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid) === apm.clusterUuid + ); + if (clusterIndex >= 0) { + const { stats, config } = apm; + clusters[clusterIndex].apm = { + ...stats, + config, + }; + } }); // check ccr configuration diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.ts index 4b46c44b57aae..2cec89b18aecd 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_state.ts @@ -54,8 +54,8 @@ export function getClustersState( checkParam(esIndexPattern, 'esIndexPattern in cluster/getClustersHealth'); const clusterUuids = clusters - .filter((cluster) => !cluster.cluster_state) - .map((cluster) => cluster.cluster_uuid); + .filter((cluster) => !cluster.cluster_state || !cluster.elasticsearch?.cluster?.stats?.state) + .map((cluster) => cluster.cluster_uuid || cluster.elasticsearch?.cluster?.id); // we only need to fetch the cluster state if we don't already have it // newer documents (those from the version 6 schema and later already have the cluster state with cluster stats) @@ -69,8 +69,9 @@ export function getClustersState( ignoreUnavailable: true, filterPath: [ 'hits.hits._source.cluster_uuid', + 'hits.hits._source.elasticsearch.cluster.id', 'hits.hits._source.cluster_state', - 'hits.hits._source.cluster_state', + 'hits.hits._source.elasticsearch.cluster.stats.state', ], body: { query: { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts index df62346f965a3..4067293e7829d 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts @@ -57,15 +57,26 @@ function fetchClusterStats(req: LegacyRequest, esIndexPattern: string, clusterUu filterPath: [ 'hits.hits._index', 'hits.hits._source.cluster_uuid', + 'hits.hits._source.elasticsearch.cluster.id', 'hits.hits._source.cluster_name', + 'hits.hits._source.elasticsearch.cluster.name', 'hits.hits._source.version', + 'hits.hits._source.elasticsearch.version', + 'hits.hits._source.elasticsearch.cluster.node.version', 'hits.hits._source.license.status', // license data only includes necessary fields to drive UI + 'hits.hits._source.elasticsearch.cluster.stats.license.status', 'hits.hits._source.license.type', + 'hits.hits._source.elasticsearch.cluster.stats.license.type', 'hits.hits._source.license.issue_date', + 'hits.hits._source.elasticsearch.cluster.stats.license.issue_date', 'hits.hits._source.license.expiry_date', + 'hits.hits._source.elasticsearch.cluster.stats.license.expiry_date', 'hits.hits._source.license.expiry_date_in_millis', + 'hits.hits._source.elasticsearch.cluster.stats.license.expiry_date_in_millis', 'hits.hits._source.cluster_stats', + 'hits.hits._source.elasticsearch.cluster.stats', 'hits.hits._source.cluster_state', + 'hits.hits._source.elasticsearch.cluster.stats.state', 'hits.hits._source.cluster_settings.cluster.metadata.display_name', ], body: { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.js deleted file mode 100644 index 94cef6eb08e95..0000000000000 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { pick, omit, get } from 'lodash'; -import { calculateOverallStatus } from '../calculate_overall_status'; -import { LOGGING_TAG } from '../../../common/constants'; -import { MonitoringLicenseError } from '../errors/custom_errors'; - -export function getClustersSummary(server, clusters, kibanaUuid, isCcrEnabled) { - return clusters.map((cluster) => { - const { - isSupported, - cluster_uuid: clusterUuid, - version, - license, - cluster_stats: clusterStats, - logstash, - kibana, - ml, - beats, - apm, - alerts, - ccs, - cluster_settings: clusterSettings, - logs, - } = cluster; - - const clusterName = get(clusterSettings, 'cluster.metadata.display_name', cluster.cluster_name); - - // check for any missing licenses - if (!license) { - const clusterId = cluster.name || clusterName || clusterUuid; - server.log( - ['error', LOGGING_TAG], - "Could not find license information for cluster = '" + - clusterId + - "'. " + - "Please check the cluster's master node server logs for errors or warnings." - ); - throw new MonitoringLicenseError(clusterId); - } - - const { - status: licenseStatus, - type: licenseType, - expiry_date_in_millis: licenseExpiry, - } = license; - - const indices = pick(clusterStats.indices, ['count', 'docs', 'shards', 'store']); - - const jvm = { - max_uptime_in_millis: clusterStats.nodes.jvm.max_uptime_in_millis, - mem: clusterStats.nodes.jvm.mem, - }; - - const nodes = { - fs: clusterStats.nodes.fs, - count: { - total: clusterStats.nodes.count.total, - }, - jvm, - }; - const { status } = cluster.cluster_state; - - return { - isSupported, - cluster_uuid: clusterUuid, - cluster_name: clusterName, - version, - license: { - status: licenseStatus, - type: licenseType, - expiry_date_in_millis: licenseExpiry, - }, - elasticsearch: { - cluster_stats: { - indices, - nodes, - status, - }, - logs, - }, - logstash, - kibana: omit(kibana, 'uuids'), - ml, - ccs, - beats, - apm, - alerts, - isPrimary: kibana ? kibana.uuids.includes(kibanaUuid) : false, - status: calculateOverallStatus([status, (kibana && kibana.status) || null]), - isCcrEnabled, - }; - }); -} diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.test.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.test.js index 22e8cb65cec64..0ddb87104ec71 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.test.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.test.js @@ -10,7 +10,9 @@ import { getClustersSummary } from './get_clusters_summary'; const mockLog = jest.fn(); const mockServer = { - log: mockLog, + log: { + error: mockLog, + }, }; describe('getClustersSummary', () => { @@ -34,7 +36,6 @@ describe('getClustersSummary', () => { expect(() => getClustersSummary(mockServer, fakeClusters)).toThrow('Monitoring License Error'); expect(mockLog).toHaveBeenCalledWith( - ['error', 'monitoring'], "Could not find license information for cluster = 'Custom name'. " + "Please check the cluster's master node server logs for errors or warnings." ); diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts new file mode 100644 index 0000000000000..955c4d3d3b625 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_summary.ts @@ -0,0 +1,161 @@ +/* + * 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 { omit, get } from 'lodash'; +import { + ElasticsearchModifiedSource, + ElasticsearchLegacySource, + ElasticsearchSourceKibanaStats, +} from '../../../common/types/es'; +// @ts-ignore +import { calculateOverallStatus } from '../calculate_overall_status'; +// @ts-ignore +import { MonitoringLicenseError } from '../errors/custom_errors'; + +type EnhancedClusters = ElasticsearchModifiedSource & { + license: ElasticsearchLegacySource['license']; + [key: string]: any; +}; + +type EnhancedKibana = ElasticsearchSourceKibanaStats['kibana'] & { + uuids?: string[]; +}; + +export function getClustersSummary( + server: any, + clusters: EnhancedClusters[], + kibanaUuid: string, + isCcrEnabled: boolean +) { + return clusters.map((cluster) => { + const { + isSupported, + logstash, + kibana, + ml, + beats, + apm, + alerts, + ccs, + cluster_settings: clusterSettings, + logs, + } = cluster; + + const license = cluster.license || cluster.elasticsearch?.cluster?.stats?.license; + const version = cluster.version || cluster.elasticsearch?.version; + const clusterUuid = cluster.cluster_uuid || cluster.elasticsearch?.cluster?.id; + const clusterStatsLegacy = cluster.cluster_stats; + const clusterStatsMB = cluster.elasticsearch?.cluster?.stats; + + const clusterName = get( + clusterSettings, + 'cluster.metadata.display_name', + cluster.elasticsearch?.cluster?.name ?? cluster.cluster_name + ); + + // check for any missing licenses + if (!license) { + const clusterId = cluster.name || clusterName || clusterUuid; + server.log.error( + "Could not find license information for cluster = '" + + clusterId + + "'. " + + "Please check the cluster's master node server logs for errors or warnings." + ); + throw new MonitoringLicenseError(clusterId); + } + + const { + status: licenseStatus, + type: licenseType, + expiry_date_in_millis: licenseExpiry, + } = license; + + const indices = { + count: clusterStatsLegacy?.indices?.count ?? clusterStatsMB?.indices?.total, + docs: { + deleted: + clusterStatsLegacy?.indices?.docs?.deleted ?? clusterStatsMB?.indices?.docs?.deleted, + count: clusterStatsLegacy?.indices?.docs?.count ?? clusterStatsMB?.indices?.docs?.total, + }, + shards: { + total: clusterStatsLegacy?.indices?.shards?.total ?? clusterStatsMB?.indices?.shards?.count, + primaries: + clusterStatsLegacy?.indices?.shards?.primaries ?? + clusterStatsMB?.indices?.shards?.primaries, + }, + store: { + size_in_bytes: + clusterStatsLegacy?.indices?.store?.size_in_bytes ?? + clusterStatsMB?.indices?.store?.size?.bytes, + }, + }; + + const jvm = { + max_uptime_in_millis: + clusterStatsLegacy?.nodes?.jvm?.max_uptime_in_millis ?? + clusterStatsMB?.nodes?.jvm?.max_uptime?.ms, + mem: { + heap_max_in_bytes: + clusterStatsLegacy?.nodes?.jvm?.mem?.heap_max_in_bytes ?? + clusterStatsMB?.nodes?.jvm?.memory?.heap?.max?.bytes, + heap_used_in_bytes: + clusterStatsLegacy?.nodes?.jvm?.mem?.heap_used_in_bytes ?? + clusterStatsMB?.nodes?.jvm?.memory?.heap?.used?.bytes, + }, + }; + + const nodes = { + fs: { + total_in_bytes: + clusterStatsLegacy?.nodes?.fs?.total_in_bytes ?? clusterStatsMB?.nodes?.fs?.total?.bytes, + available_in_bytes: + clusterStatsLegacy?.nodes?.fs?.available_in_bytes ?? + clusterStatsMB?.nodes?.fs?.available?.bytes, + }, + count: { + total: clusterStatsLegacy?.nodes?.count?.total ?? clusterStatsMB?.nodes?.count, + }, + jvm, + }; + const { status } = cluster.cluster_state ?? + cluster?.elasticsearch?.cluster?.stats ?? { status: null }; + + return { + isSupported, + cluster_uuid: clusterUuid, + cluster_name: clusterName, + version, + license: { + status: licenseStatus, + type: licenseType, + expiry_date_in_millis: licenseExpiry, + }, + elasticsearch: { + cluster_stats: { + indices, + nodes, + status, + }, + logs, + }, + logstash, + kibana: omit(kibana, 'uuids'), + ml, + ccs, + beats, + apm, + alerts, + isPrimary: kibana ? (kibana as EnhancedKibana).uuids?.includes(kibanaUuid) : false, + status: calculateOverallStatus([ + status, + (kibana && (kibana as EnhancedKibana).status) || null, + ]), + isCcrEnabled, + }; + }); +} diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.js b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.js index 970acf901e920..52f2bb7b19736 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.js @@ -32,7 +32,7 @@ export function getIndexPatterns(server, additionalPatterns = {}, ccs = '*') { ...Object.keys(additionalPatterns).reduce((accum, varName) => { return { ...accum, - [varName]: prefixIndexPattern(config, additionalPatterns[varName], ccs), + [varName]: prefixIndexPattern(config, additionalPatterns[varName], ccs, true), }; }, {}), }; diff --git a/x-pack/plugins/monitoring/server/lib/create_query.js b/x-pack/plugins/monitoring/server/lib/create_query.js index 532fc2beb7f59..c9a5ffeca3cee 100644 --- a/x-pack/plugins/monitoring/server/lib/create_query.js +++ b/x-pack/plugins/monitoring/server/lib/create_query.js @@ -52,13 +52,22 @@ export function createTimeFilter(options) { */ export function createQuery(options) { options = defaults(options, { filters: [] }); - const { type, clusterUuid, uuid, filters } = options; + const { type, types, clusterUuid, uuid, filters } = options; const isFromStandaloneCluster = clusterUuid === STANDALONE_CLUSTER_CLUSTER_UUID; let typeFilter; if (type) { typeFilter = { bool: { should: [{ term: { type } }, { term: { 'metricset.name': type } }] } }; + } else if (types) { + typeFilter = { + bool: { + should: [ + ...types.map((type) => ({ term: { type } })), + ...types.map((type) => ({ term: { 'metricset.name': type } })), + ], + }, + }; } let clusterUuidFilter; diff --git a/x-pack/plugins/monitoring/server/lib/details/get_series.js b/x-pack/plugins/monitoring/server/lib/details/get_series.js index d89370ba8d4ce..ca062ad5599fa 100644 --- a/x-pack/plugins/monitoring/server/lib/details/get_series.js +++ b/x-pack/plugins/monitoring/server/lib/details/get_series.js @@ -43,7 +43,15 @@ function getUuid(req, metric) { } function defaultCalculation(bucket, key) { - const value = get(bucket, key, null); + const mbKey = `metric_mb_deriv.normalized_value`; + const legacyValue = get(bucket, key, null); + const mbValue = get(bucket, mbKey, null); + let value; + if (!isNaN(mbValue) && mbValue > 0) { + value = mbValue; + } else { + value = legacyValue; + } // negatives suggest derivatives that have been reset (usually due to restarts that reset the count) if (value < 0) { return null; @@ -54,6 +62,17 @@ function defaultCalculation(bucket, key) { function createMetricAggs(metric) { if (metric.derivative) { + const mbDerivative = metric.mbField + ? { + metric_mb_deriv: { + derivative: { + buckets_path: 'metric_mb', + gap_policy: 'skip', + unit: NORMALIZED_DERIVATIVE_UNIT, + }, + }, + } + : {}; return { metric_deriv: { derivative: { @@ -62,6 +81,7 @@ function createMetricAggs(metric) { unit: NORMALIZED_DERIVATIVE_UNIT, }, }, + ...mbDerivative, ...metric.aggs, }; } @@ -97,6 +117,13 @@ async function fetchSeries( }, ...createMetricAggs(metric), }; + if (metric.mbField) { + dateHistogramSubAggs.metric_mb = { + [metric.metricAgg]: { + field: metric.mbField, + }, + }; + } } let aggs = { @@ -209,7 +236,7 @@ function isObject(value) { } function countBuckets(data, count = 0) { - if (data.buckets) { + if (data && data.buckets) { count += data.buckets.length; for (const bucket of data.buckets) { for (const key of Object.keys(bucket)) { @@ -218,7 +245,7 @@ function countBuckets(data, count = 0) { } } } - } else { + } else if (data) { for (const key of Object.keys(data)) { if (isObject(data[key])) { count = countBuckets(data[key], count); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.ts index d86ef0218aba6..d0abed2ad8b8d 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/ccr.ts @@ -15,15 +15,8 @@ import { createQuery } from '../create_query'; import { ElasticsearchResponse } from '../../../common/types/es'; import { LegacyRequest } from '../../types'; -export function handleResponse(response: ElasticsearchResponse) { - const isEnabled = response.hits?.hits[0]?._source.stack_stats?.xpack?.ccr?.enabled ?? undefined; - const isAvailable = - response.hits?.hits[0]?._source.stack_stats?.xpack?.ccr?.available ?? undefined; - return isEnabled && isAvailable; -} - export async function checkCcrEnabled(req: LegacyRequest, esIndexPattern: string) { - checkParam(esIndexPattern, 'esIndexPattern in getNodes'); + checkParam(esIndexPattern, 'esIndexPattern in checkCcrEnabled'); const start = moment.utc(req.payload.timeRange.min).valueOf(); const end = moment.utc(req.payload.timeRange.max).valueOf(); @@ -45,10 +38,17 @@ export async function checkCcrEnabled(req: LegacyRequest, esIndexPattern: string }), sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], }, - filterPath: ['hits.hits._source.stack_stats.xpack.ccr'], + filterPath: [ + 'hits.hits._source.stack_stats.xpack.ccr', + 'hits.hits._source.elasticsearch.cluster.stats.stack.xpack.ccr', + ], }; const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - const response = await callWithRequest(req, 'search', params); - return handleResponse(response); + const response: ElasticsearchResponse = await callWithRequest(req, 'search', params); + const legacyCcr = response.hits?.hits[0]?._source.stack_stats?.xpack?.ccr; + const mbCcr = response.hits?.hits[0]?._source?.elasticsearch?.cluster?.stats?.stack?.xpack?.ccr; + const isEnabled = legacyCcr?.enabled ?? mbCcr?.enabled; + const isAvailable = legacyCcr?.available ?? mbCcr?.available; + return isEnabled && isAvailable; } diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.test.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.test.js index 791d9ac78f641..2b43e6523128b 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.test.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { handleLastRecoveries, filterOldShardActivity } from './get_last_recovery'; +import { handleLegacyLastRecoveries, filterOldShardActivity } from './get_last_recovery'; describe('get_last_recovery', () => { // Note: times are from the epoch! @@ -47,24 +47,24 @@ describe('get_last_recovery', () => { it('No hits results in an empty array', () => { // Note: we don't expect it to touch hits without total === 1 - expect(handleLastRecoveries({ hits: { hits: [] } }, new Date(0))).toHaveLength(0); + expect(handleLegacyLastRecoveries({ hits: { hits: [] } }, new Date(0))).toHaveLength(0); }); it('Filters on stop time', () => { - expect(handleLastRecoveries(resp, new Date(0))).toHaveLength(5); - expect(handleLastRecoveries(resp, new Date(99))).toHaveLength(5); - expect(handleLastRecoveries(resp, new Date(100))).toHaveLength(5); - expect(handleLastRecoveries(resp, new Date(101))).toHaveLength(3); - expect(handleLastRecoveries(resp, new Date(501))).toHaveLength(0); + expect(handleLegacyLastRecoveries(resp, new Date(0))).toHaveLength(5); + expect(handleLegacyLastRecoveries(resp, new Date(99))).toHaveLength(5); + expect(handleLegacyLastRecoveries(resp, new Date(100))).toHaveLength(5); + expect(handleLegacyLastRecoveries(resp, new Date(101))).toHaveLength(3); + expect(handleLegacyLastRecoveries(resp, new Date(501))).toHaveLength(0); - const filteredActivities = handleLastRecoveries(resp, new Date(301)); + const filteredActivities = handleLegacyLastRecoveries(resp, new Date(301)); expect(filteredActivities).toHaveLength(1); expect(filteredActivities[0].stop_time_in_millis).toEqual(500); }); it('Sorts based on start time (descending)', () => { - const sortedActivities = handleLastRecoveries(resp, new Date(0)); + const sortedActivities = handleLegacyLastRecoveries(resp, new Date(0)); expect(sortedActivities[0].start_time_in_millis).toEqual(100); expect(sortedActivities[4].start_time_in_millis).toEqual(0); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts index 5350fe3e96ee9..b666934264933 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts @@ -13,7 +13,11 @@ import { checkParam } from '../error_missing_required'; import { createQuery } from '../create_query'; // @ts-ignore import { ElasticsearchMetric } from '../metrics'; -import { ElasticsearchResponse, ElasticsearchIndexRecoveryShard } from '../../../common/types/es'; +import { + ElasticsearchResponse, + ElasticsearchIndexRecoveryShard, + ElasticsearchResponseHit, +} from '../../../common/types/es'; import { LegacyRequest } from '../../types'; /** @@ -28,9 +32,12 @@ import { LegacyRequest } from '../../types'; * @returns {boolean} true to keep */ export function filterOldShardActivity(startMs: number) { - return (activity: ElasticsearchIndexRecoveryShard) => { + return (activity?: ElasticsearchIndexRecoveryShard) => { // either it's still going and there is no stop time, or the stop time happened after we started looking for one - return !_.isNumber(activity.stop_time_in_millis) || activity.stop_time_in_millis >= startMs; + return ( + activity && + (!_.isNumber(activity.stop_time_in_millis) || activity.stop_time_in_millis >= startMs) + ); }; } @@ -42,19 +49,44 @@ export function filterOldShardActivity(startMs: number) { * @param {Date} start The start time from the request payload (expected to be of type {@code Date}) * @returns {Object[]} An array of shards representing active shard activity from {@code _source.index_recovery.shards}. */ -export function handleLastRecoveries(resp: ElasticsearchResponse, start: number) { +export function handleLegacyLastRecoveries(resp: ElasticsearchResponse, start: number) { if (resp.hits?.hits.length === 1) { const data = (resp.hits?.hits[0]?._source.index_recovery?.shards ?? []).filter( filterOldShardActivity(moment.utc(start).valueOf()) ); - data.sort((a, b) => b.start_time_in_millis - a.start_time_in_millis); + data.sort((a, b) => (b.start_time_in_millis ?? 0) - (a.start_time_in_millis ?? 0)); return data; } return []; } -export function getLastRecovery(req: LegacyRequest, esIndexPattern: string) { +// For MB, we index individual documents instead of a single document with a list of recovered shards +// This means we need to query a bit differently to end up with the same result. We need to ensure +// that our recovered shards are within the same time window to match the legacy query (of size: 1) +export function handleMbLastRecoveries(resp: ElasticsearchResponse, start: number) { + const hits = resp.hits?.hits ?? []; + const groupedByTimestamp = hits.reduce( + (accum: { [timestamp: string]: ElasticsearchResponseHit[] }, hit) => { + const timestamp = hit._source['@timestamp'] ?? ''; + accum[timestamp] = accum[timestamp] || []; + accum[timestamp].push(hit); + return accum; + }, + {} + ); + const maxTimestamp = resp.aggregations?.max_timestamp?.value_as_string; + const mapped = (groupedByTimestamp[maxTimestamp] ?? []).map( + (hit) => hit._source.elasticsearch?.index?.recovery + ); + const filtered = mapped.filter(filterOldShardActivity(moment.utc(start).valueOf())); + filtered.sort((a, b) => + a && b ? (b.start_time_in_millis ?? 0) - (a.start_time_in_millis ?? 0) : 0 + ); + return filtered; +} + +export async function getLastRecovery(req: LegacyRequest, esIndexPattern: string, size: number) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getLastRecovery'); const start = req.payload.timeRange.min; @@ -62,7 +94,7 @@ export function getLastRecovery(req: LegacyRequest, esIndexPattern: string) { const clusterUuid = req.params.clusterUuid; const metric = ElasticsearchMetric.getMetricFields(); - const params = { + const legacyParams = { index: esIndexPattern, size: 1, ignoreUnavailable: true, @@ -72,9 +104,31 @@ export function getLastRecovery(req: LegacyRequest, esIndexPattern: string) { query: createQuery({ type: 'index_recovery', start, end, clusterUuid, metric }), }, }; + const mbParams = { + index: esIndexPattern, + size, + ignoreUnavailable: true, + body: { + _source: ['elasticsearch.index.recovery', '@timestamp'], + sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, + query: createQuery({ type: 'index_recovery', start, end, clusterUuid, metric }), + aggs: { + max_timestamp: { + max: { + field: '@timestamp', + }, + }, + }, + }, + }; const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - return callWithRequest(req, 'search', params).then((resp) => { - return handleLastRecoveries(resp, start); - }); + const [legacyResp, mbResp] = await Promise.all([ + callWithRequest(req, 'search', legacyParams), + callWithRequest(req, 'search', mbParams), + ]); + const legacyResult = handleLegacyLastRecoveries(legacyResp, start); + const mbResult = handleMbLastRecoveries(mbResp, start); + + return [...legacyResult, ...mbResult]; } diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.ts index dcd2040c03719..e825c11566135 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_ml_jobs.ts @@ -21,7 +21,18 @@ import { LegacyRequest } from '../../types'; */ export function handleResponse(response: ElasticsearchResponse) { const hits = response.hits?.hits; - return hits?.map((hit) => hit._source.job_stats) ?? []; + return ( + hits?.map((hit) => { + const job = hit._source.job_stats ?? hit._source.elasticsearch; + return { + ...job, + node: { + ...job?.node, + name: job?.node?.name ?? job?.node?.id, + }, + }; + }) ?? [] + ); } export function getMlJobs(req: LegacyRequest, esIndexPattern: string) { @@ -39,17 +50,24 @@ export function getMlJobs(req: LegacyRequest, esIndexPattern: string) { ignoreUnavailable: true, filterPath: [ 'hits.hits._source.job_stats.job_id', + 'hits.hits._source.elasticsearch.ml.job.id', 'hits.hits._source.job_stats.state', + 'hits.hits._source.elasticsearch.ml.job.state', 'hits.hits._source.job_stats.data_counts.processed_record_count', + 'hits.hits._source.elasticsearch.ml.job.data_counts.processed_record_count', 'hits.hits._source.job_stats.model_size_stats.model_bytes', + 'hits.hits._source.elasticsearch.ml.job.model_size_stats.model_bytes', 'hits.hits._source.job_stats.forecasts_stats.total', + 'hits.hits._source.elasticsearch.ml.job.forecasts_stats.total', 'hits.hits._source.job_stats.node.id', + 'hits.hits._source.elasticsearch.node.id', 'hits.hits._source.job_stats.node.name', + 'hits.hits._source.elasticsearch.node.name', ], body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, collapse: { field: 'job_stats.job_id' }, - query: createQuery({ type: 'job_stats', start, end, clusterUuid, metric }), + query: createQuery({ types: ['ml_job', 'job_stats'], start, end, clusterUuid, metric }), }, }; @@ -66,7 +84,7 @@ export function getMlJobsForCluster( esIndexPattern: string, cluster: ElasticsearchSource ) { - const license = cluster.license ?? {}; + const license = cluster.license ?? cluster.elasticsearch?.cluster?.stats?.license ?? {}; if (license.status === 'active' && includes(ML_SUPPORTED_LICENSES, license.type)) { // ML is supported @@ -80,7 +98,7 @@ export function getMlJobsForCluster( ignoreUnavailable: true, filterPath: 'aggregations.jobs_count.value', body: { - query: createQuery({ type: 'job_stats', start, end, clusterUuid, metric }), + query: createQuery({ types: ['ml_job', 'job_stats'], start, end, clusterUuid, metric }), aggs: { jobs_count: { cardinality: { field: 'job_stats.job_id' } }, }, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.ts index eda096bd0af16..ee4988d773974 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_index_summary.ts @@ -18,7 +18,9 @@ import { LegacyRequest } from '../../../types'; export function handleResponse(shardStats: any, indexUuid: string) { return (response: ElasticsearchResponse) => { - const indexStats = response.hits?.hits[0]?._source.index_stats; + const indexStats = + response.hits?.hits[0]?._source.index_stats ?? + response.hits?.hits[0]?._source.elasticsearch?.index; const primaries = indexStats?.primaries; const total = indexStats?.total; @@ -74,14 +76,30 @@ export function getIndexSummary( checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getIndexSummary'); const metric = ElasticsearchMetric.getMetricFields(); - const filters = [{ term: { 'index_stats.index': indexUuid } }]; + const filters = [ + { + bool: { + should: [ + { term: { 'index_stats.index': indexUuid } }, + { term: { 'elasticsearch.index.name': indexUuid } }, + ], + }, + }, + ]; const params = { index: esIndexPattern, size: 1, ignoreUnavailable: true, body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, - query: createQuery({ type: 'index_stats', start, end, clusterUuid, metric, filters }), + query: createQuery({ + types: ['index', 'index_stats'], + start, + end, + clusterUuid, + metric, + filters, + }), }, }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.ts index 47a6f31430c73..9a61d6c603b97 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/indices/get_indices.ts @@ -29,12 +29,16 @@ export function handleResponse( // map the hits const hits = resp?.hits?.hits ?? []; return hits.map((hit) => { - const stats = hit._source.index_stats; - const earliestStats = hit.inner_hits?.earliest?.hits?.hits[0]?._source.index_stats; + const stats = hit._source.index_stats ?? hit._source.elasticsearch?.index; + const earliestStats = + hit.inner_hits?.earliest?.hits?.hits[0]?._source.index_stats ?? + hit.inner_hits?.earliest?.hits?.hits[0]?._source.elasticsearch?.index; const rateOptions = { - hitTimestamp: hit._source.timestamp, - earliestHitTimestamp: hit.inner_hits?.earliest?.hits?.hits[0]?._source.timestamp, + hitTimestamp: hit._source.timestamp ?? hit._source['@timestamp'], + earliestHitTimestamp: + hit.inner_hits?.earliest?.hits?.hits[0]?._source.timestamp ?? + hit.inner_hits?.earliest?.hits?.hits[0]?._source['@timestamp'], timeWindowMin: min, timeWindowMax: max, }; @@ -53,7 +57,7 @@ export function handleResponse( ...rateOptions, }); - const shardStatsForIndex = get(shardStats, ['indices', stats?.index ?? '']); + const shardStatsForIndex = get(shardStats, ['indices', stats?.index ?? stats?.name ?? '']); let status; let statusSort; let unassignedShards; @@ -77,7 +81,7 @@ export function handleResponse( } return { - name: stats?.index, + name: stats?.index ?? stats?.name, status, doc_count: stats?.primaries?.docs?.count, data_size: stats?.total?.store?.size_in_bytes, @@ -116,22 +120,31 @@ export function buildGetIndicesQuery( filterPath: [ // only filter path can filter for inner_hits 'hits.hits._source.index_stats.index', + 'hits.hits._source.elasticsearch.index.name', 'hits.hits._source.index_stats.primaries.docs.count', + 'hits.hits._source.elasticsearch.index.primaries.docs.count', 'hits.hits._source.index_stats.total.store.size_in_bytes', + 'hits.hits._source.elasticsearch.index.total.store.size_in_bytes', // latest hits for calculating metrics 'hits.hits._source.timestamp', + 'hits.hits._source.@timestamp', 'hits.hits._source.index_stats.primaries.indexing.index_total', + 'hits.hits._source.elasticsearch.index.primaries.indexing.index_total', 'hits.hits._source.index_stats.total.search.query_total', + 'hits.hits._source.elasticsearch.index.total.search.query_total', // earliest hits for calculating metrics 'hits.hits.inner_hits.earliest.hits.hits._source.timestamp', + 'hits.hits.inner_hits.earliest.hits.hits._source.@timestamp', 'hits.hits.inner_hits.earliest.hits.hits._source.index_stats.primaries.indexing.index_total', + 'hits.hits.inner_hits.earliest.hits.hits._source.elasticsearch.index.primaries.indexing.index_total', 'hits.hits.inner_hits.earliest.hits.hits._source.index_stats.total.search.query_total', + 'hits.hits.inner_hits.earliest.hits.hits._source.elasticsearch.index.total.search.query_total', ], body: { query: createQuery({ - type: 'index_stats', + types: ['index', 'index_stats'], start, end, clusterUuid, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/calculate_node_type.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/calculate_node_type.js index 8f15132930fd9..4b2bcb4cda432 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/calculate_node_type.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/calculate_node_type.js @@ -25,7 +25,9 @@ export function calculateNodeType(node, masterNodeId) { return attr === 'false'; } - if (node.uuid !== undefined && node.uuid === masterNodeId) { + const uuid = node.uuid ?? node.id; + + if (uuid !== undefined && uuid === masterNodeId) { return 'master'; } if (includes(node.node_ids, masterNodeId)) { diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.ts index ace6baf76f80a..2235d3d2f3224 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.ts @@ -112,6 +112,7 @@ export async function getNodes( }, filterPath: [ 'hits.hits._source.source_node', + 'hits.hits._source.service.address', 'hits.hits._source.elasticsearch.node', 'aggregations.nodes.buckets.key', ...LISTING_METRICS_PATHS, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.ts index 8139ae48c0188..745556f5d2c88 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.ts @@ -26,12 +26,13 @@ export function mapNodesInfo( clusterStats?: ElasticsearchModifiedSource, nodesShardCount?: { nodes: { [nodeId: string]: { shardCount: number } } } ) { - const clusterState = clusterStats?.cluster_state ?? { nodes: {} }; + const clusterState = + clusterStats?.cluster_state ?? clusterStats?.elasticsearch?.cluster?.stats?.state; return nodeHits.reduce((prev, node) => { const sourceNode = node._source.source_node || node._source.elasticsearch?.node; - const calculatedNodeType = calculateNodeType(sourceNode, clusterState.master_node); + const calculatedNodeType = calculateNodeType(sourceNode, clusterState?.master_node); const { nodeType, nodeTypeLabel, nodeTypeClass } = getNodeTypeClassLabel( sourceNode, calculatedNodeType @@ -40,13 +41,13 @@ export function mapNodesInfo( if (!uuid) { return prev; } - const isOnline = !isUndefined(clusterState.nodes ? clusterState.nodes[uuid] : undefined); + const isOnline = !isUndefined(clusterState?.nodes ? clusterState.nodes[uuid] : undefined); return { ...prev, [uuid]: { name: sourceNode?.name, - transport_address: sourceNode?.transport_address, + transport_address: node._source.service?.address ?? sourceNode?.transport_address, type: nodeType, isOnline, nodeTypeLabel, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.ts similarity index 52% rename from x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.ts index e5ae6bf448ac9..d3acf8ccaf443 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.ts @@ -6,16 +6,38 @@ */ import { get } from 'lodash'; +// @ts-ignore import { checkParam } from '../../error_missing_required'; +// @ts-ignore import { createQuery } from '../../create_query'; +// @ts-ignore import { ElasticsearchMetric } from '../../metrics'; +// @ts-ignore import { calculateIndicesTotals } from './calculate_shard_stat_indices_totals'; +import { LegacyRequest } from '../../../types'; +import { ElasticsearchModifiedSource } from '../../../../common/types/es'; -async function getUnassignedShardData(req, esIndexPattern, cluster) { +async function getUnassignedShardData( + req: LegacyRequest, + esIndexPattern: string, + cluster: ElasticsearchModifiedSource +) { const config = req.server.config(); const maxBucketSize = config.get('monitoring.ui.max_bucket_size'); const metric = ElasticsearchMetric.getMetricFields(); + const filters = []; + if (cluster.cluster_state?.state_uuid) { + filters.push({ term: { state_uuid: cluster.cluster_state?.state_uuid } }); + } else if (cluster.elasticsearch?.cluster?.stats?.state?.state_uuid) { + filters.push({ + term: { + 'elasticsearch.cluster.stats.state.state_uuid': + cluster.elasticsearch?.cluster?.stats?.state?.state_uuid, + }, + }); + } + const params = { index: esIndexPattern, size: 0, @@ -23,10 +45,10 @@ async function getUnassignedShardData(req, esIndexPattern, cluster) { body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ - type: 'shards', - clusterUuid: cluster.cluster_uuid, + types: ['shard', 'shards'], + clusterUuid: cluster.cluster_uuid ?? cluster.elasticsearch?.cluster?.id, metric, - filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + filters, }), aggs: { indices: { @@ -60,34 +82,41 @@ async function getUnassignedShardData(req, esIndexPattern, cluster) { return await callWithRequest(req, 'search', params); } -export async function getIndicesUnassignedShardStats(req, esIndexPattern, cluster) { +export async function getIndicesUnassignedShardStats( + req: LegacyRequest, + esIndexPattern: string, + cluster: ElasticsearchModifiedSource +) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); const response = await getUnassignedShardData(req, esIndexPattern, cluster); - const indices = get(response, 'aggregations.indices.buckets', []).reduce((accum, bucket) => { - const index = bucket.key; - const states = get(bucket, 'state.primary.buckets', []); - const unassignedReplica = states - .filter((state) => state.key_as_string === 'false') - .reduce((total, state) => total + state.doc_count, 0); - const unassignedPrimary = states - .filter((state) => state.key_as_string === 'true') - .reduce((total, state) => total + state.doc_count, 0); + const indices = get(response, 'aggregations.indices.buckets', []).reduce( + (accum: any, bucket: any) => { + const index = bucket.key; + const states = get(bucket, 'state.primary.buckets', []); + const unassignedReplica = states + .filter((state: any) => state.key_as_string === 'false') + .reduce((total: number, state: any) => total + state.doc_count, 0); + const unassignedPrimary = states + .filter((state: any) => state.key_as_string === 'true') + .reduce((total: number, state: any) => total + state.doc_count, 0); - let status = 'green'; - if (unassignedReplica > 0) { - status = 'yellow'; - } - if (unassignedPrimary > 0) { - status = 'red'; - } + let status = 'green'; + if (unassignedReplica > 0) { + status = 'yellow'; + } + if (unassignedPrimary > 0) { + status = 'red'; + } - accum[index] = { - unassigned: { primary: unassignedPrimary, replica: unassignedReplica }, - status, - }; - return accum; - }, {}); + accum[index] = { + unassigned: { primary: unassignedPrimary, replica: unassignedReplica }, + status, + }; + return accum; + }, + {} + ); const indicesTotals = calculateIndicesTotals(indices); return { indices, indicesTotals }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.ts similarity index 55% rename from x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.ts index 68573653ac58a..7cda87bf09af8 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.ts @@ -6,15 +6,36 @@ */ import { get } from 'lodash'; +// @ts-ignore import { checkParam } from '../../error_missing_required'; +// @ts-ignore import { createQuery } from '../../create_query'; +// @ts-ignore import { ElasticsearchMetric } from '../../metrics'; +import { LegacyRequest } from '../../../types'; +import { ElasticsearchModifiedSource } from '../../../../common/types/es'; -async function getShardCountPerNode(req, esIndexPattern, cluster) { +async function getShardCountPerNode( + req: LegacyRequest, + esIndexPattern: string, + cluster: ElasticsearchModifiedSource +) { const config = req.server.config(); const maxBucketSize = config.get('monitoring.ui.max_bucket_size'); const metric = ElasticsearchMetric.getMetricFields(); + const filters = []; + if (cluster.cluster_state?.state_uuid) { + filters.push({ term: { state_uuid: cluster.cluster_state?.state_uuid } }); + } else if (cluster.elasticsearch?.cluster?.stats?.state?.state_uuid) { + filters.push({ + term: { + 'elasticsearch.cluster.stats.state.state_uuid': + cluster.elasticsearch?.cluster?.stats?.state?.state_uuid, + }, + }); + } + const params = { index: esIndexPattern, size: 0, @@ -22,10 +43,10 @@ async function getShardCountPerNode(req, esIndexPattern, cluster) { body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ - type: 'shards', - clusterUuid: cluster.cluster_uuid, + types: ['shard', 'shards'], + clusterUuid: cluster.cluster_uuid ?? cluster.elasticsearch?.cluster?.id, metric, - filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + filters, }), aggs: { nodes: { @@ -42,13 +63,20 @@ async function getShardCountPerNode(req, esIndexPattern, cluster) { return await callWithRequest(req, 'search', params); } -export async function getNodesShardCount(req, esIndexPattern, cluster) { +export async function getNodesShardCount( + req: LegacyRequest, + esIndexPattern: string, + cluster: ElasticsearchModifiedSource +) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); const response = await getShardCountPerNode(req, esIndexPattern, cluster); - const nodes = get(response, 'aggregations.nodes.buckets', []).reduce((accum, bucket) => { - accum[bucket.key] = { shardCount: bucket.doc_count }; - return accum; - }, {}); + const nodes = get(response, 'aggregations.nodes.buckets', []).reduce( + (accum: any, bucket: any) => { + accum[bucket.key] = { shardCount: bucket.doc_count }; + return accum; + }, + {} + ); return { nodes }; } diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_allocation.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_allocation.ts index 4bbdad6fdee90..b739b4a6533db 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_allocation.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_allocation.ts @@ -13,7 +13,6 @@ import { createQuery } from '../../create_query'; import { ElasticsearchMetric } from '../../metrics'; import { ElasticsearchResponse, ElasticsearchLegacySource } from '../../../../common/types/es'; import { LegacyRequest } from '../../../types'; - export function handleResponse(response: ElasticsearchResponse) { const hits = response.hits?.hits; if (!hits) { @@ -23,16 +22,31 @@ export function handleResponse(response: ElasticsearchResponse) { // deduplicate any shards from earlier days with the same cluster state state_uuid const uniqueShards = new Set(); - // map into object with shard and source properties + // map into object with shard and source propertiesd return hits.reduce((shards: Array, hit) => { - const shard = hit._source.shard; + const legacyShard = hit._source.shard; + const mbShard = hit._source.elasticsearch; - if (shard) { + if (legacyShard || mbShard) { + const index = mbShard?.index?.name ?? legacyShard?.index; + const shardNumber = mbShard?.shard?.number ?? legacyShard?.shard; + const primary = mbShard?.shard?.primary ?? legacyShard?.primary; + const relocatingNode = + mbShard?.shard?.relocating_node?.id ?? legacyShard?.relocating_node ?? null; + const node = mbShard?.node?.id ?? legacyShard?.node; // note: if the request is for a node, then it's enough to deduplicate without primary, but for indices it displays both - const shardId = `${shard.index}-${shard.shard}-${shard.primary}-${shard.relocating_node}-${shard.node}`; + const shardId = `${index}-${shardNumber}-${primary}-${relocatingNode}-${node}`; if (!uniqueShards.has(shardId)) { - shards.push(shard); + // @ts-ignore + shards.push({ + index, + node, + primary, + relocating_node: relocatingNode, + shard: shardNumber, + state: legacyShard?.state ?? mbShard?.shard?.state, + }); uniqueShards.add(shardId); } } @@ -52,10 +66,34 @@ export function getShardAllocation( ) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardAllocation'); - const filters = [{ term: { state_uuid: stateUuid } }, shardFilter]; + const filters = [ + { + bool: { + should: [ + { + term: { + state_uuid: stateUuid, + }, + }, + { + term: { + 'elasticsearch.cluster.state.id': stateUuid, + }, + }, + ], + }, + }, + shardFilter, + ]; + if (!showSystemIndices) { filters.push({ - bool: { must_not: [{ prefix: { 'shard.index': '.' } }] }, + bool: { + must_not: [ + { prefix: { 'shard.index': '.' } }, + { prefix: { 'elasticsearch.index.name': '.' } }, + ], + }, }); } @@ -67,7 +105,7 @@ export function getShardAllocation( size: config.get('monitoring.ui.max_bucket_size'), ignoreUnavailable: true, body: { - query: createQuery({ type: 'shards', clusterUuid, metric, filters }), + query: createQuery({ types: ['shard', 'shards'], clusterUuid, metric, filters }), }, }; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.ts similarity index 58% rename from x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js rename to x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.ts index df09cd977e258..f7e2775328e20 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.ts @@ -6,14 +6,27 @@ */ import { get } from 'lodash'; +// @ts-ignore import { checkParam } from '../../error_missing_required'; +// @ts-ignore import { createQuery } from '../../create_query'; +// @ts-ignore import { ElasticsearchMetric } from '../../metrics'; +// @ts-ignore import { normalizeIndexShards, normalizeNodeShards } from './normalize_shard_objects'; +// @ts-ignore import { getShardAggs } from './get_shard_stat_aggs'; +// @ts-ignore import { calculateIndicesTotals } from './calculate_shard_stat_indices_totals'; +import { LegacyRequest } from '../../../types'; +import { ElasticsearchResponse, ElasticsearchModifiedSource } from '../../../../common/types/es'; -export function handleResponse(resp, includeNodes, includeIndices, cluster) { +export function handleResponse( + resp: ElasticsearchResponse, + includeNodes: boolean, + includeIndices: boolean, + cluster: ElasticsearchModifiedSource +) { let indices; let indicesTotals; let nodes; @@ -25,7 +38,11 @@ export function handleResponse(resp, includeNodes, includeIndices, cluster) { } if (includeNodes) { - const masterNode = get(cluster, 'cluster_state.master_node'); + const masterNode = get( + cluster, + 'elasticsearch.cluster.stats.state.master_node', + get(cluster, 'cluster_state.master_node') + ); nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); } @@ -37,21 +54,44 @@ export function handleResponse(resp, includeNodes, includeIndices, cluster) { } export function getShardStats( - req, - esIndexPattern, - cluster, + req: LegacyRequest, + esIndexPattern: string, + cluster: ElasticsearchModifiedSource, { includeNodes = false, includeIndices = false, indexName = null, nodeUuid = null } = {} ) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); const config = req.server.config(); const metric = ElasticsearchMetric.getMetricFields(); - const filters = [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }]; + const filters = []; + if (cluster.cluster_state?.state_uuid) { + filters.push({ term: { state_uuid: cluster.cluster_state.state_uuid } }); + } else if (cluster.elasticsearch?.cluster?.stats?.state?.state_uuid) { + filters.push({ + term: { + 'elasticsearch.cluster.state.id': cluster.elasticsearch.cluster.stats.state.state_uuid, + }, + }); + } if (indexName) { - filters.push({ term: { 'shard.index': indexName } }); + filters.push({ + bool: { + should: [ + { term: { 'shard.index': indexName } }, + { term: { 'elasticsearch.index.name': indexName } }, + ], + }, + }); } if (nodeUuid) { - filters.push({ term: { 'shard.node': nodeUuid } }); + filters.push({ + bool: { + should: [ + { term: { 'shard.node': nodeUuid } }, + { term: { 'elasticsearch.node.id': nodeUuid } }, + ], + }, + }); } const params = { index: esIndexPattern, @@ -60,8 +100,8 @@ export function getShardStats( body: { sort: { timestamp: { order: 'desc', unmapped_type: 'long' } }, query: createQuery({ - type: 'shards', - clusterUuid: cluster.cluster_uuid, + types: ['shard', 'shards'], + clusterUuid: cluster.cluster_uuid ?? cluster.elasticsearch?.cluster?.id, metric, filters, }), diff --git a/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.ts b/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.ts index a2b9e545fe171..3b0af657947e5 100644 --- a/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.ts +++ b/x-pack/plugins/monitoring/server/lib/kibana/get_kibana_info.ts @@ -14,12 +14,15 @@ import { LegacyRequest } from '../../types'; import { ElasticsearchResponse } from '../../../common/types/es'; export function handleResponse(resp: ElasticsearchResponse) { - const source = resp.hits?.hits[0]?._source.kibana_stats; - const kibana = source?.kibana; + const legacySource = resp.hits?.hits[0]?._source.kibana_stats; + const mbSource = resp.hits?.hits[0]?._source.kibana?.stats; + const kibana = resp.hits?.hits[0]?._source.kibana?.kibana ?? legacySource?.kibana; return merge(kibana, { - availability: calculateAvailability(source?.timestamp), - os_memory_free: source?.os?.memory?.free_in_bytes, - uptime: source?.process?.uptime_in_millis, + availability: calculateAvailability( + resp.hits?.hits[0]?._source['@timestamp'] ?? legacySource?.timestamp + ), + os_memory_free: mbSource?.os?.memory?.free_in_bytes ?? legacySource?.os?.memory?.free_in_bytes, + uptime: mbSource?.process?.uptime?.ms ?? legacySource?.process?.uptime_in_millis, }); } @@ -36,9 +39,13 @@ export function getKibanaInfo( ignoreUnavailable: true, filterPath: [ 'hits.hits._source.kibana_stats.kibana', + 'hits.hits._source.kibana.kibana', 'hits.hits._source.kibana_stats.os.memory.free_in_bytes', + 'hits.hits._source.kibana.stats.os.memory.free_in_bytes', 'hits.hits._source.kibana_stats.process.uptime_in_millis', + 'hits.hits._source.kibana.stats.process.uptime.ms', 'hits.hits._source.kibana_stats.timestamp', + 'hits.hits._source.@timestamp', ], body: { query: { diff --git a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js deleted file mode 100644 index ec481d60767e6..0000000000000 --- a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.js +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import moment from 'moment'; -import { checkParam } from '../error_missing_required'; -import { createQuery } from '../create_query'; -import { calculateAvailability } from '../calculate_availability'; -import { KibanaMetric } from '../metrics'; - -/* - * Get detailed info for Kibanas in the cluster - * for Kibana listing page - * For each instance: - * - name - * - status - * - memory - * - os load average - * - requests - * - response times - */ -export function getKibanas(req, kbnIndexPattern, { clusterUuid }) { - checkParam(kbnIndexPattern, 'kbnIndexPattern in getKibanas'); - - const config = req.server.config(); - const start = moment.utc(req.payload.timeRange.min).valueOf(); - const end = moment.utc(req.payload.timeRange.max).valueOf(); - - const params = { - index: kbnIndexPattern, - size: config.get('monitoring.ui.max_bucket_size'), - ignoreUnavailable: true, - body: { - query: createQuery({ - type: 'kibana_stats', - start, - end, - clusterUuid, - metric: KibanaMetric.getMetricFields(), - }), - collapse: { - field: 'kibana_stats.kibana.uuid', - }, - sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], - _source: [ - 'timestamp', - 'kibana_stats.process.memory.resident_set_size_in_bytes', - 'kibana_stats.os.load.1m', - 'kibana_stats.response_times.average', - 'kibana_stats.response_times.max', - 'kibana_stats.requests.total', - 'kibana_stats.kibana.transport_address', - 'kibana_stats.kibana.name', - 'kibana_stats.kibana.host', - 'kibana_stats.kibana.uuid', - 'kibana_stats.kibana.status', - 'kibana_stats.concurrent_connections', - ], - }, - }; - - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - return callWithRequest(req, 'search', params).then((resp) => { - const instances = get(resp, 'hits.hits', []); - - return instances.map((hit) => { - return { - ...get(hit, '_source.kibana_stats'), - availability: calculateAvailability(get(hit, '_source.timestamp')), - }; - }); - }); -} diff --git a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.ts b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.ts new file mode 100644 index 0000000000000..4da4c40b25568 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas.ts @@ -0,0 +1,151 @@ +/* + * 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 moment from 'moment'; +// @ts-ignore +import { checkParam } from '../error_missing_required'; +// @ts-ignore +import { createQuery } from '../create_query'; +// @ts-ignore +import { calculateAvailability } from '../calculate_availability'; +// @ts-ignore +import { KibanaMetric } from '../metrics'; +import { LegacyRequest } from '../../types'; +import { ElasticsearchResponse } from '../../../common/types/es'; + +interface Kibana { + process?: { + memory?: { + resident_set_size_in_bytes?: number; + }; + }; + os?: { + load?: { + '1m'?: number; + }; + }; + response_times?: { + average?: number; + max?: number; + }; + requests?: { + total?: number; + }; + concurrent_connections?: number; + kibana?: { + transport_address?: string; + name?: string; + host?: string; + uuid?: string; + status?: string; + }; + availability: boolean; +} + +/* + * Get detailed info for Kibanas in the cluster + * for Kibana listing page + * For each instance: + * - name + * - status + * - memory + * - os load average + * - requests + * - response times + */ +export async function getKibanas( + req: LegacyRequest, + kbnIndexPattern: string, + { clusterUuid }: { clusterUuid: string } +) { + checkParam(kbnIndexPattern, 'kbnIndexPattern in getKibanas'); + + const config = req.server.config(); + const start = moment.utc(req.payload.timeRange.min).valueOf(); + const end = moment.utc(req.payload.timeRange.max).valueOf(); + + const params = { + index: kbnIndexPattern, + size: config.get('monitoring.ui.max_bucket_size'), + ignoreUnavailable: true, + body: { + query: createQuery({ + types: ['kibana_stats', 'stats'], + start, + end, + clusterUuid, + metric: KibanaMetric.getMetricFields(), + }), + collapse: { + field: 'kibana_stats.kibana.uuid', + }, + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], + _source: [ + 'timestamp', + '@timestamp', + 'kibana_stats.process.memory.resident_set_size_in_bytes', + 'kibana.stats.process.memory.resident_set_size.bytes', + 'kibana_stats.os.load.1m', + 'kibana.stats.os.load.1m', + 'kibana_stats.response_times.average', + 'kibana.stats.response_time.avg.ms', + 'kibana_stats.response_times.max', + 'kibana.stats.response_time.max.ms', + 'kibana_stats.requests.total', + 'kibana.stats.request.total', + 'kibana_stats.kibana.transport_address', + 'kibana.kibana.transport_address', + 'kibana_stats.kibana.name', + 'kibana.kibana.name', + 'kibana_stats.kibana.host', + 'kibana.kibana.host', + 'kibana_stats.kibana.uuid', + 'kibana.kibana.uuid', + 'kibana_stats.kibana.status', + 'kibana.kibana.status', + 'kibana_stats.concurrent_connections', + 'kibana.stats.concurrent_connections', + ], + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + const response: ElasticsearchResponse = await callWithRequest(req, 'search', params); + const instances = response.hits?.hits ?? []; + + return instances.map((hit) => { + const legacyStats = hit._source.kibana_stats; + const mbStats = hit._source.kibana?.stats; + + const kibana: Kibana = { + kibana: hit._source.kibana?.kibana ?? legacyStats?.kibana, + concurrent_connections: + mbStats?.concurrent_connections ?? legacyStats?.concurrent_connections, + process: { + memory: { + resident_set_size_in_bytes: + mbStats?.process?.memory?.resident_set_size?.bytes ?? + legacyStats?.process?.memory?.resident_set_size_in_bytes, + }, + }, + os: { + load: { + '1m': mbStats?.os?.load?.['1m'] ?? legacyStats?.os?.load?.['1m'], + }, + }, + response_times: { + average: mbStats?.response_time?.avg?.ms ?? legacyStats?.response_times?.average, + max: mbStats?.response_time?.max?.ms ?? legacyStats?.response_times?.max, + }, + requests: { + total: mbStats?.request?.total ?? legacyStats?.requests?.total, + }, + availability: calculateAvailability(hit._source['@timestamp'] ?? hit._source.timestamp), + }; + return kibana; + }); +} diff --git a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas_for_clusters.js b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas_for_clusters.js index 002ca93323a8a..7d64860122922 100644 --- a/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas_for_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/kibana/get_kibanas_for_clusters.js @@ -32,7 +32,7 @@ export function getKibanasForClusters(req, kbnIndexPattern, clusters) { const end = req.payload.timeRange.max; return Bluebird.map(clusters, (cluster) => { - const clusterUuid = cluster.cluster_uuid; + const clusterUuid = get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid); const metric = KibanaClusterMetric.getMetricFields(); const params = { index: kbnIndexPattern, @@ -40,7 +40,7 @@ export function getKibanasForClusters(req, kbnIndexPattern, clusters) { ignoreUnavailable: true, body: { query: createQuery({ - type: 'kibana_stats', + types: ['stats', 'kibana_stats'], start, end, clusterUuid, diff --git a/x-pack/plugins/monitoring/server/lib/logs/get_log_types.js b/x-pack/plugins/monitoring/server/lib/logs/get_log_types.ts similarity index 70% rename from x-pack/plugins/monitoring/server/lib/logs/get_log_types.js rename to x-pack/plugins/monitoring/server/lib/logs/get_log_types.ts index 726ee5a89f573..82b73c6b87f06 100644 --- a/x-pack/plugins/monitoring/server/lib/logs/get_log_types.js +++ b/x-pack/plugins/monitoring/server/lib/logs/get_log_types.ts @@ -5,25 +5,40 @@ * 2.0. */ -import { get } from 'lodash'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createTimeFilter } from '../create_query'; +// @ts-ignore import { detectReason } from './detect_reason'; +// @ts-ignore import { detectReasonFromException } from './detect_reason_from_exception'; +import { LegacyRequest } from '../../types'; +import { FilebeatResponse } from '../../../common/types/filebeat'; -async function handleResponse(response, req, filebeatIndexPattern, opts) { - const result = { +interface LogType { + level?: string; + count?: number; +} + +async function handleResponse( + response: FilebeatResponse, + req: LegacyRequest, + filebeatIndexPattern: string, + opts: { clusterUuid: string; nodeUuid: string; indexUuid: string; start: number; end: number } +) { + const result: { enabled: boolean; types: LogType[]; reason?: any } = { enabled: false, types: [], }; - const typeBuckets = get(response, 'aggregations.types.buckets', []); + const typeBuckets = response.aggregations?.types?.buckets ?? []; if (typeBuckets.length) { result.enabled = true; - result.types = typeBuckets.map((typeBucket) => { + result.types = typeBuckets.map((typeBucket: any) => { return { type: typeBucket.key.split('.')[1], - levels: typeBucket.levels.buckets.map((levelBucket) => { + levels: typeBucket.levels.buckets.map((levelBucket: any) => { return { level: levelBucket.key.toLowerCase(), count: levelBucket.doc_count, @@ -39,9 +54,15 @@ async function handleResponse(response, req, filebeatIndexPattern, opts) { } export async function getLogTypes( - req, - filebeatIndexPattern, - { clusterUuid, nodeUuid, indexUuid, start, end } + req: LegacyRequest, + filebeatIndexPattern: string, + { + clusterUuid, + nodeUuid, + indexUuid, + start, + end, + }: { clusterUuid: string; nodeUuid: string; indexUuid: string; start: number; end: number } ) { checkParam(filebeatIndexPattern, 'filebeatIndexPattern in logs/getLogTypes'); @@ -90,7 +111,10 @@ export async function getLogTypes( }; const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - let result = {}; + let result: { enabled: boolean; types: LogType[]; reason?: any } = { + enabled: false, + types: [], + }; try { const response = await callWithRequest(req, 'search', params); result = await handleResponse(response, req, filebeatIndexPattern, { diff --git a/x-pack/plugins/monitoring/server/lib/logs/get_logs.js b/x-pack/plugins/monitoring/server/lib/logs/get_logs.ts similarity index 64% rename from x-pack/plugins/monitoring/server/lib/logs/get_logs.js rename to x-pack/plugins/monitoring/server/lib/logs/get_logs.ts index 896c7b346634e..11e8f6550bcb7 100644 --- a/x-pack/plugins/monitoring/server/lib/logs/get_logs.js +++ b/x-pack/plugins/monitoring/server/lib/logs/get_logs.ts @@ -6,37 +6,59 @@ */ import moment from 'moment'; -import { get } from 'lodash'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createTimeFilter } from '../create_query'; +// @ts-ignore import { detectReason } from './detect_reason'; +// @ts-ignore import { formatUTCTimestampForTimezone } from '../format_timezone'; +// @ts-ignore import { getTimezone } from '../get_timezone'; +// @ts-ignore import { detectReasonFromException } from './detect_reason_from_exception'; +import { LegacyRequest } from '../../types'; +import { FilebeatResponse } from '../../../common/types/filebeat'; -async function handleResponse(response, req, filebeatIndexPattern, opts) { - const result = { +interface Log { + timestamp?: string; + component?: string; + node?: string; + index?: string; + level?: string; + type?: string; + message?: string; +} + +async function handleResponse( + response: FilebeatResponse, + req: LegacyRequest, + filebeatIndexPattern: string, + opts: { clusterUuid: string; nodeUuid: string; indexUuid: string; start: number; end: number } +) { + const result: { enabled: boolean; logs: Log[]; reason?: any } = { enabled: false, logs: [], }; const timezone = await getTimezone(req); - const hits = get(response, 'hits.hits', []); + const hits = response.hits?.hits ?? []; if (hits.length) { result.enabled = true; result.logs = hits.map((hit) => { const source = hit._source; - const type = get(source, 'event.dataset').split('.')[1]; - const utcTimestamp = moment(get(source, '@timestamp')).valueOf(); + const type = (source.event?.dataset ?? '').split('.')[1]; + const utcTimestamp = moment(source['@timestamp']).valueOf(); return { timestamp: formatUTCTimestampForTimezone(utcTimestamp, timezone), - component: get(source, 'elasticsearch.component'), - node: get(source, 'elasticsearch.node.name'), - index: get(source, 'elasticsearch.index.name'), - level: get(source, 'log.level'), + component: source.elasticsearch?.component, + node: source.elasticsearch?.node?.name, + index: source.elasticsearch?.index?.name, + level: source.log?.level, type, - message: get(source, 'message'), + message: source.message, }; }); } else { @@ -47,10 +69,16 @@ async function handleResponse(response, req, filebeatIndexPattern, opts) { } export async function getLogs( - config, - req, - filebeatIndexPattern, - { clusterUuid, nodeUuid, indexUuid, start, end } + config: { get: (key: string) => any }, + req: LegacyRequest, + filebeatIndexPattern: string, + { + clusterUuid, + nodeUuid, + indexUuid, + start, + end, + }: { clusterUuid: string; nodeUuid: string; indexUuid: string; start: number; end: number } ) { checkParam(filebeatIndexPattern, 'filebeatIndexPattern in logs/getLogs'); @@ -94,9 +122,12 @@ export async function getLogs( const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - let result = {}; + let result: { enabled: boolean; logs: Log[]; reason?: any } = { + enabled: false, + logs: [], + }; try { - const response = await callWithRequest(req, 'search', params); + const response: FilebeatResponse = await callWithRequest(req, 'search', params); result = await handleResponse(response, req, filebeatIndexPattern, { clusterUuid, nodeUuid, diff --git a/x-pack/plugins/monitoring/server/lib/logs/init_infra_source.ts b/x-pack/plugins/monitoring/server/lib/logs/init_infra_source.ts index d1b0417e2851a..c0fa931676870 100644 --- a/x-pack/plugins/monitoring/server/lib/logs/init_infra_source.ts +++ b/x-pack/plugins/monitoring/server/lib/logs/init_infra_source.ts @@ -13,7 +13,7 @@ import { InfraPluginSetup } from '../../../../infra/server'; export const initInfraSource = (config: MonitoringConfig, infraPlugin: InfraPluginSetup) => { if (infraPlugin) { - const filebeatIndexPattern = prefixIndexPattern(config, config.ui.logs.index, '*'); + const filebeatIndexPattern = prefixIndexPattern(config, config.ui.logs.index, '*', true); infraPlugin.defineInternalSourceConfiguration(INFRA_SOURCE_ID, { name: 'Elastic Stack Logs', logIndices: { diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_logstash_for_clusters.js b/x-pack/plugins/monitoring/server/lib/logstash/get_logstash_for_clusters.js index 18f4cb07ba021..58155e35ad52f 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_logstash_for_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_logstash_for_clusters.js @@ -44,14 +44,14 @@ export function getLogstashForClusters(req, lsIndexPattern, clusters) { const config = req.server.config(); return Bluebird.map(clusters, (cluster) => { - const clusterUuid = cluster.cluster_uuid; + const clusterUuid = get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid); const params = { index: lsIndexPattern, size: 0, ignoreUnavailable: true, body: { query: createQuery({ - type: 'logstash_stats', + types: ['stats', 'logstash_stats'], start, end, clusterUuid, @@ -148,6 +148,31 @@ export function getLogstashForClusters(req, lsIndexPattern, clusters) { }, }, }, + pipelines_nested_mb: { + nested: { + path: 'logstash.node.stats.pipelines', + }, + aggs: { + pipelines: { + sum_bucket: { + buckets_path: 'queue_types>num_pipelines', + }, + }, + queue_types: { + terms: { + field: 'logstash.node.stats.pipelines.queue.type', + size: config.get('monitoring.ui.max_bucket_size'), + }, + aggs: { + num_pipelines: { + cardinality: { + field: 'logstash.node.stats.pipelines.id', + }, + }, + }, + }, + }, + }, events_in_total: { sum_bucket: { buckets_path: 'logstash_uuids>events_in_total_per_node', @@ -199,6 +224,11 @@ export function getLogstashForClusters(req, lsIndexPattern, clusters) { maxUptime = get(aggregations, 'max_uptime.value'); } + let types = get(aggregations, 'pipelines_nested_mb.queue_types.buckets', []); + if (!types || types.length === 0) { + types = get(aggregations, 'pipelines_nested.queue_types.buckets', []); + } + return { clusterUuid, stats: { @@ -208,8 +238,10 @@ export function getLogstashForClusters(req, lsIndexPattern, clusters) { avg_memory: memory, avg_memory_used: memoryUsed, max_uptime: maxUptime, - pipeline_count: get(aggregations, 'pipelines_nested.pipelines.value', 0), - queue_types: getQueueTypes(get(aggregations, 'pipelines_nested.queue_types.buckets', [])), + pipeline_count: + get(aggregations, 'pipelines_nested_mb.pipelines.value') || + get(aggregations, 'pipelines_nested.pipelines.value', 0), + queue_types: getQueueTypes(types), versions: logstashVersions.map((versionBucket) => versionBucket.key), }, }; diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.ts index 60d4da6c8335e..954b78c432374 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_node_info.ts @@ -17,14 +17,15 @@ import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../common/constants'; import { standaloneClusterFilter } from '../standalone_clusters/standalone_cluster_query_filter'; export function handleResponse(resp: ElasticsearchResponse) { - const source = resp.hits?.hits[0]?._source?.logstash_stats; - const logstash = source?.logstash; + const legacyStats = resp.hits?.hits[0]?._source?.logstash_stats; + const mbStats = resp.hits?.hits[0]?._source?.logstash?.node?.stats; + const logstash = mbStats?.logstash ?? legacyStats?.logstash; const info = merge(logstash, { - availability: calculateAvailability(source?.timestamp), - events: source?.events, - reloads: source?.reloads, - queue_type: source?.queue?.type, - uptime: source?.jvm?.uptime_in_millis, + availability: calculateAvailability(mbStats?.timestamp ?? legacyStats?.timestamp), + events: mbStats?.events ?? legacyStats?.events, + reloads: mbStats?.reloads ?? legacyStats?.reloads, + queue_type: mbStats?.queue?.type ?? legacyStats?.queue?.type, + uptime: mbStats?.jvm?.uptime_in_millis ?? legacyStats?.jvm?.uptime_in_millis, }); return info; } @@ -47,11 +48,17 @@ export function getNodeInfo( ignoreUnavailable: true, filterPath: [ 'hits.hits._source.logstash_stats.events', + 'hits.hits._source.logstash.node.stats.events', 'hits.hits._source.logstash_stats.jvm.uptime_in_millis', + 'hits.hits._source.logstash.node.stats.jvm.uptime_in_millis', 'hits.hits._source.logstash_stats.logstash', + 'hits.hits._source.logstash.node.stats.logstash', 'hits.hits._source.logstash_stats.queue.type', + 'hits.hits._source.logstash.node.stats.queue.type', 'hits.hits._source.logstash_stats.reloads', + 'hits.hits._source.logstash.node.stats.reloads', 'hits.hits._source.logstash_stats.timestamp', + 'hits.hits._source.logstash.node.stats.timestamp', ], body: { query: { diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js b/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js deleted file mode 100644 index 426ffc8bc5a65..0000000000000 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import moment from 'moment'; -import { checkParam } from '../error_missing_required'; -import { createQuery } from '../create_query'; -import { calculateAvailability } from '../calculate_availability'; -import { LogstashMetric } from '../metrics'; - -/* - * Get detailed info for Logstash's in the cluster - * for Logstash nodes listing page - * For each instance: - * - name - * - status - * - JVM memory - * - os load average - * - events - * - config reloads - */ -export function getNodes(req, lsIndexPattern, { clusterUuid }) { - checkParam(lsIndexPattern, 'lsIndexPattern in getNodes'); - - const config = req.server.config(); - const start = moment.utc(req.payload.timeRange.min).valueOf(); - const end = moment.utc(req.payload.timeRange.max).valueOf(); - - const params = { - index: lsIndexPattern, - size: config.get('monitoring.ui.max_bucket_size'), // FIXME - ignoreUnavailable: true, - body: { - query: createQuery({ - start, - end, - clusterUuid, - metric: LogstashMetric.getMetricFields(), - type: 'logstash_stats', - }), - collapse: { - field: 'logstash_stats.logstash.uuid', - }, - sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], - _source: [ - 'timestamp', - 'logstash_stats.process.cpu.percent', - 'logstash_stats.jvm.mem.heap_used_percent', - 'logstash_stats.os.cpu.load_average.1m', - 'logstash_stats.events.out', - 'logstash_stats.logstash.http_address', - 'logstash_stats.logstash.name', - 'logstash_stats.logstash.host', - 'logstash_stats.logstash.uuid', - 'logstash_stats.logstash.status', - 'logstash_stats.logstash.pipeline', - 'logstash_stats.reloads', - 'logstash_stats.logstash.version', - ], - }, - }; - - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - return callWithRequest(req, 'search', params).then((resp) => { - const instances = get(resp, 'hits.hits', []); - - return instances.map((hit) => { - return { - ...get(hit, '_source.logstash_stats'), - availability: calculateAvailability(get(hit, '_source.timestamp')), - }; - }); - }); -} diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.ts new file mode 100644 index 0000000000000..9db59fec25530 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_nodes.ts @@ -0,0 +1,168 @@ +/* + * 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 moment from 'moment'; +// @ts-ignore +import { checkParam } from '../error_missing_required'; +// @ts-ignore +import { createQuery } from '../create_query'; +// @ts-ignore +import { calculateAvailability } from '../calculate_availability'; +// @ts-ignore +import { LogstashMetric } from '../metrics'; +import { LegacyRequest } from '../../types'; +import { ElasticsearchResponse } from '../../../common/types/es'; + +interface Logstash { + jvm?: { + mem?: { + heap_used_percent?: number; + }; + }; + logstash?: { + pipeline?: { + batch_size?: number; + workers?: number; + }; + http_address?: string; + name?: string; + host?: string; + uuid?: string; + version?: string; + status?: string; + }; + process?: { + cpu?: { + percent?: number; + }; + }; + os?: { + cpu?: { + load_average?: { + '1m'?: number; + }; + }; + }; + events?: { + out?: number; + }; + reloads?: { + failures?: number; + successes?: number; + }; + availability?: boolean; +} + +/* + * Get detailed info for Logstash's in the cluster + * for Logstash nodes listing page + * For each instance: + * - name + * - status + * - JVM memory + * - os load average + * - events + * - config reloads + */ +export async function getNodes( + req: LegacyRequest, + lsIndexPattern: string, + { clusterUuid }: { clusterUuid: string } +) { + checkParam(lsIndexPattern, 'lsIndexPattern in getNodes'); + + const config = req.server.config(); + const start = moment.utc(req.payload.timeRange.min).valueOf(); + const end = moment.utc(req.payload.timeRange.max).valueOf(); + + const params = { + index: lsIndexPattern, + size: config.get('monitoring.ui.max_bucket_size'), // FIXME + ignoreUnavailable: true, + body: { + query: createQuery({ + start, + end, + clusterUuid, + metric: LogstashMetric.getMetricFields(), + types: ['stats', 'logstash_stats'], + }), + collapse: { + field: 'logstash_stats.logstash.uuid', + }, + sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], + _source: [ + 'timestamp', + '@timestamp', + 'logstash_stats.process.cpu.percent', + 'logstash.node.stats.process.cpu.percent', + 'logstash_stats.jvm.mem.heap_used_percent', + 'logstash.node.stats.jvm.mem.heap_used_percent', + 'logstash_stats.os.cpu.load_average.1m', + 'logstash.node.stats.os.cpu.load_average.1m', + 'logstash_stats.events.out', + 'logstash.node.stats.events.out', + 'logstash_stats.logstash.http_address', + 'logstash.node.stats.logstash.http_address', + 'logstash_stats.logstash.name', + 'logstash.node.stats.logstash.name', + 'logstash_stats.logstash.host', + 'logstash.node.stats.logstash.host', + 'logstash_stats.logstash.uuid', + 'logstash.node.stats.logstash.uuid', + 'logstash_stats.logstash.status', + 'logstash.node.stats.logstash.status', + 'logstash_stats.logstash.pipeline', + 'logstash.node.stats.logstash.pipeline', + 'logstash_stats.reloads', + 'logstash.node.stats.reloads', + 'logstash_stats.logstash.version', + 'logstash.node.stats.logstash.version', + ], + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + const response: ElasticsearchResponse = await callWithRequest(req, 'search', params); + return response.hits?.hits.map((hit) => { + const legacyStats = hit._source.logstash_stats; + const mbStats = hit._source.logstash?.node?.stats; + + const logstash: Logstash = { + logstash: mbStats?.logstash ?? legacyStats?.logstash, + jvm: { + mem: { + heap_used_percent: + mbStats?.jvm?.mem?.heap_used_percent ?? legacyStats?.jvm?.mem?.heap_used_percent, + }, + }, + process: { + cpu: { + percent: mbStats?.process?.cpu?.percent ?? legacyStats?.process?.cpu?.percent, + }, + }, + os: { + cpu: { + load_average: { + '1m': + mbStats?.os?.cpu?.load_average?.['1m'] ?? legacyStats?.os?.cpu?.load_average?.['1m'], + }, + }, + }, + events: { + out: mbStats?.events?.out ?? legacyStats?.events?.out, + }, + reloads: { + failures: mbStats?.reloads?.failures ?? legacyStats?.reloads?.failures, + successes: mbStats?.reloads?.successes ?? legacyStats?.reloads?.successes, + }, + availability: calculateAvailability(hit._source['@timestamp'] ?? hit._source.timestamp), + }; + + return logstash; + }); +} diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js b/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js index c459c11d17c42..32662ae0efa34 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js @@ -103,7 +103,16 @@ async function getPaginatedThroughputData(pipelines, req, lsIndexPattern, throug req, lsIndexPattern, [throughputMetric], - [], + [ + { + bool: { + should: [ + { term: { type: 'logstash_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, + ], { pipeline, }, @@ -133,7 +142,13 @@ async function getPaginatedNodesData(pipelines, req, lsIndexPattern, nodesCountM req, lsIndexPattern, [nodesCountMetric], - [], + [ + { + bool: { + should: [{ term: { type: 'logstash_stats' } }, { term: { 'metricset.name': 'stats' } }], + }, + }, + ], { pageOfPipelines: pipelines }, 2 ); @@ -170,9 +185,24 @@ async function getThroughputPipelines(req, lsIndexPattern, pipelines, throughput const metricsResponse = await Promise.all( pipelines.map((pipeline) => { return new Promise(async (resolve) => { - const data = await getMetrics(req, lsIndexPattern, [throughputMetric], [], { - pipeline, - }); + const data = await getMetrics( + req, + lsIndexPattern, + [throughputMetric], + [ + { + bool: { + should: [ + { term: { type: 'logstash_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, + ], + { + pipeline, + } + ); resolve(reduceData(pipeline, data)); }); @@ -183,9 +213,21 @@ async function getThroughputPipelines(req, lsIndexPattern, pipelines, throughput } async function getNodePipelines(req, lsIndexPattern, pipelines, nodesCountMetric) { - const metricData = await getMetrics(req, lsIndexPattern, [nodesCountMetric], [], { - pageOfPipelines: pipelines, - }); + const metricData = await getMetrics( + req, + lsIndexPattern, + [nodesCountMetric], + [ + { + bool: { + should: [{ term: { type: 'logstash_stats' } }, { term: { 'metricset.name': 'stats' } }], + }, + }, + ], + { + pageOfPipelines: pipelines, + } + ); const metricObject = metricData[nodesCountMetric][0]; const pipelinesData = pipelines.map(({ id }) => { diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js index a5d117878103e..1521c5d3773d0 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js @@ -28,7 +28,7 @@ export async function getLogstashPipelineIds( index: logstashIndexPattern, size: 0, ignoreUnavailable: true, - filterPath: ['aggregations.nest.id.buckets'], + filterPath: ['aggregations.nest.id.buckets', 'aggregations.nest_mb.id.buckets'], body: { query: createQuery({ start, @@ -64,14 +64,50 @@ export async function getLogstashPipelineIds( }, }, }, + nest_mb: { + nested: { + path: 'logstash.node.stats.pipelines', + }, + aggs: { + id: { + terms: { + field: 'logstash.node.stats.pipelines.id', + size, + }, + aggs: { + unnest_mb: { + reverse_nested: {}, + aggs: { + nodes: { + terms: { + field: 'logstash.node.stats.logstash.uuid', + size, + }, + }, + }, + }, + }, + }, + }, + }, }, }, }; const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); const response = await callWithRequest(req, 'search', params); - return get(response, 'aggregations.nest.id.buckets', []).map((bucket) => ({ - id: bucket.key, - nodeIds: get(bucket, 'unnest.nodes.buckets', []).map((item) => item.key), - })); + let buckets = get(response, 'aggregations.nest_mb.id.buckets', []); + if (!buckets || buckets.length === 0) { + buckets = get(response, 'aggregations.nest.id.buckets', []); + } + return buckets.map((bucket) => { + let nodeBuckets = get(bucket, 'unnest_mb.nodes.buckets', []); + if (!nodeBuckets || nodeBuckets.length === 0) { + nodeBuckets = get(bucket, 'unnest.nodes.buckets', []); + } + return { + id: bucket.key, + nodeIds: nodeBuckets.map((item) => item.key), + }; + }); } diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_stats_aggregation.js b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_stats_aggregation.js index e17061a4608df..2e35a4639fa5a 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_stats_aggregation.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_stats_aggregation.js @@ -157,7 +157,7 @@ export function getPipelineStatsAggregation( end = version.lastSeen; const query = createQuery({ - type: 'logstash_stats', + types: ['stats', 'logstash_stats'], start, end, metric: LogstashMetric.getMetricFields(), diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js index f4577aa408048..d1121c78407ff 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_versions.js @@ -28,7 +28,7 @@ function fetchPipelineVersions(...args) { }, ]; const query = createQuery({ - type: 'logstash_stats', + types: ['stats', 'logstash_stats'], metric: LogstashMetric.getMetricFields(), clusterUuid, filters, diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex_stats_aggregation.js b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex_stats_aggregation.js index 56b0842be77ea..81d1f2bf57217 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex_stats_aggregation.js +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex_stats_aggregation.js @@ -200,7 +200,7 @@ export function getPipelineVertexStatsAggregation( end = version.lastSeen; const query = createQuery({ - type: 'logstash_stats', + types: ['stats', 'logstash_stats'], start, end, metric: LogstashMetric.getMetricFields(), diff --git a/x-pack/plugins/monitoring/server/lib/metrics/__snapshots__/metrics.test.js.snap b/x-pack/plugins/monitoring/server/lib/metrics/__snapshots__/metrics.test.js.snap index f2ee1b2f6c2ab..674e826b579e5 100644 --- a/x-pack/plugins/monitoring/server/lib/metrics/__snapshots__/metrics.test.js.snap +++ b/x-pack/plugins/monitoring/server/lib/metrics/__snapshots__/metrics.test.js.snap @@ -3511,6 +3511,7 @@ Object { "format": "0,0.[00]", "getDateHistogramSubAggs": [Function], "label": "Pipeline Throughput", + "mbField": "logstash.node.stats.pipelines.events.out", "timestampField": "logstash_stats.timestamp", "units": "e/s", "uuidField": "logstash_stats.logstash.uuid", diff --git a/x-pack/plugins/monitoring/server/lib/metrics/logstash/classes.js b/x-pack/plugins/monitoring/server/lib/metrics/logstash/classes.js index 8d6bc5cb64c08..234a1ac89f66d 100644 --- a/x-pack/plugins/monitoring/server/lib/metrics/logstash/classes.js +++ b/x-pack/plugins/monitoring/server/lib/metrics/logstash/classes.js @@ -263,11 +263,23 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { unit: NORMALIZED_DERIVATIVE_UNIT, }, }, + metric_mb_deriv: { + derivative: { + buckets_path: 'sum_mb', + gap_policy: 'skip', + unit: NORMALIZED_DERIVATIVE_UNIT, + }, + }, sum: { sum_bucket: { buckets_path: 'by_node_id>nest>pipeline>events_stats', }, }, + sum_mb: { + sum_bucket: { + buckets_path: 'by_node_id>nest_mb>pipeline>events_stats', + }, + }, by_node_id: { terms: { field: 'logstash_stats.logstash.uuid', @@ -296,6 +308,27 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { }, }, }, + nest_mb: { + nested: { + path: 'logstash.node.stats.pipelines', + }, + aggs: { + pipeline: { + filter: { + term: { + 'logstash.node.stats.pipelines.id': pipeline.id, + }, + }, + aggs: { + events_stats: { + max: { + field: this.mbField, + }, + }, + }, + }, + }, + }, }, }, }; @@ -342,12 +375,42 @@ export class LogstashPipelineNodeCountMetric extends LogstashMetric { }, }, }, + pipelines_mb_nested: { + nested: { + path: 'logstash.node.stats.pipelines', + }, + aggs: { + by_pipeline_id: { + terms: { + field: 'logstash.node.stats.pipelines.id', + size: 1000, + ...termAggExtras, + }, + aggs: { + to_root: { + reverse_nested: {}, + aggs: { + node_count: { + cardinality: { + field: this.field, + }, + }, + }, + }, + }, + }, + }, + }, }; }; this.calculation = (bucket) => { const pipelineNodesCounts = {}; - const pipelineBuckets = _.get(bucket, 'pipelines_nested.by_pipeline_id.buckets', []); + const legacyPipelineBuckets = _.get(bucket, 'pipelines_nested.by_pipeline_id.buckets', []); + const mbPiplineBuckets = _.get(bucket, 'pipelines_mb_nested.by_pipeline_id.buckets', []); + const pipelineBuckets = legacyPipelineBuckets.length + ? legacyPipelineBuckets + : mbPiplineBuckets; pipelineBuckets.forEach((pipelineBucket) => { pipelineNodesCounts[pipelineBucket.key] = _.get(pipelineBucket, 'to_root.node_count.value'); }); diff --git a/x-pack/plugins/monitoring/server/lib/metrics/logstash/metrics.js b/x-pack/plugins/monitoring/server/lib/metrics/logstash/metrics.js index bd344e551ce63..cd518804eeb67 100644 --- a/x-pack/plugins/monitoring/server/lib/metrics/logstash/metrics.js +++ b/x-pack/plugins/monitoring/server/lib/metrics/logstash/metrics.js @@ -430,6 +430,7 @@ export const metrics = { units: 'B', }), logstash_cluster_pipeline_throughput: new LogstashPipelineThroughputMetric({ + mbField: 'logstash.node.stats.pipelines.events.out', field: 'logstash_stats.pipelines.events.out', label: pipelineThroughputLabel, description: pipelineThroughputDescription, diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js index 000c26bff48ac..36a48002005b3 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js @@ -38,18 +38,20 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod filters.push({ term: { cluster_uuid: clusterUuid } }); } - const nodesClause = []; + const nodesClause = {}; if (nodeUuid) { - nodesClause.push({ - bool: { - should: [ - { term: { 'node_stats.node_id': nodeUuid } }, - { term: { 'kibana_stats.kibana.uuid': nodeUuid } }, - { term: { 'beats_stats.beat.uuid': nodeUuid } }, - { term: { 'logstash_stats.logstash.uuid': nodeUuid } }, - ], + nodesClause.must = [ + { + bool: { + should: [ + { term: { 'node_stats.node_id': nodeUuid } }, + { term: { 'kibana_stats.kibana.uuid': nodeUuid } }, + { term: { 'beats_stats.beat.uuid': nodeUuid } }, + { term: { 'logstash_stats.logstash.uuid': nodeUuid } }, + ], + }, }, - }); + ]; } const params = { @@ -61,7 +63,7 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod query: { bool: { filter: filters, - must: nodesClause, + ...nodesClause, }, }, aggs: { @@ -77,9 +79,21 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod size, }, aggs: { - by_timestamp: { - max: { - field: 'timestamp', + single_type: { + filter: { + bool: { + should: [ + { term: { type: 'node_stats' } }, + { term: { 'metricset.name': 'node_stats' } }, + ], + }, + }, + aggs: { + by_timestamp: { + max: { + field: 'timestamp', + }, + }, }, }, }, @@ -90,9 +104,21 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod size, }, aggs: { - by_timestamp: { - max: { - field: 'timestamp', + single_type: { + filter: { + bool: { + should: [ + { term: { type: 'kibana_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, + aggs: { + by_timestamp: { + max: { + field: 'timestamp', + }, + }, }, }, }, @@ -103,21 +129,33 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod size, }, aggs: { - by_timestamp: { - max: { - field: 'timestamp', + single_type: { + filter: { + bool: { + should: [ + { term: { type: 'beats_stats' } }, + { term: { 'metricset.name': 'beats_stats' } }, + ], + }, }, - }, - beat_type: { - terms: { - field: 'beats_stats.beat.type', - size, - }, - }, - cluster_uuid: { - terms: { - field: 'cluster_uuid', - size, + aggs: { + by_timestamp: { + max: { + field: 'timestamp', + }, + }, + beat_type: { + terms: { + field: 'beats_stats.beat.type', + size, + }, + }, + cluster_uuid: { + terms: { + field: 'cluster_uuid', + size, + }, + }, }, }, }, @@ -128,15 +166,27 @@ const getRecentMonitoringDocuments = async (req, indexPatterns, clusterUuid, nod size, }, aggs: { - by_timestamp: { - max: { - field: 'timestamp', + single_type: { + filter: { + bool: { + should: [ + { term: { type: 'logstash_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, }, - }, - cluster_uuid: { - terms: { - field: 'cluster_uuid', - size, + aggs: { + by_timestamp: { + max: { + field: 'timestamp', + }, + }, + cluster_uuid: { + terms: { + field: 'cluster_uuid', + size, + }, + }, }, }, }, @@ -224,8 +274,18 @@ function getUuidBucketName(productName) { } } +function matchesMetricbeatIndex(metricbeatIndex, index) { + if (index.includes(metricbeatIndex)) { + return true; + } + if (metricbeatIndex.includes('*')) { + return new RegExp(metricbeatIndex).test(index); + } + return false; +} + function isBeatFromAPM(bucket) { - const beatType = get(bucket, 'beat_type'); + const beatType = get(bucket, 'single_type.beat_type'); if (!beatType) { return false; } @@ -364,6 +424,7 @@ export const getCollectionStatus = async ( ) => { const config = req.server.config(); const kibanaUuid = config.get('server.uuid'); + const metricbeatIndex = config.get('monitoring.ui.metricbeat.index'); const size = config.get('monitoring.ui.max_bucket_size'); const hasPermissions = await hasNecessaryPermissions(req); @@ -399,8 +460,18 @@ export const getCollectionStatus = async ( const status = PRODUCTS.reduce((products, product) => { const token = product.token || product.name; - const indexBuckets = indicesBuckets.filter((bucket) => bucket.key.includes(token)); const uuidBucketName = getUuidBucketName(product.name); + const indexBuckets = indicesBuckets.filter((bucket) => { + if (bucket.key.includes(token)) { + return true; + } + if (matchesMetricbeatIndex(metricbeatIndex, bucket.key)) { + if (get(bucket, `${uuidBucketName}.buckets`, []).length) { + return true; + } + } + return false; + }); const productStatus = { totalUniqueInstanceCount: 0, @@ -422,7 +493,9 @@ export const getCollectionStatus = async ( // If there is a single bucket, then they are fully migrated or fully on the internal collector else if (indexBuckets.length === 1) { const singleIndexBucket = indexBuckets[0]; - const isFullyMigrated = singleIndexBucket.key.includes(METRICBEAT_INDEX_NAME_UNIQUE_TOKEN); + const isFullyMigrated = + singleIndexBucket.key.includes(METRICBEAT_INDEX_NAME_UNIQUE_TOKEN) || + matchesMetricbeatIndex(metricbeatIndex, singleIndexBucket.key); const map = isFullyMigrated ? fullyMigratedUuidsMap : internalCollectorsUuidsMap; const uuidBuckets = get(singleIndexBucket, `${uuidBucketName}.buckets`, []); @@ -430,17 +503,18 @@ export const getCollectionStatus = async ( if (shouldSkipBucket(product, bucket)) { continue; } - const { key, by_timestamp: byTimestamp } = bucket; + const { key, single_type: singleType } = bucket; if (!map[key]) { + const { by_timestamp: byTimestamp } = singleType; map[key] = { lastTimestamp: get(byTimestamp, 'value') }; if (product.name === KIBANA_SYSTEM_ID && key === kibanaUuid) { map[key].isPrimary = true; } if (product.name === BEATS_SYSTEM_ID) { - map[key].beatType = get(bucket.beat_type, 'buckets[0].key'); + map[key].beatType = get(bucket.single_type, 'beat_type.buckets[0].key'); } - if (bucket.cluster_uuid) { - map[key].clusterUuid = get(bucket.cluster_uuid, 'buckets[0].key', '') || null; + if (singleType.cluster_uuid) { + map[key].clusterUuid = get(singleType.cluster_uuid, 'buckets[0].key', '') || null; } } } @@ -502,7 +576,8 @@ export const getCollectionStatus = async ( for (const indexBucket of indexBuckets) { const isFullyMigrated = considerAllInstancesMigrated || - indexBucket.key.includes(METRICBEAT_INDEX_NAME_UNIQUE_TOKEN); + indexBucket.key.includes(METRICBEAT_INDEX_NAME_UNIQUE_TOKEN) || + matchesMetricbeatIndex(metricbeatIndex, indexBucket.key); const map = isFullyMigrated ? fullyMigratedUuidsMap : internalCollectorsUuidsMap; const otherMap = !isFullyMigrated ? fullyMigratedUuidsMap : internalCollectorsUuidsMap; @@ -512,7 +587,8 @@ export const getCollectionStatus = async ( continue; } - const { key, by_timestamp: byTimestamp } = bucket; + const { key, single_type: singleType } = bucket; + const { by_timestamp: byTimestamp } = singleType; if (!map[key]) { if (otherMap[key]) { partiallyMigratedUuidsMap[key] = otherMap[key] || {}; @@ -523,10 +599,10 @@ export const getCollectionStatus = async ( map[key].isPrimary = true; } if (product.name === BEATS_SYSTEM_ID) { - map[key].beatType = get(bucket.beat_type, 'buckets[0].key'); + map[key].beatType = get(singleType.beat_type, 'buckets[0].key'); } - if (bucket.cluster_uuid) { - map[key].clusterUuid = get(bucket.cluster_uuid, 'buckets[0].key', '') || null; + if (singleType.cluster_uuid) { + map[key].clusterUuid = get(singleType.cluster_uuid, 'buckets[0].key', '') || null; } } } diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js index 437e07512d46b..1029f00455c69 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js @@ -34,6 +34,9 @@ const mockReq = ( if (prop === 'server.uuid') { return 'kibana-1234'; } + if (prop === 'monitoring.ui.metricbeat.index') { + return 'metricbeat-*'; + } }), }; }, @@ -104,24 +107,27 @@ describe('getCollectionStatus', () => { buckets: [ { key: '.monitoring-es-7-2019', - es_uuids: { buckets: [{ key: 'es_1' }] }, + es_uuids: { buckets: [{ key: 'es_1', single_type: {} }] }, }, { key: '.monitoring-kibana-7-2019', - kibana_uuids: { buckets: [{ key: 'kibana_1' }] }, + kibana_uuids: { buckets: [{ key: 'kibana_1', single_type: {} }] }, }, { key: '.monitoring-beats-7-2019', beats_uuids: { buckets: [ - { key: 'apm_1', beat_type: { buckets: [{ key: 'apm-server' }] } }, - { key: 'beats_1' }, + { + key: 'apm_1', + single_type: { beat_type: { buckets: [{ key: 'apm-server' }] } }, + }, + { key: 'beats_1', single_type: {} }, ], }, }, { key: '.monitoring-logstash-7-2019', - logstash_uuids: { buckets: [{ key: 'logstash_1' }] }, + logstash_uuids: { buckets: [{ key: 'logstash_1', single_type: {} }] }, }, ], }, @@ -158,19 +164,19 @@ describe('getCollectionStatus', () => { buckets: [ { key: '.monitoring-es-7-mb-2019', - es_uuids: { buckets: [{ key: 'es_1' }] }, + es_uuids: { buckets: [{ key: 'es_1', single_type: {} }] }, }, { key: '.monitoring-kibana-7-mb-2019', - kibana_uuids: { buckets: [{ key: 'kibana_1' }] }, + kibana_uuids: { buckets: [{ key: 'kibana_1', single_type: {} }] }, }, { key: '.monitoring-beats-7-2019', - beats_uuids: { buckets: [{ key: 'beats_1' }] }, + beats_uuids: { buckets: [{ key: 'beats_1', single_type: {} }] }, }, { key: '.monitoring-logstash-7-2019', - logstash_uuids: { buckets: [{ key: 'logstash_1' }] }, + logstash_uuids: { buckets: [{ key: 'logstash_1', single_type: {} }] }, }, ], }, @@ -203,23 +209,30 @@ describe('getCollectionStatus', () => { buckets: [ { key: '.monitoring-es-7-mb-2019', - es_uuids: { buckets: [{ key: 'es_1' }] }, + es_uuids: { buckets: [{ key: 'es_1', single_type: {} }] }, }, { key: '.monitoring-kibana-7-mb-2019', - kibana_uuids: { buckets: [{ key: 'kibana_1' }, { key: 'kibana_2' }] }, + kibana_uuids: { + buckets: [ + { key: 'kibana_1', single_type: {} }, + { key: 'kibana_2', single_type: {} }, + ], + }, }, { key: '.monitoring-kibana-7-2019', - kibana_uuids: { buckets: [{ key: 'kibana_1', by_timestamp: { value: 12 } }] }, + kibana_uuids: { + buckets: [{ key: 'kibana_1', single_type: { by_timestamp: { value: 12 } } }], + }, }, { key: '.monitoring-beats-7-2019', - beats_uuids: { buckets: [{ key: 'beats_1' }] }, + beats_uuids: { buckets: [{ key: 'beats_1', single_type: {} }] }, }, { key: '.monitoring-logstash-7-2019', - logstash_uuids: { buckets: [{ key: 'logstash_1' }] }, + logstash_uuids: { buckets: [{ key: 'logstash_1', single_type: {} }] }, }, ], }, diff --git a/x-pack/plugins/monitoring/server/lib/standalone_clusters/get_standalone_cluster_definition.js b/x-pack/plugins/monitoring/server/lib/standalone_clusters/get_standalone_cluster_definition.js index 79a32f7690e14..ccbc6ccaa78aa 100644 --- a/x-pack/plugins/monitoring/server/lib/standalone_clusters/get_standalone_cluster_definition.js +++ b/x-pack/plugins/monitoring/server/lib/standalone_clusters/get_standalone_cluster_definition.js @@ -18,5 +18,15 @@ export const getStandaloneClusterDefinition = () => { count: {}, }, }, + elasticsearch: { + cluster: { + stats: { + nodes: { + jvm: {}, + count: {}, + }, + }, + }, + }, }; }; diff --git a/x-pack/plugins/monitoring/server/lib/standalone_clusters/has_standalone_clusters.js b/x-pack/plugins/monitoring/server/lib/standalone_clusters/has_standalone_clusters.js index 5201be68d9894..938610e51693e 100644 --- a/x-pack/plugins/monitoring/server/lib/standalone_clusters/has_standalone_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/standalone_clusters/has_standalone_clusters.js @@ -15,7 +15,25 @@ export async function hasStandaloneClusters(req, indexPatterns) { return list; }, []); - const filters = [standaloneClusterFilter]; + const filters = [ + standaloneClusterFilter, + { + bool: { + should: [ + { + terms: { + type: ['logstash_stats', 'logstash_state', 'beats_stats', 'beats_state'], + }, + }, + { + terms: { + 'metricset.name': ['logstash_stats', 'logstash_state', 'beats_stats', 'beats_state'], + }, + }, + ], + }, + }, + ]; // Not every page will contain a time range so check for that if (req.payload.timeRange) { const start = req.payload.timeRange.min; diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 6b47b47a21394..56c654963d340 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -333,6 +333,7 @@ export class MonitoringPlugin } }, server: { + log: this.log, route: () => {}, config: legacyConfigWrapper, newPlatform: { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.ts index 0a4bf98aa8b7a..4d36e36d7cd84 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr.ts @@ -13,7 +13,11 @@ import { handleError } from '../../../../lib/errors/handle_error'; // @ts-ignore import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; -import { ElasticsearchResponse, ElasticsearchSource } from '../../../../../common/types/es'; +import { + ElasticsearchResponse, + ElasticsearchLegacySource, + ElasticsearchMetricbeatSource, +} from '../../../../../common/types/es'; import { LegacyRequest } from '../../../../types'; function getBucketScript(max: string, min: string) { @@ -97,9 +101,13 @@ function buildRequest( size: maxBucketSize, filterPath: [ 'hits.hits.inner_hits.by_shard.hits.hits._source.ccr_stats.read_exceptions', + 'hits.hits.inner_hits.by_shard.hits.hits._source.elasticsearch.ccr.read_exceptions', 'hits.hits.inner_hits.by_shard.hits.hits._source.ccr_stats.follower_index', + 'hits.hits.inner_hits.by_shard.hits.hits._source.elasticsearch.ccr.follower.index', 'hits.hits.inner_hits.by_shard.hits.hits._source.ccr_stats.shard_id', + 'hits.hits.inner_hits.by_shard.hits.hits._source.elasticsearch.ccr.follower.shard.number', 'hits.hits.inner_hits.by_shard.hits.hits._source.ccr_stats.time_since_last_read_millis', + 'hits.hits.inner_hits.by_shard.hits.hits._source.elasticsearch.ccr.follower.time_since_last_read.ms', 'aggregations.by_follower_index.buckets.key', 'aggregations.by_follower_index.buckets.leader_index.buckets.key', 'aggregations.by_follower_index.buckets.leader_index.buckets.remote_cluster.buckets.key', @@ -115,10 +123,23 @@ function buildRequest( bool: { must: [ { - term: { - type: { - value: 'ccr_stats', - }, + bool: { + should: [ + { + term: { + type: { + value: 'ccr_stats', + }, + }, + }, + { + term: { + 'metricset.name': { + value: 'ccr', + }, + }, + }, + ], }, }, { @@ -209,29 +230,28 @@ export function ccrRoute(server: { try { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - const response: ElasticsearchResponse = await callWithRequest( - req, - 'search', - buildRequest(req, config, esIndexPattern) - ); + const params = buildRequest(req, config, esIndexPattern); + const response: ElasticsearchResponse = await callWithRequest(req, 'search', params); if (!response || Object.keys(response).length === 0) { return { data: [] }; } const fullStats: { - [key: string]: Array>; + [key: string]: Array< + | NonNullable + | NonNullable['ccr'] + >; } = response.hits?.hits.reduce((accum, hit) => { const innerHits = hit.inner_hits?.by_shard.hits?.hits ?? []; - const innerHitsSource = innerHits.map( - (innerHit) => - innerHit._source.ccr_stats as NonNullable - ); - const grouped = groupBy( - innerHitsSource, - (stat) => `${stat.follower_index}:${stat.shard_id}` - ); + const grouped = groupBy(innerHits, (innerHit) => { + if (innerHit._source.ccr_stats) { + return `${innerHit._source.ccr_stats.follower_index}:${innerHit._source.ccr_stats.shard_id}`; + } else if (innerHit._source.elasticsearch?.ccr?.follower?.shard) { + return `${innerHit._source.elasticsearch?.ccr?.follower?.index}:${innerHit._source.elasticsearch?.ccr?.follower?.shard?.number}`; + } + }); return { ...accum, @@ -268,14 +288,25 @@ export function ccrRoute(server: { stat.shards = get(bucket, 'by_shard_id.buckets').reduce( (accum2: any, shardBucket: any) => { - const fullStat = fullStats[`${bucket.key}:${shardBucket.key}`][0] ?? {}; + const fullStat: any = fullStats[`${bucket.key}:${shardBucket.key}`][0]; + const fullLegacyStat: ElasticsearchLegacySource = fullStat._source?.ccr_stats + ? fullStat._source + : null; + const fullMbStat: ElasticsearchMetricbeatSource = fullStat._source?.elasticsearch?.ccr + ? fullStat._source + : null; + const readExceptions = + fullLegacyStat?.ccr_stats?.read_exceptions ?? + fullMbStat?.elasticsearch?.ccr?.read_exceptions ?? + []; const shardStat = { shardId: shardBucket.key, - error: fullStat.read_exceptions?.length - ? fullStat.read_exceptions[0].exception?.type - : null, + error: readExceptions.length ? readExceptions[0].exception?.type : null, opsSynced: get(shardBucket, 'ops_synced.value'), - syncLagTime: fullStat.time_since_last_read_millis, + syncLagTime: + // @ts-ignore + fullLegacyStat?.ccr_stats?.time_since_last_read_millis ?? + fullMbStat?.elasticsearch?.ccr?.follower?.time_since_last_read?.ms, syncLagOps: get(shardBucket, 'lag_ops.value'), syncLagOpsLeader: get(shardBucket, 'leader_lag_ops.value'), syncLagOpsFollower: get(shardBucket, 'follower_lag_ops.value'), diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.ts index 096f18a154cb2..ac5563430fb7c 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/ccr_shard.ts @@ -37,9 +37,13 @@ async function getCcrStat(req: LegacyRequest, esIndexPattern: string, filters: u size: 1, filterPath: [ 'hits.hits._source.ccr_stats', + 'hits.hits._source.elasticsearch.ccr', 'hits.hits._source.timestamp', + 'hits.hits._source.@timestamp', 'hits.hits.inner_hits.oldest.hits.hits._source.ccr_stats.operations_written', + 'hits.hits.inner_hits.oldest.hits.hits._source.elasticsearch.ccr.follower.operations_written', 'hits.hits.inner_hits.oldest.hits.hits._source.ccr_stats.failed_read_requests', + 'hits.hits.inner_hits.oldest.hits.hits._source.elasticsearch.ccr.requests.failed.read.count', ], body: { sort: [{ timestamp: { order: 'desc', unmapped_type: 'long' } }], @@ -102,10 +106,23 @@ export function ccrShardRoute(server: { route: (p: any) => void; config: () => { const filters = [ { - term: { - type: { - value: 'ccr_stats', - }, + bool: { + should: [ + { + term: { + type: { + value: 'ccr_stats', + }, + }, + }, + { + term: { + 'metricset.name': { + value: 'ccr', + }, + }, + }, + ], }, }, { @@ -138,16 +155,23 @@ export function ccrShardRoute(server: { route: (p: any) => void; config: () => { getCcrStat(req, esIndexPattern, filters), ]); - const stat = ccrResponse.hits?.hits[0]?._source.ccr_stats ?? {}; - const oldestStat = - ccrResponse.hits?.hits[0].inner_hits?.oldest.hits?.hits[0]?._source.ccr_stats ?? {}; + const legacyStat = ccrResponse.hits?.hits[0]?._source.ccr_stats; + const mbStat = ccrResponse.hits?.hits[0]?._source.elasticsearch?.ccr; + const oldestLegacyStat = + ccrResponse.hits?.hits[0].inner_hits?.oldest.hits?.hits[0]?._source.ccr_stats; + const oldestMBStat = + ccrResponse.hits?.hits[0].inner_hits?.oldest.hits?.hits[0]?._source.elasticsearch?.ccr; + + const leaderIndex = mbStat ? mbStat?.leader?.index : legacyStat?.leader_index; return { metrics, - stat, - formattedLeader: getFormattedLeaderIndex(stat.leader_index ?? ''), - timestamp: ccrResponse.hits?.hits[0]?._source.timestamp, - oldestStat, + stat: mbStat ?? legacyStat, + formattedLeader: getFormattedLeaderIndex(leaderIndex ?? ''), + timestamp: + ccrResponse.hits?.hits[0]?._source['@timestamp'] ?? + ccrResponse.hits?.hits[0]?._source.timestamp, + oldestStat: oldestMBStat ?? oldestLegacyStat, }; } catch (err) { return handleError(err, req); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js index 52dfe898efb85..89ca911f44268 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js @@ -51,7 +51,8 @@ export function esIndexRoute(server) { const filebeatIndexPattern = prefixIndexPattern( config, config.get('monitoring.ui.logs.index'), - '*' + '*', + true ); const isAdvanced = req.payload.is_advanced; const metricSet = isAdvanced ? metricSetAdvanced : metricSetOverview; @@ -78,8 +79,19 @@ export function esIndexRoute(server) { let shardAllocation; if (!isAdvanced) { // TODO: Why so many fields needed for a single component (shard legend)? - const shardFilter = { term: { 'shard.index': indexUuid } }; - const stateUuid = get(cluster, 'cluster_state.state_uuid'); + const shardFilter = { + bool: { + should: [ + { term: { 'shard.index': indexUuid } }, + { term: { 'elasticsearch.index.name': indexUuid } }, + ], + }, + }; + const stateUuid = get( + cluster, + 'elasticsearch.cluster.stats.state.state_uuid', + get(cluster, 'cluster_state.state_uuid') + ); const allocationOptions = { shardFilter, stateUuid, diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js index 4a4411eeba6a5..2122f8ceb2215 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js @@ -52,7 +52,8 @@ export function esNodeRoute(server) { const filebeatIndexPattern = prefixIndexPattern( config, config.get('monitoring.ui.logs.index'), - '*' + '*', + true ); const isAdvanced = req.payload.is_advanced; @@ -76,7 +77,11 @@ export function esNodeRoute(server) { try { const cluster = await getClusterStats(req, esIndexPattern, clusterUuid); - const clusterState = get(cluster, 'cluster_state', { nodes: {} }); + const clusterState = get( + cluster, + 'cluster_state', + get(cluster, 'elasticsearch.cluster.stats.state') + ); const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeIndices: true, includeNodes: true, @@ -91,13 +96,23 @@ export function esNodeRoute(server) { const metrics = await getMetrics(req, esIndexPattern, metricSet, [ { term: { 'source_node.uuid': nodeUuid } }, ]); - let logs; let shardAllocation; if (!isAdvanced) { // TODO: Why so many fields needed for a single component (shard legend)? - const shardFilter = { term: { 'shard.node': nodeUuid } }; - const stateUuid = get(cluster, 'cluster_state.state_uuid'); + const shardFilter = { + bool: { + should: [ + { term: { 'shard.node': nodeUuid } }, + { term: { 'elasticsearch.node.name': nodeUuid } }, + ], + }, + }; + const stateUuid = get( + cluster, + 'cluster_state.state_uuid', + get(cluster, 'elasticsearch.cluster.stats.state.state_uuid') + ); const allocationOptions = { shardFilter, stateUuid, diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js index 268e6f77055e6..c76513df721ba 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js @@ -43,7 +43,8 @@ export function esOverviewRoute(server) { const filebeatIndexPattern = prefixIndexPattern( config, config.get('monitoring.ui.logs.index'), - '*' + '*', + true ); const start = req.payload.timeRange.min; @@ -53,7 +54,7 @@ export function esOverviewRoute(server) { const [clusterStats, metrics, shardActivity, logs] = await Promise.all([ getClusterStats(req, esIndexPattern, clusterUuid), getMetrics(req, esIndexPattern, metricSet), - getLastRecovery(req, esIndexPattern), + getLastRecovery(req, esIndexPattern, config.get('monitoring.ui.max_bucket_size')), getLogs(config, req, filebeatIndexPattern, { clusterUuid, start, end }), ]); const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( @@ -62,12 +63,13 @@ export function esOverviewRoute(server) { clusterStats ); - return { + const result = { clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), metrics, logs, shardActivity, }; + return result; } catch (err) { throw handleError(err, req); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/kibana/overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/kibana/overview.js index cb522706d46d9..cca36d2aad1a7 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/kibana/overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/kibana/overview.js @@ -43,7 +43,16 @@ export function kibanaOverviewRoute(server) { try { const [clusterStatus, metrics] = await Promise.all([ getKibanaClusterStatus(req, kbnIndexPattern, { clusterUuid }), - getMetrics(req, kbnIndexPattern, metricSet), + getMetrics(req, kbnIndexPattern, metricSet, [ + { + bool: { + should: [ + { term: { type: 'kibana_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, + ]), ]); return { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js index 69e90ea1cfa6f..b81b4ea796c63 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/node.js @@ -72,7 +72,16 @@ export function logstashNodeRoute(server) { try { const [metrics, nodeSummary] = await Promise.all([ - getMetrics(req, lsIndexPattern, metricSet), + getMetrics(req, lsIndexPattern, metricSet, [ + { + bool: { + should: [ + { term: { type: 'logstash_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, + ]), getNodeInfo(req, lsIndexPattern, { clusterUuid, logstashUuid }), ]); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js index 7eee7c9cd982f..23dd64a1afb74 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/overview.js @@ -52,7 +52,16 @@ export function logstashOverviewRoute(server) { try { const [metrics, clusterStatus] = await Promise.all([ - getMetrics(req, lsIndexPattern, metricSet), + getMetrics(req, lsIndexPattern, metricSet, [ + { + bool: { + should: [ + { term: { type: 'logstash_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], + }, + }, + ]), getClusterStatus(req, lsIndexPattern, { clusterUuid }), ]); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts index f4f67a5582303..fafb52d6862b5 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts @@ -288,7 +288,10 @@ export async function fetchLogstashStats( { terms: { cluster_uuid: clusterUuids } }, { bool: { - must: { term: { type: 'logstash_stats' } }, + should: [ + { term: { type: 'logstash_stats' } }, + { term: { 'metricset.name': 'stats' } }, + ], }, }, ], diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index de3d044ccabcb..799214a2931ed 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -119,6 +119,7 @@ export interface LegacyRequest { } export interface LegacyServer { + log: Logger; route: (params: any) => void; config: () => { get: (key: string) => string | undefined; diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx index 3cb61f85d57f0..828038fd75436 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx @@ -6,7 +6,7 @@ */ import { Chart, Settings, AreaSeries } from '@elastic/charts'; -import { EuiIcon, EuiTextColor } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiIcon, EuiTextColor } from '@elastic/eui'; import React, { useContext } from 'react'; import { EUI_CHARTS_THEME_DARK, @@ -43,19 +43,22 @@ export function MetricWithSparkline({ id, formatter, value, timeseries, color }: ); } return ( - <> - - - - -   - {formatter(value)} - + + + + + + + + + {formatter(value)} + + ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 530b8dee3a4d2..8d3060792857e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,13 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + mockAppIndexPattern(); render( field === fd); - const displayValues = (values || []).filter((opt) => - opt.toLowerCase().includes(value.toLowerCase()) - ); + const displayValues = values.filter((opt) => opt.toLowerCase().includes(value.toLowerCase())); return ( @@ -60,50 +56,70 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is { setValue(evt.target.value); }} + placeholder={i18n.translate('xpack.observability.filters.expanded.search', { + defaultMessage: 'Search for {label}', + values: { label }, + })} /> - {loading && ( -
- -
- )} - {displayValues.map((opt) => ( - - - {isNegated !== false && ( + + {displayValues.map((opt) => ( + + + {isNegated !== false && ( + + )} - )} - - - - - ))} + + + + ))} +
); } +const ListWrapper = euiStyled.div` + height: 400px; + overflow-y: auto; + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } +`; + const Wrapper = styled.div` - max-width: 400px; + width: 400px; `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 88cb538263419..2d82aca658ec3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -119,7 +119,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P button={button} isOpen={isPopoverVisible} closePopover={closePopover} - anchorPosition="leftCenter" + anchorPosition={isNew ? 'leftCenter' : 'rightCenter'} > {!selectedField ? mainPanel : childPanel} diff --git a/x-pack/plugins/observability/public/hooks/use_es_search.ts b/x-pack/plugins/observability/public/hooks/use_es_search.ts new file mode 100644 index 0000000000000..b6ee4a63823b1 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_es_search.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 { estypes } from '@elastic/elasticsearch'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { ESSearchResponse } from '../../../../../typings/elasticsearch'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { isCompleteResponse } from '../../../../../src/plugins/data/common'; +import { useFetcher } from './use_fetcher'; + +export const useEsSearch = ( + params: TParams, + fnDeps: any[] +) => { + const { + services: { data }, + } = useKibana<{ data: DataPublicPluginStart }>(); + + const { data: response = {}, loading } = useFetcher(() => { + return new Promise((resolve) => { + const search$ = data.search + .search({ + params, + }) + .subscribe({ + next: (result) => { + if (isCompleteResponse(result)) { + // Final result + resolve(result); + search$.unsubscribe(); + } + }, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...fnDeps]); + + const { rawResponse } = response as any; + + return { data: rawResponse as ESSearchResponse, loading }; +}; + +export function createEsParams(params: T): T { + return params; +} diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index e17f515ed6cb9..147a66f3d505e 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -5,11 +5,12 @@ * 2.0. */ +import { capitalize, merge } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; import { IndexPattern } from '../../../../../src/plugins/data/common'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; -import { useFetcher } from './use_fetcher'; import { ESFilter } from '../../../../../typings/elasticsearch'; +import { createEsParams, useEsSearch } from './use_es_search'; export interface Props { sourceField: string; @@ -17,6 +18,7 @@ export interface Props { indexPattern: IndexPattern; filters?: ESFilter[]; time?: { from: string; to: string }; + keepHistory?: boolean; } export const useValuesList = ({ @@ -25,38 +27,83 @@ export const useValuesList = ({ query = '', filters, time, + keepHistory, }: Props): { values: string[]; loading?: boolean } => { - const { - services: { data }, - } = useKibana<{ data: DataPublicPluginStart }>(); + const [debouncedQuery, setDebounceQuery] = useState(query); + const [values, setValues] = useState([]); const { from, to } = time ?? {}; - const { data: values, loading } = useFetcher(() => { - if (!sourceField || !indexPattern) { - return []; + let includeClause = ''; + + if (query) { + if (query[0].toLowerCase() === query[0]) { + // if first letter is lowercase we also add the capitalize option + includeClause = `(${query}|${capitalize(query)}).*`; + } else { + // otherwise we add lowercase option prefix + includeClause = `(${query}|${query.toLowerCase()}).*`; } - return data.autocomplete.getValueSuggestions({ - indexPattern, - query: query || '', - useTimeRange: !(from && to), - field: indexPattern.getFieldByName(sourceField)!, - boolFilter: - from && to - ? [ - ...(filters || []), - { - range: { - '@timestamp': { - gte: from, - lte: to, - }, - }, - }, - ] - : filters || [], - }); - }, [query, sourceField, data.autocomplete, indexPattern, from, to, filters]); - - return { values: values as string[], loading }; + } + + useDebounce( + () => { + setDebounceQuery(query); + }, + 350, + [query] + ); + + const { data, loading } = useEsSearch( + createEsParams({ + index: indexPattern.title, + body: { + query: { + bool: { + filter: [ + ...(filters ?? []), + ...(from && to + ? [ + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ] + : []), + ], + }, + }, + size: 0, + aggs: { + values: { + terms: { + field: sourceField, + size: 100, + ...(query ? { include: includeClause } : {}), + }, + }, + }, + }, + }), + [debouncedQuery, from, to] + ); + + useEffect(() => { + const newValues = + data?.aggregations?.values.buckets.map(({ key: value }) => value as string) ?? []; + + if (keepHistory) { + setValues((prevState) => { + return merge(newValues, prevState); + }); + } else { + setValues(newValues); + } + }, [data, keepHistory, loading]); + + return { values, loading }; }; diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css index 86183694330e2..508d217cdd030 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css @@ -23,11 +23,6 @@ filter-bar, display: none !important; } -/* override open/closed positioning of the app wrapper/nav */ -.app-wrapper { - left: 0px !important; -} - /** * Discover Tweaks */ diff --git a/x-pack/plugins/reporting/server/lib/layouts/print.css b/x-pack/plugins/reporting/server/lib/layouts/print.css index fa963ac72ab41..92cbb327a3216 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print.css +++ b/x-pack/plugins/reporting/server/lib/layouts/print.css @@ -23,11 +23,6 @@ filter-bar, display: none !important; } -/* override open/closed positioning of the app wrapper/nav */ -.app-wrapper { - left: 0px !important; -} - /** * Discover Tweaks */ diff --git a/x-pack/plugins/reporting/server/lib/screenshots/constants.ts b/x-pack/plugins/reporting/server/lib/screenshots/constants.ts index fb5220fa39555..3d8c50782deed 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/constants.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/constants.ts @@ -5,7 +5,8 @@ * 2.0. */ -export const DEFAULT_PAGELOAD_SELECTOR = '.application'; +import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; +export const DEFAULT_PAGELOAD_SELECTOR = `.${APP_WRAPPER_CLASS}`; export const CONTEXT_GETNUMBEROFITEMS = 'GetNumberOfItems'; export const CONTEXT_GETBROWSERDIMENSIONS = 'GetBrowserDimensions'; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 31726fa42a9cb..5419775f14407 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -204,7 +204,7 @@ describe('Screenshot Observable Pipeline', () => { expect(mockOpen.mock.calls.length).toBe(2); const firstSelector = mockOpen.mock.calls[0][1].waitForSelector; - expect(firstSelector).toBe('.application'); + expect(firstSelector).toBe('.kbnAppWrapper'); const secondSelector = mockOpen.mock.calls[1][1].waitForSelector; expect(secondSelector).toBe('[data-shared-page="2"]'); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index 70e5b89af7e82..7405e8cff8975 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ReportingCore } from '../..'; +import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { omitBlockedHeaders } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; @@ -47,8 +48,8 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log height: 2024, }, selectors: { - screenshot: '.application', - renderComplete: '.application', + screenshot: `.${APP_WRAPPER_CLASS}`, + renderComplete: `.${APP_WRAPPER_CLASS}`, itemsCountAttribute: 'data-test-subj="kibanaChrome"', timefilterDurationAttribute: 'data-test-subj="kibanaChrome"', }, diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index 7141b1a141185..3f2a95a34224c 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; +import { ROUTE_TAG_CAN_REDIRECT } from '../../../security/server'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; @@ -198,6 +199,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { docId: schema.string({ minLength: 3 }), }), }, + options: { tags: [ROUTE_TAG_CAN_REDIRECT] }, }, userHandler(async (user, context, req, res) => { // ensure the async dependencies are loaded diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index a205109f537e7..ef83230fc2aba 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -19,7 +19,22 @@ export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; +/** + * This is the key of a query parameter that contains the name of the authentication provider that should be used to + * authenticate request. It's also used while the user is being redirected during single-sign-on authentication flows. + * That query parameter is discarded after the authentication flow succeeds. See the `Authenticator`, + * `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for more information. + */ export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint'; + +/** + * This is the key of a query parameter that contains metadata about the (client-side) URL hash while the user is being + * redirected during single-sign-on authentication flows. That query parameter is discarded after the authentication + * flow succeeds. See the `Authenticator`, `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for + * more information. + */ +export const AUTH_URL_HASH_QUERY_STRING_PARAMETER = 'auth_url_hash'; + export const LOGOUT_PROVIDER_QUERY_STRING_PARAMETER = 'provider'; export const LOGOUT_REASON_QUERY_STRING_PARAMETER = 'msg'; export const NEXT_URL_QUERY_STRING_PARAMETER = 'next'; diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index 6dad3886401ac..cb7d64fe79786 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -9,8 +9,10 @@ import type { AuthenticatedUser } from './authenticated_user'; // We omit `roles` here since the original interface defines this field as `readonly string[]` that makes it hard to use // in various mocks that expect mutable string array. -type AuthenticatedUserProps = Partial & { roles: string[] }>; -export function mockAuthenticatedUser(user: AuthenticatedUserProps = {}) { +export type MockAuthenticatedUserProps = Partial< + Omit & { roles: string[] } +>; +export function mockAuthenticatedUser(user: MockAuthenticatedUserProps = {}) { return { username: 'user', email: 'email', diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 4f0f69ac68886..202fc1b98452c 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -42,6 +42,11 @@ export interface AuthenticationServiceSetup { areAPIKeysEnabled: () => Promise; } +/** + * Start has the same contract as Setup for now. + */ +export type AuthenticationServiceStart = AuthenticationServiceSetup; + export class AuthenticationService { public setup({ application, diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts index bf68d9f7a6e5e..44fd5ab195341 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -5,19 +5,29 @@ * 2.0. */ -import type { AppMount, ScopedHistory } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock } from 'src/core/public/mocks'; import { captureURLApp } from './capture_url_app'; describe('captureURLApp', () => { + let mockLocationReplace: jest.Mock; beforeAll(() => { + mockLocationReplace = jest.fn(); Object.defineProperty(window, 'location', { - value: { href: 'https://some-host' }, + value: { + href: 'https://some-host', + hash: '#/?_g=()', + origin: 'https://some-host', + replace: mockLocationReplace, + }, writable: true, }); }); + beforeEach(() => { + mockLocationReplace.mockClear(); + }); + it('properly registers application', () => { const coreSetupMock = coreMock.createSetup(); @@ -42,34 +52,37 @@ describe('captureURLApp', () => { it('properly handles captured URL', async () => { window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( - '/mock-base-path/app/home' - )}&providerType=saml&providerName=saml1#/?_g=()`; + '/mock-base-path/app/home?auth_provider_hint=saml1' + )}#/?_g=()`; const coreSetupMock = coreMock.createSetup(); - coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' }); - captureURLApp.create(coreSetupMock); const [[{ mount }]] = coreSetupMock.application.register.mock.calls; - await (mount as AppMount)({ - element: document.createElement('div'), - appBasePath: '', - onAppLeave: jest.fn(), - setHeaderActionMenu: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, - }); + await mount(coreMock.createAppMountParamters()); - expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1); - expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ - providerType: 'saml', - providerName: 'saml1', - currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( - '/mock-base-path/app/home' - )}&providerType=saml&providerName=saml1#/?_g=()`, - }), - }); + expect(mockLocationReplace).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith( + 'https://some-host/mock-base-path/app/home?auth_provider_hint=saml1&auth_url_hash=%23%2F%3F_g%3D%28%29#/?_g=()' + ); + expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled(); + }); - expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()'); + it('properly handles open redirects', async () => { + window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + 'https://evil.com/mock-base-path/app/home?auth_provider_hint=saml1' + )}#/?_g=()`; + + const coreSetupMock = coreMock.createSetup(); + captureURLApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await mount(coreMock.createAppMountParamters()); + + expect(mockLocationReplace).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith( + 'https://some-host/?auth_url_hash=%23%2F%3F_g%3D%28%29' + ); + expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts index 7797ce4e62102..af45314c5bacb 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { parse } from 'url'; - import type { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public'; +import { AUTH_URL_HASH_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { parseNext } from '../../../common/parse_next'; + interface CreateDeps { application: ApplicationSetup; http: HttpSetup; @@ -22,20 +23,17 @@ interface CreateDeps { * path segment into the `next` query string parameter (so that it's not lost during redirect). And * since browsers preserve hash fragments during redirects (assuming redirect location doesn't * specify its own hash fragment, which is true in our case) this app can capture both path and - * hash URL segments and send them back to the authentication provider via login endpoint. + * hash URL segments and re-try request sending hash fragment in a dedicated query string parameter. * * The flow can look like this: - * 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication. - * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`. - * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`. - * 4. The app captures full URL and sends it back as is via login endpoint: - * { - * providerType: 'saml', - * providerName: 'saml1', - * currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch' - * } - * 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment - * and finally passes it to the provider that initiated capturing. + * 1. User visits `https://kibana.com/app/kibana#/management/elasticsearch` that initiates authentication. + * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1`. + * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1#/management/elasticsearch`. + * 4. The app reconstructs original URL, adds `auth_url_hash` query string parameter with the captured hash fragment and redirects user to: + * https://kibana.com/app/kibana?auth_provider_hint=saml1&auth_url_hash=%23%2Fmanagement%2Felasticsearch#/management/elasticsearch + * 5. Once Kibana receives this request, it immediately picks exactly the same provider to handle authentication (based on `auth_provider_hint=saml1`), + * and, since it has full URL now (original request path, query string and hash extracted from `auth_url_hash=%23%2Fmanagement%2Felasticsearch`), + * it can proceed to a proper authentication handshake. */ export const captureURLApp = Object.freeze({ id: 'security_capture_url', @@ -48,19 +46,14 @@ export const captureURLApp = Object.freeze({ appRoute: '/internal/security/capture-url', async mount() { try { - const { providerName, providerType } = parse(window.location.href, true).query ?? {}; - if (!providerName || !providerType) { - fatalErrors.add(new Error('Provider to capture URL for is not specified.')); - return () => {}; - } - - const { location } = await http.post<{ location: string }>('/internal/security/login', { - body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }), - }); - - window.location.href = location; + const url = new URL( + parseNext(window.location.href, http.basePath.serverBasePath), + window.location.origin + ); + url.searchParams.append(AUTH_URL_HASH_QUERY_STRING_PARAMETER, window.location.hash); + window.location.replace(url.toString()); } catch (err) { - fatalErrors.add(new Error('Cannot login with captured URL.')); + fatalErrors.add(new Error(`Cannot parse current URL: ${err && err.message}.`)); } return () => {}; diff --git a/x-pack/plugins/security/public/authentication/index.mock.ts b/x-pack/plugins/security/public/authentication/index.mock.ts index 47c9dad012eae..092126e6cfeed 100644 --- a/x-pack/plugins/security/public/authentication/index.mock.ts +++ b/x-pack/plugins/security/public/authentication/index.mock.ts @@ -5,11 +5,18 @@ * 2.0. */ -import type { AuthenticationServiceSetup } from './authentication_service'; +import type { + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication_service'; export const authenticationMock = { createSetup: (): jest.Mocked => ({ getCurrentUser: jest.fn(), areAPIKeysEnabled: jest.fn(), }), + createStart: (): jest.Mocked => ({ + getCurrentUser: jest.fn(), + areAPIKeysEnabled: jest.fn(), + }), }; diff --git a/x-pack/plugins/security/public/authentication/index.ts b/x-pack/plugins/security/public/authentication/index.ts index 74b4740d31ef0..50d6b0c74376e 100644 --- a/x-pack/plugins/security/public/authentication/index.ts +++ b/x-pack/plugins/security/public/authentication/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { AuthenticationService, AuthenticationServiceSetup } from './authentication_service'; +export { + AuthenticationService, + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication_service'; diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index 66e91a390784a..63928e4e82e37 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { LoginForm } from './login_form'; +export { LoginForm, LoginFormMessageType } from './login_form'; export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts index 6215f4e1e5b7a..d12ea30c784cb 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { LoginForm } from './login_form'; +export { LoginForm, MessageType as LoginFormMessageType } from './login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index f58150d4580b8..e816fa032a0e5 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -14,7 +14,7 @@ import ReactMarkdown from 'react-markdown'; import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test/jest'; import { coreMock } from 'src/core/public/mocks'; -import { LoginForm, PageMode } from './login_form'; +import { LoginForm, MessageType, PageMode } from './login_form'; function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { const assertions: Array<[string, boolean]> = @@ -90,7 +90,7 @@ describe('LoginForm', () => { { }); expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` + `Username or password is incorrect. Please try again.` ); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index ca573ada36d22..df131e2eac133 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -40,7 +40,7 @@ interface Props { http: HttpStart; notifications: NotificationsStart; selector: LoginSelector; - infoMessage?: string; + message?: { type: MessageType.Danger | MessageType.Info; content: string }; loginAssistanceMessage: string; loginHelp?: string; authProviderHint?: string; @@ -66,7 +66,7 @@ enum LoadingStateType { AutoLogin, } -enum MessageType { +export enum MessageType { None, Info, Danger, @@ -106,9 +106,7 @@ export class LoginForm extends Component { loadingState: { type: LoadingStateType.None }, username: '', password: '', - message: this.props.infoMessage - ? { type: MessageType.Info, content: this.props.infoMessage } - : { type: MessageType.None }, + message: this.props.message || { type: MessageType.None }, mode, previousMode: mode, }; @@ -206,7 +204,7 @@ export class LoginForm extends Component { > @@ -480,8 +478,8 @@ export class LoginForm extends Component { const message = (error as IHttpFetchError).response?.status === 401 ? i18n.translate( - 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - { defaultMessage: 'Invalid username or password. Please try again.' } + 'xpack.security.login.basicLoginForm.usernameOrPasswordIsIncorrectErrorMessage', + { defaultMessage: 'Username or password is incorrect. Please try again.' } ) : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { defaultMessage: 'Oops! Error. Try again.', diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index a9596aff3bf0e..b3e2fac3ea2cc 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -14,7 +14,7 @@ import { coreMock } from 'src/core/public/mocks'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import type { LoginState } from '../../../common/login_state'; -import { DisabledLoginForm, LoginForm } from './components'; +import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components'; import { LoginPage } from './login_page'; const createLoginState = (options?: Partial) => { @@ -228,9 +228,12 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props(); + const { authProviderHint, message } = wrapper.find(LoginForm).props(); expect(authProviderHint).toBe('basic1'); - expect(infoMessage).toBe('Your session has timed out. Please log in again.'); + expect(message).toEqual({ + type: LoginFormMessageType.Info, + content: 'Your session has timed out. Please log in again.', + }); }); it('renders as expected when loginAssistanceMessage is set', async () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index 562adec7918d3..40438ac1c78f3 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -23,7 +23,7 @@ import { LOGOUT_REASON_QUERY_STRING_PARAMETER, } from '../../../common/constants'; import type { LoginState } from '../../../common/login_state'; -import { DisabledLoginForm, LoginForm } from './components'; +import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components'; interface Props { http: HttpStart; @@ -36,18 +36,34 @@ interface State { loginState: LoginState | null; } -const infoMessageMap = new Map([ +const messageMap = new Map([ [ 'SESSION_EXPIRED', - i18n.translate('xpack.security.login.sessionExpiredDescription', { - defaultMessage: 'Your session has timed out. Please log in again.', - }), + { + type: LoginFormMessageType.Info, + content: i18n.translate('xpack.security.login.sessionExpiredDescription', { + defaultMessage: 'Your session has timed out. Please log in again.', + }), + }, ], [ 'LOGGED_OUT', - i18n.translate('xpack.security.login.loggedOutDescription', { - defaultMessage: 'You have logged out of Elastic.', - }), + { + type: LoginFormMessageType.Info, + content: i18n.translate('xpack.security.login.loggedOutDescription', { + defaultMessage: 'You have logged out of Elastic.', + }), + }, + ], + [ + 'UNAUTHENTICATED', + { + type: LoginFormMessageType.Danger, + content: i18n.translate('xpack.security.unauthenticated.errorDescription', { + defaultMessage: + "We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.", + }), + }, ], ]); @@ -226,7 +242,7 @@ export class LoginPage extends Component { notifications={this.props.notifications} selector={selector} // @ts-expect-error Map.get is ok with getting `undefined` - infoMessage={infoMessageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())} + message={messageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())} loginAssistanceMessage={this.props.loginAssistanceMessage} loginHelp={loginHelp} authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()} diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index 0502025e9bae8..829c3ced9dddb 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -6,6 +6,8 @@ */ import { licenseMock } from '../common/licensing/index.mock'; +import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; +import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; import { authenticationMock } from './authentication/index.mock'; import { navControlServiceMock } from './nav_control/index.mock'; import { createSessionTimeoutMock } from './session/session_timeout.mock'; @@ -19,6 +21,7 @@ function createSetupMock() { } function createStartMock() { return { + authc: authenticationMock.createStart(), navControlService: navControlServiceMock.createStart(), }; } @@ -26,4 +29,6 @@ function createStartMock() { export const securityMock = { createSetup: createSetupMock, createStart: createStartMock, + createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => + mockAuthenticatedUser(props), }; diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index fa9d11422e884..d3794ddbeb1a6 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -103,6 +103,10 @@ describe('Security Plugin', () => { features: {} as FeaturesPluginStart, }) ).toEqual({ + authc: { + getCurrentUser: expect.any(Function), + areAPIKeysEnabled: expect.any(Function), + }, navControlService: { getUserMenuLinks$: expect.any(Function), addUserMenuLinks: expect.any(Function), diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 5d86f15174633..c805d9f1caf79 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -21,7 +21,7 @@ import type { LicensingPluginSetup } from '../../licensing/public'; import type { SpacesPluginStart } from '../../spaces/public'; import { SecurityLicenseService } from '../common/licensing'; import { accountManagementApp } from './account_management'; -import type { AuthenticationServiceSetup } from './authentication'; +import type { AuthenticationServiceSetup, AuthenticationServiceStart } from './authentication'; import { AuthenticationService } from './authentication'; import type { ConfigType } from './config'; import { ManagementService } from './management'; @@ -153,7 +153,10 @@ export class SecurityPlugin this.managementService.start({ capabilities: core.application.capabilities }); } - return { navControlService: this.navControlService.start({ core }) }; + return { + navControlService: this.navControlService.start({ core }), + authc: this.authc as AuthenticationServiceStart, + }; } public stop() { diff --git a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap new file mode 100644 index 0000000000000..bcb97538b4f05 --- /dev/null +++ b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; + +exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; diff --git a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap new file mode 100644 index 0000000000000..55168401992f7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We couldn't log you in

We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.

"`; diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts new file mode 100644 index 0000000000000..12a63134f4ef2 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockCanRedirectRequest = jest.fn(); +jest.mock('./can_redirect_request', () => ({ canRedirectRequest: mockCanRedirectRequest })); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index b0be9445c3fc3..d38f963a60c33 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -6,6 +6,9 @@ */ jest.mock('./authenticator'); +jest.mock('./unauthenticated_page'); + +import { mockCanRedirectRequest } from './authentication_service.test.mocks'; import Boom from '@hapi/boom'; @@ -18,6 +21,7 @@ import type { KibanaRequest, Logger, LoggerFactory, + OnPreResponseToolkit, } from 'src/core/server'; import { coreMock, @@ -37,6 +41,7 @@ import type { ConfigType } from '../config'; import { ConfigSchema, createConfig } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { sessionMock } from '../session_management/session.mock'; import { AuthenticationResult } from './authentication_result'; @@ -47,15 +52,60 @@ describe('AuthenticationService', () => { let logger: jest.Mocked; let mockSetupAuthenticationParams: { http: jest.Mocked; + config: ConfigType; license: jest.Mocked; + buildNumber: number; + }; + let mockStartAuthenticationParams: { + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; + config: ConfigType; + loggers: LoggerFactory; + http: jest.Mocked; + clusterClient: ReturnType; + featureUsageService: jest.Mocked; + session: jest.Mocked>; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); + const httpMock = coreMock.createSetup().http; + (httpMock.basePath.prepend as jest.Mock).mockImplementation( + (path) => `${httpMock.basePath.serverBasePath}${path}` + ); + (httpMock.basePath.get as jest.Mock).mockImplementation(() => httpMock.basePath.serverBasePath); mockSetupAuthenticationParams = { - http: coreMock.createSetup().http, + http: httpMock, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.create().get(), { + isTLSEnabled: false, + }), license: licenseMock.create(), + buildNumber: 100500, }; + mockCanRedirectRequest.mockReturnValue(false); + + const coreStart = coreMock.createStart(); + mockStartAuthenticationParams = { + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ), + http: coreStart.http, + clusterClient: elasticsearchServiceMock.createClusterClient(), + loggers: loggingSystemMock.create(), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + session: sessionMock.create(), + }; + (mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation( + () => mockStartAuthenticationParams.http.basePath.serverBasePath + ); service = new AuthenticationService(logger); }); @@ -71,40 +121,19 @@ describe('AuthenticationService', () => { expect.any(Function) ); }); + + it('properly registers onPreResponse handler', () => { + service.setup(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledWith( + expect.any(Function) + ); + }); }); describe('#start()', () => { - let mockStartAuthenticationParams: { - legacyAuditLogger: jest.Mocked; - audit: jest.Mocked; - config: ConfigType; - loggers: LoggerFactory; - http: jest.Mocked; - clusterClient: ReturnType; - featureUsageService: jest.Mocked; - session: jest.Mocked>; - }; beforeEach(() => { - const coreStart = coreMock.createStart(); - mockStartAuthenticationParams = { - legacyAuditLogger: securityAuditLoggerMock.create(), - audit: auditServiceMock.create(), - config: createConfig( - ConfigSchema.validate({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }), - loggingSystemMock.create().get(), - { isTLSEnabled: false } - ), - http: coreStart.http, - clusterClient: elasticsearchServiceMock.createClusterClient(), - loggers: loggingSystemMock.create(), - featureUsageService: securityFeatureUsageServiceMock.createStartContract(), - session: sessionMock.create(), - }; - service.setup(mockSetupAuthenticationParams); }); @@ -318,4 +347,371 @@ describe('AuthenticationService', () => { }); }); }); + + describe('onPreResponse handler', () => { + function getService({ runStart = true }: { runStart?: boolean } = {}) { + service.setup(mockSetupAuthenticationParams); + + if (runStart) { + service.start(mockStartAuthenticationParams); + } + + const onPreResponseHandler = + mockSetupAuthenticationParams.http.registerOnPreResponse.mock.calls[0][0]; + const [authenticator] = jest.requireMock('./authenticator').Authenticator.mock.instances; + + return { authenticator, onPreResponseHandler }; + } + + it('ignores responses with non-401 status code', async () => { + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + + const { onPreResponseHandler } = getService(); + for (const statusCode of [200, 400, 403, 404]) { + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + } + }); + + it('ignores responses to requests that cannot handle redirects', async () => { + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + mockCanRedirectRequest.mockReturnValue(false); + + const { onPreResponseHandler } = getService(); + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + }); + + it('ignores responses if authenticator is not initialized', async () => { + // Run `setup`, but not `start` to simulate non-initialized `Authenticator`. + const { onPreResponseHandler } = getService({ runStart: false }); + + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + }); + + describe('when login form is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + }); + + it('redirects to the login page when user does not have an active session', async () => { + mockCanRedirectRequest.mockReturnValue(true); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('performs logout if user has an active session', async () => { + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F', + }, + }); + }); + }); + + describe('when login selector is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + + mockSetupAuthenticationParams.config = createConfig( + ConfigSchema.validate({ + authc: { + providers: { + saml: { saml1: { order: 0, realm: 'saml1' } }, + basic: { basic1: { order: 1 } }, + }, + }, + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ); + }); + + it('redirects to the login page when user does not have an active session', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('performs logout if user has an active session', async () => { + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F', + }, + }); + }); + }); + + describe('when neither login selector nor login form is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + + mockSetupAuthenticationParams.config = createConfig( + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { order: 0, realm: 'saml1' } } } }, + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ); + }); + + it('renders unauthenticated page if user does not have an active session', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/app/some', + }); + }); + + it('renders unauthenticated page if user has an active session', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/app/some', + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/', + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 7feeff7a5d8ed..e5895422e7a74 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -15,22 +15,29 @@ import type { LoggerFactory, } from 'src/core/server'; +import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../common/constants'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticatedUser } from '../../common/model'; +import { shouldProviderUseLoginForm } from '../../common/model'; import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; import type { ConfigType } from '../config'; -import { getErrorStatusCode } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { APIKeys } from './api_keys'; import type { AuthenticationResult } from './authentication_result'; import type { ProviderLoginAttempt } from './authenticator'; import { Authenticator } from './authenticator'; +import { canRedirectRequest } from './can_redirect_request'; import type { DeauthenticationResult } from './deauthentication_result'; +import { renderUnauthenticatedPage } from './unauthenticated_page'; interface AuthenticationServiceSetupParams { - http: Pick; + http: Pick; + config: ConfigType; license: SecurityLicense; + buildNumber: number; } interface AuthenticationServiceStartParams { @@ -62,12 +69,23 @@ export interface AuthenticationServiceStart { export class AuthenticationService { private license!: SecurityLicense; private authenticator?: Authenticator; + private session?: PublicMethodsOf; constructor(private readonly logger: Logger) {} - setup({ http, license }: AuthenticationServiceSetupParams) { + setup({ config, http, license, buildNumber }: AuthenticationServiceSetupParams) { this.license = license; + // If we cannot automatically authenticate users we should redirect them straight to the login + // page if possible, so that they can try other methods to log in. If not possible, we should + // render a dedicated `Unauthenticated` page from which users can explicitly trigger a new + // login attempt. There are two cases when we can redirect to the login page: + // 1. Login selector is enabled + // 2. Login selector is disabled, but the provider with the lowest `order` uses login form + const isLoginPageAvailable = + config.authc.selector.enabled || + shouldProviderUseLoginForm(config.authc.sortedProviders[0].type); + http.registerAuth(async (request, response, t) => { if (!license.isLicenseAvailable()) { this.logger.error('License is not available, authentication is not possible.'); @@ -118,8 +136,9 @@ export class AuthenticationService { } if (authenticationResult.failed()) { - this.logger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); const error = authenticationResult.error!; + this.logger.info(`Authentication attempt failed: ${getDetailedErrorMessage(error)}`); + // proxy Elasticsearch "native" errors const statusCode = getErrorStatusCode(error); if (typeof statusCode === 'number') { @@ -139,7 +158,49 @@ export class AuthenticationService { return t.notHandled(); }); - this.logger.debug('Successfully registered core authentication handler.'); + http.registerOnPreResponse(async (request, preResponse, toolkit) => { + if (preResponse.statusCode !== 401 || !canRedirectRequest(request)) { + return toolkit.next(); + } + + if (!this.authenticator) { + // Core doesn't allow returning error here. + this.logger.error('Authentication sub-system is not fully initialized yet.'); + return toolkit.next(); + } + + // If users can eventually re-login we want to redirect them directly to the page they tried + // to access initially, but we only want to do that for routes that aren't part of the various + // authentication flows that wouldn't make any sense after successful authentication. + const originalURL = !request.route.options.tags.includes(ROUTE_TAG_AUTH_FLOW) + ? this.authenticator.getRequestOriginalURL(request) + : `${http.basePath.get(request)}/`; + if (!isLoginPageAvailable) { + return toolkit.render({ + body: renderUnauthenticatedPage({ buildNumber, basePath: http.basePath, originalURL }), + headers: { 'Content-Security-Policy': http.csp.header }, + }); + } + + const needsToLogout = (await this.session?.getSID(request)) !== undefined; + if (needsToLogout) { + this.logger.warn('Could not authenticate user with the existing session. Forcing logout.'); + } + + return toolkit.render({ + body: '
', + headers: { + 'Content-Security-Policy': http.csp.header, + Refresh: `0;url=${http.basePath.prepend( + `${ + needsToLogout ? '/logout' : '/login' + }?msg=UNAUTHENTICATED&${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + originalURL + )}` + )}`, + }, + }); + }); } start({ @@ -161,6 +222,7 @@ export class AuthenticationService { const getCurrentUser = (request: KibanaRequest) => http.auth.get(request).state ?? null; + this.session = session; this.authenticator = new Authenticator({ legacyAuditLogger, audit, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 1bd430d0c5c98..ca33be92e9e99 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -20,6 +20,10 @@ import { loggingSystemMock, } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../common/constants'; import type { SecurityLicenseFeatures } from '../../common/licensing'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; @@ -1780,13 +1784,13 @@ describe('Authenticator', () => { ); }); - it('returns `notHandled` if session does not exist.', async () => { + it('redirects to login form if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); @@ -1843,12 +1847,12 @@ describe('Authenticator', () => { expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); }); - it('returns `notHandled` if session does not exist and provider name is invalid', async () => { + it('redirects to login form if session does not exist and provider name is invalid', async () => { const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); mockOptions.session.get.mockResolvedValue(null); await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); @@ -1937,4 +1941,64 @@ describe('Authenticator', () => { ); }); }); + + describe('`getRequestOriginalURL` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + beforeEach(() => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + authenticator = new Authenticator(mockOptions); + }); + + it('filters out auth specific query parameters', () => { + expect(authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest())).toBe( + '/mock-server-basepath/path' + ); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + }, + }) + ) + ).toBe('/mock-server-basepath/path'); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash', + }, + }) + ) + ).toBe('/mock-server-basepath/path'); + }); + + it('allows to include additional query parameters', () => { + expect( + authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest(), [ + ['some-param', 'some-value'], + ['some-param2', 'some-value2'], + ]) + ).toBe('/mock-server-basepath/path?some-param=some-value&some-param2=some-value2'); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash', + }, + }), + [ + ['some-param', 'some-value'], + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc1'], + ] + ) + ).toBe('/mock-server-basepath/path?some-param=some-value&auth_provider_hint=oidc1'); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index f86ff54963da9..4eeadf23c50b2 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -11,6 +11,7 @@ import type { IBasePath, IClusterClient, LoggerFactory } from 'src/core/server'; import { KibanaRequest } from '../../../../../src/core/server'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, LOGOUT_PROVIDER_QUERY_STRING_PARAMETER, LOGOUT_REASON_QUERY_STRING_PARAMETER, NEXT_URL_QUERY_STRING_PARAMETER, @@ -45,6 +46,15 @@ import { } from './providers'; import { Tokens } from './tokens'; +/** + * List of query string parameters used to pass various authentication related metadata that should + * be stripped away from URL as soon as they are no longer needed. + */ +const AUTH_METADATA_QUERY_STRING_PARAMETERS = [ + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +]; + /** * The shape of the login attempt. */ @@ -201,6 +211,7 @@ export class Authenticator { const providerCommonOptions = { client: this.options.clusterClient, basePath: this.options.basePath, + getRequestOriginalURL: this.getRequestOriginalURL.bind(this), tokens: new Tokens({ client: this.options.clusterClient.asInternalUser, logger: this.options.loggers.get('tokens'), @@ -419,7 +430,9 @@ export class Authenticator { } } - return DeauthenticationResult.notHandled(); + // If none of the configured providers could perform a logout, we should redirect user to the + // default logout location. + return DeauthenticationResult.redirectTo(this.getLoggedOutURL(request)); } /** @@ -452,6 +465,24 @@ export class Authenticator { this.options.featureUsageService.recordPreAccessAgreementUsage(); } + getRequestOriginalURL( + request: KibanaRequest, + additionalQueryStringParameters?: Array<[string, string]> + ) { + const originalURLSearchParams = [ + ...[...request.url.searchParams.entries()].filter( + ([key]) => !AUTH_METADATA_QUERY_STRING_PARAMETERS.includes(key) + ), + ...(additionalQueryStringParameters ?? []), + ]; + + return `${this.options.basePath.get(request)}${request.url.pathname}${ + originalURLSearchParams.length > 0 + ? `?${new URLSearchParams(originalURLSearchParams).toString()}` + : '' + }`; + } + /** * Initializes HTTP Authentication provider and appends it to the end of the list of enabled * authentication providers. @@ -762,9 +793,13 @@ export class Authenticator { /** * Creates a logged out URL for the specified request and provider. * @param request Request that initiated logout. - * @param providerType Type of the provider that handles logout. + * @param providerType Type of the provider that handles logout. If not specified, then the first + * provider in the chain (default) is assumed. */ - private getLoggedOutURL(request: KibanaRequest, providerType: string) { + private getLoggedOutURL( + request: KibanaRequest, + providerType: string = this.options.config.authc.sortedProviders[0].type + ) { // The app that handles logout needs to know the reason of the logout and the URL we may need to // redirect user to once they log in again (e.g. when session expires). const searchParams = new URLSearchParams(); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index 1507cd2d3a50a..805d647757ca5 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -7,6 +7,7 @@ import { httpServerMock } from 'src/core/server/mocks'; +import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; import { canRedirectRequest } from './can_redirect_request'; describe('can_redirect_request', () => { @@ -24,4 +25,33 @@ describe('can_redirect_request', () => { expect(canRedirectRequest(request)).toBe(false); }); + + it('returns false for api routes', () => { + expect( + canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/api/security/some' })) + ).toBe(false); + }); + + it('returns false for internal routes', () => { + expect( + canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/internal/security/some' })) + ).toBe(false); + }); + + it('returns true for the routes with the `security:canRedirect` tag', () => { + for (const request of [ + httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_CAN_REDIRECT] }), + httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT] }), + httpServerMock.createKibanaRequest({ + path: '/api/security/some', + routeTags: [ROUTE_TAG_CAN_REDIRECT], + }), + httpServerMock.createKibanaRequest({ + path: '/internal/security/some', + routeTags: [ROUTE_TAG_CAN_REDIRECT], + }), + ]) { + expect(canRedirectRequest(request)).toBe(true); + } + }); }); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts index 71c6365d9aea4..5a3a09f17eb86 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -7,7 +7,8 @@ import type { KibanaRequest } from 'src/core/server'; -const ROUTE_TAG_API = 'api'; +import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; + const KIBANA_XSRF_HEADER = 'kbn-xsrf'; const KIBANA_VERSION_HEADER = 'kbn-version'; @@ -24,9 +25,9 @@ export function canRedirectRequest(request: KibanaRequest) { const isApiRoute = route.options.tags.includes(ROUTE_TAG_API) || - (route.path.startsWith('/api/') && route.path !== '/api/security/logout') || + route.path.startsWith('/api/') || route.path.startsWith('/internal/'); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; - return !isApiRoute && !isAjaxRequest; + return !isAjaxRequest && (!isApiRoute || route.options.tags.includes(ROUTE_TAG_CAN_REDIRECT)); } diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index bb78b6e963763..5d3417ae9db11 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -20,6 +20,7 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) { client: elasticsearchServiceMock.createClusterClient(), logger: loggingSystemMock.create().get(), basePath: httpServiceMock.createBasePath(), + getRequestOriginalURL: jest.fn(), tokens: { refresh: jest.fn(), invalidate: jest.fn() }, name: options?.name ?? 'basic1', urls: { diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 18d567a143fee..c7c0edcf1e9e1 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -27,6 +27,10 @@ import type { Tokens } from '../tokens'; export interface AuthenticationProviderOptions { name: string; basePath: HttpServiceSetup['basePath']; + getRequestOriginalURL: ( + request: KibanaRequest, + additionalQueryStringParameters?: Array<[string, string]> + ) => string; client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index ebeca42682eb9..444a7f3e50a25 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -11,6 +11,10 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -376,18 +380,78 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); await expect(provider.authenticate(request, null)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'], + ]); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); + it('initiates OIDC handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path'); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path', + query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' }, + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment', + realm: 'oidc1', + }, + } + ) + ); + + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request); + + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', + body: { realm: 'oidc1' }, + }); + }); + it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { @@ -520,6 +584,9 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX requests to the "capture URL" page if refresh token is expired or already refreshed.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; @@ -534,11 +601,16 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'], + ]); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 2afa49fe6e082..83f0ec50abb0d 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,8 +10,13 @@ import type from 'type-detect'; import type { KibanaRequest } from 'src/core/server'; -import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, + NEXT_URL_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -201,7 +206,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.captureRedirectURL(request) + ? await this.initiateAuthenticationHandshake(request) : authenticationResult; } @@ -264,7 +269,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }) ).body as any; } catch (err) { - this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via OpenID Connect: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } @@ -313,7 +320,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { state: { state, nonce, redirectURL, realm: this.realm } } ); } catch (err) { - this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + this.logger.debug( + `Failed to initiate OpenID Connect authentication: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -341,7 +350,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via state: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -379,7 +390,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); - return this.captureRedirectURL(request); + return this.initiateAuthenticationHandshake(request); } return AuthenticationResult.failed( @@ -440,7 +451,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`); return DeauthenticationResult.failed(err); } } @@ -457,22 +468,29 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake. + * Tries to initiate OIDC authentication handshake. If the request already includes user URL hash fragment, we will + * initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment + * first and only then initiate SAML handshake. * @param request Request instance. */ - private captureRedirectURL(request: KibanaRequest) { - const searchParams = new URLSearchParams([ - [ - NEXT_URL_QUERY_STRING_PARAMETER, - `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`, - ], - ['providerType', this.type], - ['providerName', this.options.name], - ]); + private initiateAuthenticationHandshake(request: KibanaRequest) { + const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); + if (originalURLHash != null) { + return this.initiateOIDCAuthentication( + request, + { realm: this.realm }, + `${this.options.getRequestOriginalURL(request)}${originalURLHash}` + ); + } + return AuthenticationResult.redirectTo( `${ this.options.basePath.serverBasePath - }/internal/security/capture-url?${searchParams.toString()}`, + }/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + this.options.getRequestOriginalURL(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name], + ]) + )}`, // Here we indicate that current session, if any, should be invalidated. It is a no-op for the // initial handshake, but is essential when both access and refresh tokens are expired. { state: null } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index bd51a0f815329..dfcdb66e61c35 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -10,6 +10,10 @@ import Boom from '@hapi/boom'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -848,18 +852,63 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'], + ]); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); + it('initiates SAML handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path'); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path', + query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' }, + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment', + realm: 'test-realm', + }, + } + ) + ); + + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request); + + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', + body: { realm: 'test-realm' }, + }); + }); + it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { @@ -1024,6 +1073,9 @@ describe('SAMLAuthenticationProvider', () => { }); it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { accessToken: 'expired-token', @@ -1040,11 +1092,16 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'], + ]); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 7c27e2ebeff10..ea818e5df6e12 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -9,9 +9,14 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; -import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, + NEXT_URL_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { isInternalURL } from '../../../common/is_internal_url'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -185,7 +190,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } else { this.logger.debug( `Failed to perform a login: ${ - authenticationResult.error && authenticationResult.error.message + authenticationResult.error && getDetailedErrorMessage(authenticationResult.error) }` ); } @@ -230,7 +235,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to capture user URL and // initiate SAML handshake, otherwise just return authentication result we have. return authenticationResult.notHandled() && canStartNewSession(request) - ? this.captureRedirectURL(request) + ? this.initiateAuthenticationHandshake(request) : authenticationResult; } @@ -283,7 +288,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`); return DeauthenticationResult.failed(err); } } @@ -362,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }) ).body as any; } catch (err) { - this.logger.debug(`Failed to log in with SAML response: ${err.message}`); + this.logger.debug(`Failed to log in with SAML response: ${getDetailedErrorMessage(err)}`); // Since we don't know upfront what realm is targeted by the Identity Provider initiated login // there is a chance that it failed because of realm mismatch and hence we should return @@ -452,7 +457,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { refreshToken: existingState.refreshToken!, }); } catch (err) { - this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`); + this.logger.debug( + `Failed to perform IdP initiated local logout: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } @@ -483,7 +490,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via state: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -520,7 +529,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.' ); - return this.captureRedirectURL(request); + return this.initiateAuthenticationHandshake(request); } return AuthenticationResult.failed( @@ -569,7 +578,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { state: { requestId, redirectURL, realm: this.realm }, }); } catch (err) { - this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); + this.logger.debug(`Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`); return AuthenticationResult.failed(err); } } @@ -629,22 +638,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. + * Tries to initiate SAML authentication handshake. If the request already includes user URL hash fragment, we will + * initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment + * first and only then initiate SAML handshake. * @param request Request instance. */ - private captureRedirectURL(request: KibanaRequest) { - const searchParams = new URLSearchParams([ - [ - NEXT_URL_QUERY_STRING_PARAMETER, - `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`, - ], - ['providerType', this.type], - ['providerName', this.options.name], - ]); + private initiateAuthenticationHandshake(request: KibanaRequest) { + const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); + if (originalURLHash != null) { + return this.authenticateViaHandshake( + request, + `${this.options.getRequestOriginalURL(request)}${originalURLHash}` + ); + } + return AuthenticationResult.redirectTo( `${ this.options.basePath.serverBasePath - }/internal/security/capture-url?${searchParams.toString()}`, + }/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + this.options.getRequestOriginalURL(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name], + ]) + )}`, // Here we indicate that current session, if any, should be invalidated. It is a no-op for the // initial handshake, but is essential when both access and refresh tokens are expired. { state: null } diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index 8f6dd9275e59c..1adbb2dc66533 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -8,7 +8,7 @@ import type { ElasticsearchClient, Logger } from 'src/core/server'; import type { AuthenticationInfo } from '../elasticsearch'; -import { getErrorStatusCode } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; /** * Represents a pair of access and refresh tokens. @@ -73,11 +73,11 @@ export class Tokens { return { accessToken, refreshToken, - // @ts-expect-error @elastic/elasticsearch decalred GetUserAccessTokenResponse.authentication: string + // @ts-expect-error @elastic/elasticsearch declared GetUserAccessTokenResponse.authentication: string authenticationInfo: authenticationInfo as AuthenticationInfo, }; } catch (err) { - this.logger.debug(`Failed to refresh access token: ${err.message}`); + this.logger.debug(`Failed to refresh access token: ${getDetailedErrorMessage(err)}`); // There are at least two common cases when refresh token request can fail: // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. @@ -123,7 +123,7 @@ export class Tokens { }) ).body.invalidated_tokens; } catch (err) { - this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); + this.logger.debug(`Failed to invalidate refresh token: ${getDetailedErrorMessage(err)}`); // When using already deleted refresh token, Elasticsearch responds with 404 and a body that // shows that no tokens were invalidated. @@ -155,7 +155,7 @@ export class Tokens { }) ).body.invalidated_tokens; } catch (err) { - this.logger.debug(`Failed to invalidate access token: ${err.message}`); + this.logger.debug(`Failed to invalidate access token: ${getDetailedErrorMessage(err)}`); // When using already deleted access token, Elasticsearch responds with 404 and a body that // shows that no tokens were invalidated. diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx new file mode 100644 index 0000000000000..5cb6c899d7560 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx @@ -0,0 +1,35 @@ +/* + * 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 React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { coreMock } from '../../../../../src/core/server/mocks'; +import { UnauthenticatedPage } from './unauthenticated_page'; + +jest.mock('src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('UnauthenticatedPage', () => { + it('renders as expected', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx new file mode 100644 index 0000000000000..48d61a72e085d --- /dev/null +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-expect-error no definitions in component folder +import { EuiButton } from '@elastic/eui/lib/components/button'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IBasePath } from 'src/core/server'; + +import { PromptPage } from '../prompt_page'; + +interface Props { + originalURL: string; + buildNumber: number; + basePath: IBasePath; +} + +export function UnauthenticatedPage({ basePath, originalURL, buildNumber }: Props) { + return ( + + +

+ } + actions={[ + + + , + ]} + /> + ); +} + +export function renderUnauthenticatedPage(props: Props) { + return renderToStaticMarkup(); +} diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index 785c57490e8ef..1011d82eb1f73 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx index db3c84477ffb1..144a8bc5fd0c4 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.tsx +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import type { Observable, Subscription } from 'rxjs'; -import * as UiSharedDeps from '@kbn/ui-shared-deps'; import type { CapabilitiesSetup, HttpServiceSetup, @@ -163,25 +162,14 @@ export class AuthorizationService { http.registerOnPreResponse((request, preResponse, toolkit) => { if (preResponse.statusCode === 403 && canRedirectRequest(request)) { - const basePath = http.basePath.get(request); - const next = `${basePath}${request.url.pathname}${request.url.search}`; - const regularBundlePath = `${basePath}/${buildNumber}/bundles`; - - const logoutUrl = http.basePath.prepend( - `/api/security/logout?${querystring.stringify({ next })}` - ); - const styleSheetPaths = [ - `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, - `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, - `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/ui/legacy_light_theme.css`, - ]; - + const next = `${http.basePath.get(request)}${request.url.pathname}${request.url.search}`; const body = renderToStaticMarkup( ); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx index e76c8ff138fcb..d5e27c9d39ffd 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { coreMock } from '../../../../../src/core/server/mocks'; import { ResetSessionPage } from './reset_session_page'; jest.mock('src/core/server/rendering/views/fonts', () => ({ @@ -16,11 +17,16 @@ jest.mock('src/core/server/rendering/views/fonts', () => ({ describe('ResetSessionPage', () => { it('renders as expected', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + const body = renderToStaticMarkup( ); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx index c2d43cd3dd030..4e2e6f4631287 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -7,101 +7,53 @@ // @ts-expect-error no definitions in component folder import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; -// @ts-expect-error no definitions in component folder -import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; -// @ts-expect-error no definitions in component folder -import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; -// @ts-expect-error no definitions in component folder -import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; -// @ts-expect-error no definitions in component folder -import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; - -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Fonts } from '../../../../../src/core/server/rendering/views/fonts'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IBasePath } from 'src/core/server'; -// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded -// in advance the first time this page is rendered server-side. If not, the -// icon svg wouldn't contain any paths the first time the page was rendered. -appendIconComponentCache({ - alert: EuiIconAlert, -}); +import { PromptPage } from '../prompt_page'; export function ResetSessionPage({ logoutUrl, - styleSheetPaths, + buildNumber, basePath, }: { logoutUrl: string; - styleSheetPaths: string[]; - basePath: string; + buildNumber: number; + basePath: IBasePath; }) { - const uiPublicUrl = `${basePath}/ui`; return ( - - - {styleSheetPaths.map((path) => ( - - ))} - - {/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */} - - - - - - - - - - - - - - } - body={ -

- -

- } - actions={[ - - - , - - - , - ]} - /> -
-
-
-
- - + + +

+ } + actions={[ + + + , + + + , + ]} + /> ); } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index b66ed6e9eb7ca..087cf8f4f8ee8 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -30,6 +30,7 @@ export type { CheckPrivilegesPayload } from './authorization'; export { LegacyAuditLogger, AuditLogger, AuditEvent } from './audit'; export type { SecurityPluginSetup, SecurityPluginStart }; export type { AuthenticatedUser } from '../common/model'; +export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index 07f60ceb890f1..c30fcd8b69604 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -8,6 +8,8 @@ import type { ApiResponse } from '@elastic/elasticsearch'; import { licenseMock } from '../common/licensing/index.mock'; +import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; +import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; import { auditServiceMock } from './audit/index.mock'; import { authenticationServiceMock } from './authentication/authentication_service.mock'; import { authorizationMock } from './authorization/index.mock'; @@ -62,4 +64,6 @@ export const securityMock = { createSetup: createSetupMock, createStart: createStartMock, createApiResponse: createApiResponseMock, + createMockAuthenticatedUser: (props: MockAuthenticatedUserProps = {}) => + mockAuthenticatedUser(props), }; diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 586707dd8c9aa..57be308525fdd 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -246,7 +246,12 @@ export class SecurityPlugin this.elasticsearchService.setup({ license, status: core.status }); this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); this.sessionManagementService.setup({ config, http: core.http, taskManager }); - this.authenticationService.setup({ http: core.http, license }); + this.authenticationService.setup({ + http: core.http, + config, + license, + buildNumber: this.initializerContext.env.packageInfo.buildNum, + }); registerSecurityUsageCollector({ usageCollection, config, license }); diff --git a/x-pack/plugins/security/server/prompt_page.test.tsx b/x-pack/plugins/security/server/prompt_page.test.tsx new file mode 100644 index 0000000000000..01c4488576f57 --- /dev/null +++ b/x-pack/plugins/security/server/prompt_page.test.tsx @@ -0,0 +1,57 @@ +/* + * 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 React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { coreMock } from '../../../../src/core/server/mocks'; +import { PromptPage } from './prompt_page'; + +jest.mock('src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('PromptPage', () => { + it('renders as expected without additional scripts', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + Some Body
} + actions={[Action#1, Action#2]} + /> + ); + + expect(body).toMatchSnapshot(); + }); + + it('renders as expected with additional scripts', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + Some Body
} + actions={[Action#1, Action#2]} + /> + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/prompt_page.tsx b/x-pack/plugins/security/server/prompt_page.tsx new file mode 100644 index 0000000000000..338d39b29e534 --- /dev/null +++ b/x-pack/plugins/security/server/prompt_page.tsx @@ -0,0 +1,96 @@ +/* + * 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. + */ + +// @ts-expect-error no definitions in component folder +import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; +// @ts-expect-error no definitions in component folder +import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; +// @ts-expect-error no definitions in component folder +import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; +// @ts-expect-error no definitions in component folder +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; +import type { IBasePath } from 'src/core/server'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Fonts } from '../../../../src/core/server/rendering/views/fonts'; + +// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded +// in advance the first time this page is rendered server-side. If not, the +// icon svg wouldn't contain any paths the first time the page was rendered. +appendIconComponentCache({ + alert: EuiIconAlert, +}); + +interface Props { + buildNumber: number; + basePath: IBasePath; + scriptPaths?: string[]; + title: ReactNode; + body: ReactNode; + actions: ReactNode; +} + +export function PromptPage({ + basePath, + buildNumber, + scriptPaths = [], + title, + body, + actions, +}: Props) { + const uiPublicURL = `${basePath.serverBasePath}/ui`; + const regularBundlePath = `${basePath.serverBasePath}/${buildNumber}/bundles`; + const styleSheetPaths = [ + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + `${basePath.serverBasePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath.serverBasePath}/ui/legacy_light_theme.css`, + ]; + + return ( + + + Elastic + {styleSheetPaths.map((path) => ( + + ))} + + {/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */} + + + {scriptPaths.map((path) => ( +