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/dev_docs/tutorials/data/search.mdx b/dev_docs/tutorials/data/search.mdx index 69b4d5dab58b5..9cf46bb96c72a 100644 --- a/dev_docs/tutorials/data/search.mdx +++ b/dev_docs/tutorials/data/search.mdx @@ -355,7 +355,7 @@ export class SearchEmbeddable this.updateOutput({ loading: true, error: undefined }); // Make the request, wait for the final result - const resp = await searchSource.fetch$({ + const {rawResponse: resp} = await searchSource.fetch$({ sessionId: searchSessionId, }).toPromise(); 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/apm/apm-app-users.asciidoc b/docs/apm/apm-app-users.asciidoc index 3f0a42251304c..9b8a9c64ac43b 100644 --- a/docs/apm/apm-app-users.asciidoc +++ b/docs/apm/apm-app-users.asciidoc @@ -10,7 +10,7 @@ Users and privileges ++++ -You can use role-based access control to grant users access to secured +Use role-based access control to grant users access to secured resources. The roles that you set up depend on your organization's security requirements and the minimum privileges required to use specific features. @@ -24,6 +24,13 @@ In general, there are three types of privileges you'll work with: * **Elasticsearch index privileges**: Control access to the data in specific indices your cluster. * **Kibana space privileges**: Grant users write or read access to features and apps within Kibana. +Select your use-case to get started: + +* <> +* <> +* <> +* <> + //// *********************************** *********************************** //// @@ -36,13 +43,25 @@ In general, there are three types of privileges you'll work with: Create an APM reader user ++++ +APM reader users typically need to view the APM app and dashboards and visualizations that use APM data. +These users might also need to create and edit dashboards, visualizations, and machine learning jobs. + [[apm-app-reader-full]] -==== Full APM reader +==== APM reader -APM reader users typically need to view the APM app, dashboards, and visualizations that contain APM data. -These users might also need to create and edit dashboards, visualizations, and machine learning jobs. +To create an APM reader user: + +. Create a new role, named something like `read-apm`, and assign the following privileges: ++ +-- +include::./tab-widgets/apm-app-reader/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following built-in roles: +. Assign the `read-apm` role created in the previous step, and the following built-in roles to +any APM reader users: + [options="header"] |==== @@ -51,9 +70,6 @@ These users might also need to create and edit dashboards, visualizations, and m |`kibana_admin` |Grants access to all features in Kibana. -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices - |`machine_learning_admin` |Grants the privileges required to create, update, and view machine learning jobs |==== @@ -63,14 +79,14 @@ These users might also need to create and edit dashboards, visualizations, and m In some instances, you may wish to restrict certain Kibana apps that a user has access to. -. Assign the following built in roles: +. Create a new role, named something like `read-apm-partial`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/apm-app-reader/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. . Assign space privileges to any Kibana space that the user needs access to. Here are two examples: @@ -98,6 +114,8 @@ Here are two examples: |Grants the privileges required to create, update, and view machine learning jobs |==== +include::./tab-widgets/code.asciidoc[] + //// *********************************** *********************************** //// @@ -138,7 +156,7 @@ and assign the following privileges: ^1^ +\{ANNOTATION_INDEX\}+ should be the index name you've defined in <>. -. Assign the `annotation_user` created previously, and the built-in roles necessary to create +. Assign the `annotation_user` created previously, and the roles and privileges necessary to create a <> or <> APM reader to any users that need to view annotations in the APM app [[apm-app-annotation-api]] @@ -163,17 +181,17 @@ See <>. Central configuration users need to be able to view, create, update, and delete Agent configurations. -. Assign the following built-in roles: +. Create a new role, named something like `central-config-manager`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose - -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/central-config-users/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following Kibana space privileges: +. Assign the `central-config-manager` role created in the previous step, and the following Kibana space privileges to +anyone who needs to manage central configurations: + [options="header"] |==== @@ -190,16 +208,17 @@ Central configuration users need to be able to view, create, update, and delete In some instances, you may wish to create a user that can only read central configurations, but not create, update, or delete them. -. Assign the following built-in roles: +. Create a new role, named something like `central-config-reader`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/central-config-users/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following Kibana space privileges: +. Assign the `central-config-reader` role created in the previous step, and the following Kibana space privileges to +anyone who needs to read central configurations: + [options="header"] |==== @@ -215,6 +234,8 @@ but not create, update, or delete them. See <>. +include::./tab-widgets/code.asciidoc[] + //// *********************************** *********************************** //// diff --git a/docs/apm/tab-widgets/apm-app-reader/content.asciidoc b/docs/apm/tab-widgets/apm-app-reader/content.asciidoc new file mode 100644 index 0000000000000..6b9c996035f6c --- /dev/null +++ b/docs/apm/tab-widgets/apm-app-reader/content.asciidoc @@ -0,0 +1,45 @@ +// tag::classic-indices[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-*` +|Read-only access to `apm-*` data + +|Index +|`view_index_metadata` on `apm-*` +|Read-only access to `apm-*` index metadata +|==== +// end::classic-indices[] + +// tag::data-streams[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `logs-apm*` +|Read-only access to `logs-apm*` data + +|Index +|`view_index_metadata` on `logs-apm*` +|Read-only access to `logs-apm*` index metadata + +|Index +|`read` on `metrics-apm*` +|Read-only access to `metrics-apm*` data + +|Index +|`view_index_metadata` on `metrics-apm*` +|Read-only access to `metrics-apm*` index metadata + +|Index +|`read` on `traces-apm*` +|Read-only access to `traces-apm*` data + +|Index +|`view_index_metadata` on `traces-apm*` +|Read-only access to `traces-apm*` index metadata +|==== +// end::data-streams[] diff --git a/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc b/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc new file mode 100644 index 0000000000000..51c01367786b6 --- /dev/null +++ b/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc @@ -0,0 +1,40 @@ +++++ +
+
+ + +
+
+++++ + +include::content.asciidoc[tag=classic-indices] + +++++ +
+ +
+++++ \ No newline at end of file diff --git a/docs/apm/tab-widgets/central-config-users/content.asciidoc b/docs/apm/tab-widgets/central-config-users/content.asciidoc new file mode 100644 index 0000000000000..0945050d9a861 --- /dev/null +++ b/docs/apm/tab-widgets/central-config-users/content.asciidoc @@ -0,0 +1,53 @@ +// tag::classic-indices[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-*` +|Read-only access to `apm-*` data + +|Index +|`view_index_metadata` on `apm-*` +|Read-only access to `apm-*` index metadata +|==== +// end::classic-indices[] + +// tag::data-streams[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-agent-configuration` +|Read-only access to `apm-agent-configuration` data + +|Index +|`view_index_metadata` on `apm-agent-configuration` +|Read-only access to `apm-agent-configuration` index metadata + +|Index +|`read` on `logs-apm*` +|Read-only access to `logs-apm*` data + +|Index +|`view_index_metadata` on `logs-apm*` +|Read-only access to `logs-apm*` index metadata + +|Index +|`read` on `metrics-apm*` +|Read-only access to `metrics-apm*` data + +|Index +|`view_index_metadata` on `metrics-apm*` +|Read-only access to `metrics-apm*` index metadata + +|Index +|`read` on `traces-apm*` +|Read-only access to `traces-apm*` data + +|Index +|`view_index_metadata` on `traces-apm*` +|Read-only access to `traces-apm*` index metadata +|==== +// end::data-streams[] diff --git a/docs/apm/tab-widgets/central-config-users/widget.asciidoc b/docs/apm/tab-widgets/central-config-users/widget.asciidoc new file mode 100644 index 0000000000000..68bef4e50c549 --- /dev/null +++ b/docs/apm/tab-widgets/central-config-users/widget.asciidoc @@ -0,0 +1,40 @@ +++++ +
+
+ + +
+
+++++ + +include::content.asciidoc[tag=classic-indices] + +++++ +
+ +
+++++ \ No newline at end of file diff --git a/docs/apm/tab-widgets/code.asciidoc b/docs/apm/tab-widgets/code.asciidoc new file mode 100644 index 0000000000000..6a30cf55c8dbb --- /dev/null +++ b/docs/apm/tab-widgets/code.asciidoc @@ -0,0 +1,166 @@ +// Defining styles and script here for simplicity. +++++ + + + +++++ \ No newline at end of file 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 217bb03549343..dcfe317e5c826 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -63,10 +63,13 @@ yarn kbn watch-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/logging - @kbn/std - @kbn/tinymath - @kbn/utility-types 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.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/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index 193a2e5a24f3f..5fffc5436e9c6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "isClearable" | "refreshInterval" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "placeholder" | "isLoading" | "iconType" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "isClearable" | "nonKqlMode" | "nonKqlModeHelpText" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md deleted file mode 100644 index 984f99004ebe8..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getSerializableOptions](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) - -## SearchInterceptor.getSerializableOptions() method - -Signature: - -```typescript -protected getSerializableOptions(options?: ISearchOptions): Pick; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| options | ISearchOptions | | - -Returns: - -`Pick` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md deleted file mode 100644 index 8ecd8b8c5ac22..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getTimeoutMode](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) - -## SearchInterceptor.getTimeoutMode() method - -Signature: - -```typescript -protected getTimeoutMode(): TimeoutErrorMode; -``` -Returns: - -`TimeoutErrorMode` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md deleted file mode 100644 index f6421d65bc551..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [handleSearchError](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) - -## SearchInterceptor.handleSearchError() method - -Signature: - -```typescript -protected handleSearchError(e: KibanaServerError | AbortError, options?: ISearchOptions, isTimeout?: boolean): Error; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| e | KibanaServerError | AbortError | | -| options | ISearchOptions | | -| isTimeout | boolean | | - -Returns: - -`Error` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 653f052dd5a3a..e1b27f45c6339 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -16,19 +16,11 @@ export declare class SearchInterceptor | --- | --- | --- | | [(constructor)(deps)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | Constructs a new instance of the SearchInterceptor class | -## Properties - -| Property | Modifiers | Type | Description | -| --- | --- | --- | --- | -| [deps](./kibana-plugin-plugins-data-public.searchinterceptor.deps.md) | | SearchInterceptorDeps | | - ## Methods | Method | Modifiers | Description | | --- | --- | --- | -| [getSerializableOptions(options)](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) | | | -| [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | -| [handleSearchError(e, options, isTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | -| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | +| [search({ id, ...request }, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | +| [stop()](./kibana-plugin-plugins-data-public.searchinterceptor.stop.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md index a54b43da4add8..ab5ecbac119f3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -9,19 +9,19 @@ Searches using the given `search` method. Overrides the `AbortSignal` with one t Signature: ```typescript -search(request: IKibanaSearchRequest, options?: ISearchOptions): Observable; +search({ id, ...request }: IKibanaSearchRequest, options?: IAsyncSearchOptions): import("rxjs").Observable>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| request | IKibanaSearchRequest | | -| options | ISearchOptions | | +| { id, ...request } | IKibanaSearchRequest | | +| options | IAsyncSearchOptions | | Returns: -`Observable` +`import("rxjs").Observable>` `Observable` emitting the search response or an error. diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.deps.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.stop.md similarity index 54% rename from docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.deps.md rename to docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.stop.md index b517fb036798a..e0d77c5d13537 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.deps.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.stop.md @@ -1,11 +1,15 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [deps](./kibana-plugin-plugins-data-public.searchinterceptor.deps.md) +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [stop](./kibana-plugin-plugins-data-public.searchinterceptor.stop.md) -## SearchInterceptor.deps property +## SearchInterceptor.stop() method Signature: ```typescript -protected readonly deps: SearchInterceptorDeps; +stop(): void; ``` +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index 4369cf7c087da..8bc4b7606ab51 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): Observable>; +fetch$(options?: ISearchOptions): Observable>>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): Observable>; Returns: -`Observable>` +`Observable>>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md index 8ad63e2c1e9b4..bbf789b7dcf26 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timeouterrormode.md @@ -14,7 +14,6 @@ export declare enum TimeoutErrorMode | Member | Value | Description | | --- | --- | --- | -| CHANGE | 2 | | -| CONTACT | 1 | | -| UPGRADE | 0 | | +| CHANGE | 1 | | +| CONTACT | 0 | | diff --git a/docs/maps/connect-to-ems.asciidoc b/docs/maps/connect-to-ems.asciidoc index 8e4695bfc6662..88301123bae3f 100644 --- a/docs/maps/connect-to-ems.asciidoc +++ b/docs/maps/connect-to-ems.asciidoc @@ -40,11 +40,9 @@ To disable EMS, change your <> file. [id=elastic-maps-server] === Host Elastic Maps Service locally -beta::[] - If you cannot connect to Elastic Maps Service from the {kib} server or browser clients, and your cluster has the appropriate license level, you can opt to host the service on your own infrastructure. -{hosted-ems} is a self-managed version of Elastic Maps Service offered as a Docker image that provides both the EMS basemaps and EMS boundaries. You must first download and run the image. After connecting it to your {es} cluster for license validation, you're guided to download and configure the basemaps database, which must be retrieved separately. +{hosted-ems} is a self-managed version of Elastic Maps Service offered as a Docker image that provides both the EMS basemaps and EMS boundaries. The image is bundled with basemaps up to zoom level 8. After connecting it to your {es} cluster for license validation, you have the option to download and configure a more detailed basemaps database. IMPORTANT: {hosted-ems} does not serve raster tiles, needed by Vega, coordinate, and region map visualizations. @@ -69,7 +67,7 @@ docker run --rm --init --publish 8080:8080 \ {ems-docker-image} ---------------------------------- -Once {hosted-ems} is running, follow instructions from the webpage at `localhost:8080` to define a configuration file and download the basemaps database. +Once {hosted-ems} is running, follow instructions from the webpage at `localhost:8080` to define a configuration file and optionally download a more detailed basemaps database. [role="screenshot"] image::images/elastic-maps-server-instructions.png[Set-up instructions] @@ -92,6 +90,9 @@ endif::[] | `port` | Specifies the port used by the backend server. Default: *`8080`*. <>. +| `basePath` + | Specify a path at which to mount the server if you are running behind a proxy. This setting cannot end in a slash (`/`). <>. + | `ui` | Controls the display of the status page and the layer preview. *Default: `true`* @@ -190,9 +191,13 @@ services: [[elastic-maps-server-data]] ==== Data -{hosted-ems} hosts vector layer boundaries and vector tile basemaps for the entire planet. Boundaries include world countries, global administrative regions, and specific country regions. A minimal basemap is provided with {hosted-ems}. This can be used for testing environments but won't be functional for normal operations. The full basemap (around 90GB file) needs to be mounted on the Docker container for {hosted-ems} to run normally. +{hosted-ems} hosts vector layer boundaries and vector tile basemaps for the entire planet. Boundaries include world countries, global administrative regions, and specific country regions. Basemaps up to zoom level 8 are bundled in the Docker image. These basemaps are sufficient for maps and dashboards at the country level. To present maps with higher detail, follow the instructions of the front page to download and configure the appropriate basemaps database. The most detailed basemaps at zoom level 14 are good for street level maps, but require ~90GB of disk space. + + +[role="screenshot"] +image::images/elastic-maps-server-basemaps.png[Basemaps download options] -TIP: The available basemaps and boundaries can be explored from the `/maps` endpoint in a web page that is your self-managed equivalent to https://maps.elastic.co +TIP: The available basemaps and boundaries can be explored from the `/maps` endpoint in a web page that is your self-managed equivalent to https://maps.elastic.co. [float] diff --git a/docs/maps/images/elastic-maps-server-basemaps.png b/docs/maps/images/elastic-maps-server-basemaps.png new file mode 100644 index 0000000000000..3f51153d2394b Binary files /dev/null and b/docs/maps/images/elastic-maps-server-basemaps.png differ diff --git a/docs/maps/images/elastic-maps-server-instructions.png b/docs/maps/images/elastic-maps-server-instructions.png index 17e9163a845c2..5c0b47ce8f49f 100644 Binary files a/docs/maps/images/elastic-maps-server-instructions.png and b/docs/maps/images/elastic-maps-server-instructions.png differ diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c748d63484e28..50ed0d2652c6f 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -47,6 +47,88 @@ You can configure the following settings in the `kibana.yml` file. | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. + +| `xpack.actions.customHostSettings` {ess-icon} + | A list of custom host settings to override existing global settings. + Defaults to an empty list. + + + + Each entry in the list must have a `url` property, to associate a connection + type (mail or https), hostname and port with the remaining options in the + entry. + + + In the following example, two custom host settings + are defined. The first provides a custom host setting for mail server + `mail.example.com` using port 465 that supplies server certificate authorization + data from both a file and inline, and requires TLS for the + connection. The second provides a custom host setting for https server + `webhook.example.com` which turns off server certificate authorization. + +|=== + +[source,yaml] +-- +xpack.actions.customHostSettings: + - url: smtp://mail.example.com:465 + tls: + certificateAuthoritiesFiles: [ 'one.crt' ] + certificateAuthoritiesData: | + -----BEGIN CERTIFICATE----- + ... multiple lines of certificate data here ... + -----END CERTIFICATE----- + smtp: + requireTLS: true + - url: https://webhook.example.com + tls: + rejectUnauthorized: false +-- + +[cols="2*<"] +|=== + +| `xpack.actions.customHostSettings[n]` +`.url` {ess-icon} + | A URL associated with this custom host setting. Should be in the form of + `protocol://hostname:port`, where `protocol` is `https` or `smtp`. If the + port is not provided, 443 is used for `https` and 25 is used for + `smtp`. The `smtp` URLs are used for the Email actions that use this + server, and the `https` URLs are used for actions which use `https` to + connect to services. + + + + Entries with `https` URLs can use the `tls` options, and entries with `smtp` + URLs can use both the `tls` and `smtp` options. + + + + No other URL values should be part of this URL, including paths, + query strings, and authentication information. When an http or smtp request + is made as part of executing an action, only the protocol, hostname, and + port of the URL for that request are used to look up these configuration + values. + +| `xpack.actions.customHostSettings[n]` +`.smtp.ignoreTLS` {ess-icon} + | A boolean value indicating that TLS must not be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.smtp.requireTLS` {ess-icon} + | A boolean value indicating that TLS must be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.tls.rejectUnauthorized` {ess-icon} + | A boolean value indicating whether to bypass server certificate validation. + Overrides the general `xpack.actions.rejectUnauthorized` configuration + for requests made for this hostname/port. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesFiles` + | A file name or list of file names of PEM-encoded certificate files to use + to validate the server. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesData` {ess-icon} + | The contents of a PEM-encoded certificate file, or multiple files appended + into a single string. This configuration can be used for environments where + the files cannot be made available. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + @@ -79,13 +161,18 @@ a|`xpack.actions.` | `xpack.actions.rejectUnauthorized` {ess-icon} | Set to `false` to bypass certificate validation for actions. Defaults to `true`. + + - As an alternative to setting both `xpack.actions.proxyRejectUnauthorizedCertificates` and `xpack.actions.rejectUnauthorized`, you can point the OS level environment variable `NODE_EXTRA_CA_CERTS` to a file that contains the root CAs needed to trust certificates. + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting + `xpack.actions.customHostSettings` to set TLS options for specific servers. | `xpack.actions.maxResponseContentLength` {ess-icon} | Specifies the max number of bytes of the http response for requests to external resources. Defaults to 1000000 (1MB). | `xpack.actions.responseTimeout` {ess-icon} - | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as [ms|s|m|h|d|w|M|Y], for example, '20m', '24h', '7d', '1w'. Defaults to 60s. + | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as: + + + + `[ms,s,m,h,d,w,M,Y]` + + + + For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`. |=== 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/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index f4673d10bc248..6d4a0e9375678 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -53,3 +53,19 @@ Alerting and action tasks are identified by their type. When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. For more details on monitoring and diagnosing task execution in Task Manager, see <>. + +[float] +[[connector-tls-settings]] +=== Connectors have TLS errors when executing actions + +*Problem*: + +When executing actions, a connector gets a TLS socket error when connecting to +the server. + +*Resolution*: + +Configuration options are available to specialize connections to TLS servers, +including ignoring server certificate validation, and providing certificate +authority data to verify servers using custom certificates. For more details, +see <>. 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/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 65d939088515a..c9ede2ff2b45f 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -233,7 +233,7 @@ export const SearchExamplesApp = ({ } setRequest(searchSource.getSearchRequestBody()); - const res = await searchSource.fetch$().toPromise(); + const { rawResponse: res } = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; diff --git a/package.json b/package.json index ef9a82152f987..52e4aaae665bc 100644 --- a/package.json +++ b/package.json @@ -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", @@ -132,7 +132,7 @@ "@kbn/interpreter": "link:packages/kbn-interpreter", "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", - "@kbn/logging": "link:packages/kbn-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", @@ -369,7 +369,7 @@ "semver": "^7.3.2", "set-value": "^3.0.2", "source-map-support": "^0.5.19", - "squel": "^5.13.0", + "safe-squel": "^5.12.5", "stats-lite": "^2.2.0", "strip-ansi": "^6.0.0", "style-it": "^2.1.3", @@ -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", @@ -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", @@ -658,7 +658,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^89.0.0", + "chromedriver": "^90.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -757,7 +757,7 @@ "mochawesome-merge": "^4.2.0", "mock-fs": "^4.12.0", "mock-http-server": "1.3.0", - "ms-chromium-edge-driver": "^0.2.3", + "ms-chromium-edge-driver": "^0.4.2", "multimatch": "^4.0.0", "mutation-observer": "^1.0.3", "ncp": "^2.0.0", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 7f5182e907107..2850a377aaf03 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -5,10 +5,13 @@ filegroup( 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-logging:build", "//packages/kbn-std:build", "//packages/kbn-tinymath:build", "//packages/kbn-utility-types:build", 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 b9dc324ec5e78..c096ed2efb92a 100644 --- a/packages/kbn-apm-config-loader/package.json +++ b/packages/kbn-apm-config-loader/package.json @@ -4,10 +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" - } + "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-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index cc91be0df4550..9ffc7f690fd6a 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -15,7 +15,6 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/logging": "link:../kbn-logging", "@kbn/server-http-tools": "link:../kbn-server-http-tools", "@kbn/optimizer": "link:../kbn-optimizer", "@kbn/dev-utils": "link:../kbn-dev-utils" 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 1611da9aa60d4..90f2a661b91dc 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -9,9 +9,6 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build" }, - "dependencies": { - "@kbn/logging": "link:../kbn-logging" - }, "devDependencies": { "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/utility-types": "link:../kbn-utility-types" 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-logging/BUILD.bazel b/packages/kbn-logging/BUILD.bazel new file mode 100644 index 0000000000000..f42ca22ae5256 --- /dev/null +++ b/packages/kbn-logging/BUILD.bazel @@ -0,0 +1,82 @@ +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-logging" +PKG_REQUIRE_NAME = "@kbn/logging" + +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/kbn-std" +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@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-logging/package.json b/packages/kbn-logging/package.json index 596eda1fe625a..d80cc1c40d7e1 100644 --- a/packages/kbn-logging/package.json +++ b/packages/kbn-logging/package.json @@ -4,10 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", - "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "types": "./target/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-logging/tsconfig.json b/packages/kbn-logging/tsconfig.json index adec4c1966036..78985b823dd95 100644 --- a/packages/kbn-logging/tsconfig.json +++ b/packages/kbn-logging/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-logging/src", "types": [ diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 2a7a02b8e7f2f..95bf3f8f251b7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -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-tinymath/grammar/grammar.peggy b/packages/kbn-tinymath/grammar/grammar.peggy index 70f275776e45d..cbcb0b91bfea9 100644 --- a/packages/kbn-tinymath/grammar/grammar.peggy +++ b/packages/kbn-tinymath/grammar/grammar.peggy @@ -43,7 +43,7 @@ Literal "literal" // Quoted variables are interpreted as strings // but unquoted variables are more restrictive Variable - = _ Quote chars:(ValidChar / Space)* Quote _ { + = _ [\'] chars:(ValidChar / Space / [\"])* [\'] _ { return { type: 'variable', value: chars.join(''), @@ -51,6 +51,14 @@ Variable text: text() }; } + / _ [\"] chars:(ValidChar / Space / [\'])* [\"] _ { + return { + type: 'variable', + value: chars.join(''), + location: simpleLocation(location()), + text: text() + }; + } / _ rest:ValidChar+ _ { return { type: 'variable', @@ -103,10 +111,9 @@ Argument_List "arguments" } String - = [\"] value:(ValidChar)+ [\"] { return value.join(''); } - / [\'] value:(ValidChar)+ [\'] { return value.join(''); } - / value:(ValidChar)+ { return value.join(''); } - + = '"' chars:("\\\"" { return "\""; } / [^"])* '"' { return chars.join(''); } + / "'" chars:("\\\'" { return "\'"; } / [^'])* "'" { return chars.join(''); } + / chars:(ValidChar)+ { return chars.join(''); } Argument = name:[a-zA-Z_]+ _ '=' _ value:(Number / String) _ { diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index 5ddf1b049b8d4..bf1c7a9dbc5fb 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -73,6 +73,7 @@ describe('Parser', () => { expect(parse('"foo bar"')).toEqual(variableEqual('foo bar')); expect(parse('"foo bar fizz buzz"')).toEqual(variableEqual('foo bar fizz buzz')); expect(parse('"foo bar baby"')).toEqual(variableEqual('foo bar baby')); + expect(parse(`"f'oo"`)).toEqual(variableEqual(`f'oo`)); }); it('strings with single quotes', () => { @@ -88,6 +89,7 @@ describe('Parser', () => { expect(parse("' foo bar'")).toEqual(variableEqual(" foo bar")); expect(parse("'foo bar '")).toEqual(variableEqual("foo bar ")); expect(parse("'0foo'")).toEqual(variableEqual("0foo")); + expect(parse(`'f"oo'`)).toEqual(variableEqual(`f"oo`)); /* eslint-enable prettier/prettier */ }); @@ -138,10 +140,18 @@ describe('Parser', () => { ); }); + it('named argument is empty string', () => { + expect(parse('foo(q="")')).toEqual(functionEqual('foo', [namedArgumentEqual('q', '')])); + expect(parse(`foo(q='')`)).toEqual(functionEqual('foo', [namedArgumentEqual('q', '')])); + }); + it('named and positional', () => { expect(parse('foo(ref, q="bar")')).toEqual( functionEqual('foo', [variableEqual('ref'), namedArgumentEqual('q', 'bar')]) ); + expect(parse(`foo(ref, q='ba"r')`)).toEqual( + functionEqual('foo', [variableEqual('ref'), namedArgumentEqual('q', `ba"r`)]) + ); }); it('numerically named', () => { @@ -182,6 +192,21 @@ describe('Parser', () => { it('invalid named', () => { expect(() => parse('foo(offset-type="1d")')).toThrow('but "(" found'); }); + + it('named with complex strings', () => { + expect(parse(`foo(filter='😀 > "\ttab"')`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `😀 > "\ttab"`)]) + ); + }); + + it('named with escape characters', () => { + expect(parse(`foo(filter='Women\\'s Clothing')`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `Women's Clothing`)]) + ); + expect(parse(`foo(filter="\\"Quoted inner string\\"")`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `"Quoted inner string"`)]) + ); + }); }); it('Missing expression', () => { 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/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/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index 161d4a7219c8d..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 @@ -10,10 +10,11 @@ import { migrationStateActionMachine } from './migrations_state_action_machine'; 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', () => { @@ -146,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({ 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 dede52f9758e9..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 @@ -49,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}`); + } }); } diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index b84d483cf6203..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 diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 327aee1a9dfc6..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; 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/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 220bd2c91057d..3b2feeecabb7c 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -162,6 +162,7 @@ kibana_vars=( timelion.enabled vega.enableExternalUrls xpack.actions.allowedHosts + xpack.actions.customHostSettings xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfiguredAlertHistoryEsIndex 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/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/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 03cf14a577a50..1b876051d009b 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -101,7 +101,7 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - const response = await nestedSearchSource + const { rawResponse: response } = await nestedSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index b30e5740fa3fb..32775464d055f 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -11,7 +11,7 @@ import type { Filter } from '../../../es_query'; import type { IndexPattern } from '../../../index_patterns'; import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; -import { searchSourceCommonMock } from '../../search_source/mocks'; +import { searchSourceCommonMock, searchSourceInstanceMock } from '../../search_source/mocks'; import { handleRequest, RequestHandlerParams } from './request_handler'; @@ -20,12 +20,20 @@ jest.mock('../../tabify', () => ({ })); import { tabifyAggResponse } from '../../tabify'; +import { of } from 'rxjs'; describe('esaggs expression function - public', () => { let mockParams: MockedKeys; beforeEach(() => { jest.clearAllMocks(); + + searchSourceInstanceMock.fetch$ = jest.fn().mockReturnValue( + of({ + rawResponse: {}, + }) + ); + mockParams = { abortSignal: (jest.fn() as unknown) as jest.Mocked, aggs: ({ diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 173b2067cad6b..d152ebf159a8e 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -111,7 +111,7 @@ export const handleRequest = async ({ inspectorAdapters.requests?.reset(); - const response = await requestSearchSource + const { rawResponse: response } = await requestSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/poll_search.test.ts b/src/plugins/data/common/search/poll_search.test.ts index 037fd0fc059d1..38c52f5d5bec4 100644 --- a/src/plugins/data/common/search/poll_search.test.ts +++ b/src/plugins/data/common/search/poll_search.test.ts @@ -20,11 +20,13 @@ describe('pollSearch', () => { resolve({ isRunning: false, isPartial: finishWithError, + rawResponse: {}, }); } else { resolve({ isRunning: true, isPartial: true, + rawResponse: {}, }); } }); diff --git a/src/plugins/data/common/search/search_source/fetch/request_error.ts b/src/plugins/data/common/search/search_source/fetch/request_error.ts index d8c750d011b03..48e216fa05541 100644 --- a/src/plugins/data/common/search/search_source/fetch/request_error.ts +++ b/src/plugins/data/common/search/search_source/fetch/request_error.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { KbnError } from '../../../../../kibana_utils/common'; +import { IKibanaSearchResponse } from '../../types'; import { SearchError } from './types'; /** @@ -16,9 +16,9 @@ import { SearchError } from './types'; * @param {Object} resp - optional HTTP response */ export class RequestFailure extends KbnError { - public resp?: estypes.SearchResponse; - constructor(err: SearchError | null = null, resp?: estypes.SearchResponse) { - super(`Request to Elasticsearch failed: ${JSON.stringify(resp || err?.message)}`); + public resp?: IKibanaSearchResponse; + constructor(err: SearchError | null = null, resp?: IKibanaSearchResponse) { + super(`Request to Elasticsearch failed: ${JSON.stringify(resp?.rawResponse || err?.message)}`); this.resp = resp; } diff --git a/src/plugins/data/common/search/search_source/fetch/types.ts b/src/plugins/data/common/search/search_source/fetch/types.ts index 79aa45163b913..069b2a3117a0a 100644 --- a/src/plugins/data/common/search/search_source/fetch/types.ts +++ b/src/plugins/data/common/search/search_source/fetch/types.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { GetConfigFn } from '../../../types'; +import { IKibanaSearchResponse } from '../../types'; /** * @internal @@ -24,10 +24,7 @@ export interface FetchHandlers { * Callback which can be used to hook into responses, modify them, or perform * side effects like displaying UI errors on the client. */ - onResponse: ( - request: SearchRequest, - response: estypes.SearchResponse - ) => estypes.SearchResponse; + onResponse: (request: SearchRequest, response: IKibanaSearchResponse) => IKibanaSearchResponse; } export interface SearchError { diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 68e386acfd48c..a3f043a5e2657 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -903,18 +903,26 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(2); expect(complete).toBeCalledTimes(1); expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": true, + "isRunning": true, + "rawResponse": Object { "test": 1, }, - ] + }, + ] `); expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": false, + "isRunning": false, + "rawResponse": Object { "test": 2, }, - ] + }, + ] `); }); @@ -958,13 +966,9 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(1); expect(error).toBeCalledTimes(1); expect(complete).toBeCalledTimes(0); - expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "test": 1, - }, - ] - `); + expect(next.mock.calls[0][0].rawResponse).toStrictEqual({ + test: 1, + }); expect(error.mock.calls[0][0]).toBe(undefined); }); }); @@ -1174,7 +1178,7 @@ describe('SearchSource', () => { expect(fetchSub.next).toHaveBeenCalledTimes(3); expect(fetchSub.complete).toHaveBeenCalledTimes(1); expect(fetchSub.error).toHaveBeenCalledTimes(0); - expect(resp).toStrictEqual({ other: 5 }); + expect(resp.rawResponse).toStrictEqual({ other: 5 }); expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 585126e1184d2..5130224329ba2 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -271,7 +271,9 @@ export class SearchSource { * Fetch this source from Elasticsearch, returning an observable over the response(s) * @param options */ - fetch$(options: ISearchOptions = {}) { + fetch$( + options: ISearchOptions = {} + ): Observable>> { const { getConfig } = this.dependencies; const syncSearchByDefault = getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES); @@ -308,7 +310,11 @@ export class SearchSource { * @deprecated Use fetch$ instead */ fetch(options: ISearchOptions = {}) { - return this.fetch$(options).toPromise(); + return this.fetch$(options) + .toPromise() + .then((r) => { + return r.rawResponse as estypes.SearchResponse; + }); } /** @@ -341,7 +347,7 @@ export class SearchSource { * PRIVATE APIS ******/ - private inspectSearch(s$: Observable>, options: ISearchOptions) { + private inspectSearch(s$: Observable>, options: ISearchOptions) { const { id, title, description, adapter } = options.inspector || { title: '' }; const requestResponder = adapter?.start(title, { @@ -384,7 +390,7 @@ export class SearchSource { last(undefined, null), tap((finalResponse) => { if (finalResponse) { - requestResponder?.stats(getResponseInspectorStats(finalResponse, this)); + requestResponder?.stats(getResponseInspectorStats(finalResponse.rawResponse, this)); requestResponder?.ok({ json: finalResponse }); } }), @@ -424,8 +430,8 @@ export class SearchSource { ); } } - return response; } + return response; } /** @@ -477,7 +483,7 @@ export class SearchSource { } }); }), - map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + map((response) => onResponse(searchRequest, response)) ); } diff --git a/src/plugins/data/common/search/utils.ts b/src/plugins/data/common/search/utils.ts index e87434cd6ca83..e11957c6fa9fc 100644 --- a/src/plugins/data/common/search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -12,7 +12,7 @@ import type { IKibanaSearchResponse } from './types'; * @returns true if response had an error while executing in ES */ export const isErrorResponse = (response?: IKibanaSearchResponse) => { - return !response || (!response.isRunning && response.isPartial); + return !response || !response.rawResponse || (!response.isRunning && response.isPartial); }; /** diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 573820890de71..f5def327b5473 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -33,7 +33,6 @@ const createSetupContract = (): Setup => { search: searchServiceMock.createSetupContract(), fieldFormats: fieldFormatsServiceMock.createSetupContract(), query: querySetupMock, - __enhance: jest.fn(), }; }; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 862dd63948a22..2da3b90029980 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -16,7 +16,6 @@ import { DataPublicPluginStart, DataSetupDependencies, DataStartDependencies, - DataPublicPluginEnhancements, } from './types'; import { AutocompleteService } from './autocomplete'; import { SearchService } from './search/search_service'; @@ -122,9 +121,6 @@ export class DataPublicPlugin search: searchService, fieldFormats: this.fieldFormatsService.setup(core), query: queryService, - __enhance: (enhancements: DataPublicPluginEnhancements) => { - searchService.__enhance(enhancements.search); - }, }; } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index cc7228268cb7a..868330ce078c7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -10,7 +10,6 @@ import { Adapters as Adapters_2 } from 'src/plugins/inspector/common'; import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; -import { BehaviorSubject } from 'rxjs'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; import { ConfigDeprecationProvider } from '@kbn/config'; @@ -579,10 +578,6 @@ export type CustomFilter = Filter & { // // @public export interface DataPublicPluginSetup { - // Warning: (ae-forgotten-export) The symbol "DataPublicPluginEnhancements" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - __enhance: (enhancements: DataPublicPluginEnhancements) => void; // Warning: (ae-forgotten-export) The symbol "AutocompleteSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1695,10 +1690,6 @@ export interface ISearchOptions { // // @public export interface ISearchSetup { - // Warning: (ae-forgotten-export) The symbol "SearchEnhancements" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - __enhance: (enhancements: SearchEnhancements) => void; // Warning: (ae-forgotten-export) The symbol "AggsSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -2387,25 +2378,12 @@ export interface SearchError { // @public (undocumented) export class SearchInterceptor { constructor(deps: SearchInterceptorDeps); - // @internal (undocumented) - protected application: CoreStart['application']; - // (undocumented) - protected readonly deps: SearchInterceptorDeps; - // (undocumented) - protected getSerializableOptions(options?: ISearchOptions): Pick; - // (undocumented) - protected getTimeoutMode(): TimeoutErrorMode; - // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts - // - // (undocumented) - protected handleSearchError(e: KibanaServerError | AbortError, options?: ISearchOptions, isTimeout?: boolean): Error; - // @internal - protected pendingCount$: BehaviorSubject; - // @internal (undocumented) - protected runSearch(request: IKibanaSearchRequest, options?: ISearchOptions): Promise; - search(request: IKibanaSearchRequest, options?: ISearchOptions): Observable; + // Warning: (ae-forgotten-export) The symbol "IAsyncSearchOptions" needs to be exported by the entry point index.d.ts + search({ id, ...request }: IKibanaSearchRequest, options?: IAsyncSearchOptions): import("rxjs").Observable>; // (undocumented) showError(e: Error): void; + // (undocumented) + stop(): void; } // Warning: (ae-missing-release-tag) "SearchInterceptorDeps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2468,7 +2446,7 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; - fetch$(options?: ISearchOptions): Observable>; + fetch$(options?: ISearchOptions): Observable>>; // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; @@ -2643,11 +2621,9 @@ export type TimeHistoryContract = PublicMethodsOf; // @public (undocumented) export enum TimeoutErrorMode { // (undocumented) - CHANGE = 2, - // (undocumented) - CONTACT = 1, + CHANGE = 1, // (undocumented) - UPGRADE = 0 + CONTACT = 0 } // Warning: (ae-missing-release-tag) "TimeRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/plugins/data/public/search/errors/timeout_error.test.tsx b/src/plugins/data/public/search/errors/timeout_error.test.tsx index ec803fce27bfc..f1b83ae959a9e 100644 --- a/src/plugins/data/public/search/errors/timeout_error.test.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.test.tsx @@ -20,17 +20,6 @@ describe('SearchTimeoutError', () => { startMock.application.navigateToApp.mockImplementation(jest.fn()); }); - it('Should navigate to upgrade', () => { - const e = new SearchTimeoutError(new AbortError(), TimeoutErrorMode.UPGRADE); - const component = mount(e.getErrorMessage(startMock.application)); - - expect(component.find('EuiButton').length).toBe(1); - component.find('EuiButton').simulate('click'); - expect(startMock.application.navigateToUrl).toHaveBeenCalledWith( - 'https://www.elastic.co/subscriptions' - ); - }); - it('Should create contact admin message', () => { const e = new SearchTimeoutError(new AbortError(), TimeoutErrorMode.CONTACT); const component = mount(e.getErrorMessage(startMock.application)); diff --git a/src/plugins/data/public/search/errors/timeout_error.tsx b/src/plugins/data/public/search/errors/timeout_error.tsx index e640bbc6f25cd..29b921c9cc12b 100644 --- a/src/plugins/data/public/search/errors/timeout_error.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.tsx @@ -13,7 +13,6 @@ import { ApplicationStart } from 'kibana/public'; import { KbnError } from '../../../../kibana_utils/common'; export enum TimeoutErrorMode { - UPGRADE, CONTACT, CHANGE, } @@ -31,11 +30,6 @@ export class SearchTimeoutError extends KbnError { private getMessage() { switch (this.mode) { - case TimeoutErrorMode.UPGRADE: - return i18n.translate('data.search.upgradeLicense', { - defaultMessage: - 'Your query has timed out. With our free Basic tier, your queries never time out.', - }); case TimeoutErrorMode.CONTACT: return i18n.translate('data.search.timeoutContactAdmin', { defaultMessage: @@ -51,11 +45,6 @@ export class SearchTimeoutError extends KbnError { private getActionText() { switch (this.mode) { - case TimeoutErrorMode.UPGRADE: - return i18n.translate('data.search.upgradeLicenseActionText', { - defaultMessage: 'Upgrade now', - }); - break; case TimeoutErrorMode.CHANGE: return i18n.translate('data.search.timeoutIncreaseSettingActionText', { defaultMessage: 'Edit setting', @@ -66,9 +55,6 @@ export class SearchTimeoutError extends KbnError { private onClick(application: ApplicationStart) { switch (this.mode) { - case TimeoutErrorMode.UPGRADE: - application.navigateToUrl('https://www.elastic.co/subscriptions'); - break; case TimeoutErrorMode.CHANGE: application.navigateToApp('management', { path: `/kibana/settings`, diff --git a/src/plugins/data/public/search/fetch/handle_response.test.ts b/src/plugins/data/public/search/fetch/handle_response.test.ts index 8854bee5c7657..1a430f860f438 100644 --- a/src/plugins/data/public/search/fetch/handle_response.test.ts +++ b/src/plugins/data/public/search/fetch/handle_response.test.ts @@ -12,7 +12,7 @@ import { handleResponse } from './handle_response'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { notificationServiceMock } from '../../../../../core/public/notifications/notifications_service.mock'; import { setNotifications } from '../../services'; -import { SearchResponse } from 'elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; jest.mock('@kbn/i18n', () => { return { @@ -33,8 +33,10 @@ describe('handleResponse', () => { test('should notify if timed out', () => { const request = { body: {} }; const response = { - timed_out: true, - } as SearchResponse; + rawResponse: { + timed_out: true, + }, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -46,13 +48,15 @@ describe('handleResponse', () => { test('should notify if shards failed', () => { const request = { body: {} }; const response = { - _shards: { - failed: 1, - total: 2, - successful: 1, - skipped: 1, + rawResponse: { + _shards: { + failed: 1, + total: 2, + successful: 1, + skipped: 1, + }, }, - } as SearchResponse; + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -63,7 +67,9 @@ describe('handleResponse', () => { test('returns the response', () => { const request = {}; - const response = {} as SearchResponse; + const response = { + rawResponse: {}, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); }); diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 57ee5737e50a2..58e4da6b95fae 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; -import type { estypes } from '@elastic/elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; import { SearchRequest } from '..'; -export function handleResponse(request: SearchRequest, response: estypes.SearchResponse) { - if (response.timed_out) { +export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { + const { rawResponse } = response; + if (rawResponse.timed_out) { getNotifications().toasts.addWarning({ title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', { defaultMessage: 'Data might be incomplete because your request timed out', @@ -24,12 +25,12 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR }); } - if (response._shards && response._shards.failed) { + if (rawResponse._shards && rawResponse._shards.failed) { const title = i18n.translate('data.search.searchSource.fetch.shardsFailedNotificationMessage', { defaultMessage: '{shardsFailed} of {shardsTotal} shards failed', values: { - shardsFailed: response._shards.failed, - shardsTotal: response._shards.total, + shardsFailed: rawResponse._shards.failed, + shardsTotal: rawResponse._shards.total, }, }); const description = i18n.translate( @@ -43,7 +44,7 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR <> {description} - + ); diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 92a5c36202e6f..21d607eedb152 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -12,7 +12,6 @@ export { ISearchSetup, ISearchStart, ISearchStartSearchSource, - SearchEnhancements, SearchUsageCollector, } from './types'; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 273bbfe9e7b08..562b367b92c92 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -15,7 +15,6 @@ import { createSearchUsageCollectorMock } from './collectors/mocks'; function createSetupContract(): jest.Mocked { return { aggs: searchAggsSetupMock(), - __enhance: jest.fn(), session: getSessionServiceMock(), sessionsClient: getSessionsClientMock(), usageCollector: createSearchUsageCollectorMock(), diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts deleted file mode 100644 index e74581e9a6ffe..0000000000000 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ /dev/null @@ -1,323 +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 type { MockedKeys } from '@kbn/utility-types/jest'; -import { CoreSetup, CoreStart } from '../../../../core/public'; -import { coreMock } from '../../../../core/public/mocks'; -import { IEsSearchRequest } from '../../common/search'; -import { SearchInterceptor } from './search_interceptor'; -import { AbortError } from '../../../kibana_utils/public'; -import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from './errors'; -import { searchServiceMock } from './mocks'; -import { ISearchStart, ISessionService } from '.'; -import { bfetchPluginMock } from '../../../bfetch/public/mocks'; -import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; - -import * as searchPhaseException from '../../common/search/test_data/search_phase_execution_exception.json'; -import * as resourceNotFoundException from '../../common/search/test_data/resource_not_found_exception.json'; - -let searchInterceptor: SearchInterceptor; -let mockCoreSetup: MockedKeys; -let bfetchSetup: jest.Mocked; -let fetchMock: jest.Mock; - -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); -jest.useFakeTimers(); - -describe('SearchInterceptor', () => { - let searchMock: jest.Mocked; - let mockCoreStart: MockedKeys; - beforeEach(() => { - mockCoreSetup = coreMock.createSetup(); - mockCoreStart = coreMock.createStart(); - searchMock = searchServiceMock.createStartContract(); - fetchMock = jest.fn(); - bfetchSetup = bfetchPluginMock.createSetupContract(); - bfetchSetup.batchedFunction.mockReturnValue(fetchMock); - searchInterceptor = new SearchInterceptor({ - bfetch: bfetchSetup, - toasts: mockCoreSetup.notifications.toasts, - startServices: new Promise((resolve) => { - resolve([mockCoreStart, {}, {}]); - }), - uiSettings: mockCoreSetup.uiSettings, - http: mockCoreSetup.http, - session: searchMock.session, - }); - }); - - describe('showError', () => { - test('Ignores an AbortError', async () => { - searchInterceptor.showError(new AbortError()); - expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - - test('Ignores a SearchTimeoutError', async () => { - searchInterceptor.showError(new SearchTimeoutError(new Error(), TimeoutErrorMode.UPGRADE)); - expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - - test('Renders a PainlessError', async () => { - searchInterceptor.showError( - new PainlessError({ - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: searchPhaseException.error, - }) - ); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - - test('Renders a general error', async () => { - searchInterceptor.showError(new Error('Oopsy')); - expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); - expect(mockCoreSetup.notifications.toasts.addError).toBeCalledTimes(1); - }); - }); - - describe('search', () => { - test('Observable should resolve if fetch is successful', async () => { - const mockResponse: any = { result: 200 }; - fetchMock.mockResolvedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).resolves.toBe(mockResponse); - }); - - describe('Search session', () => { - const setup = ( - opts: { - isRestore?: boolean; - isStored?: boolean; - sessionId: string; - } | null - ) => { - const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockImplementation(() => - opts - ? { - sessionId: opts.sessionId, - isRestore: opts.isRestore ?? false, - isStored: opts.isStored ?? false, - } - : null - ); - fetchMock.mockResolvedValue({ result: 200 }); - }; - - const mockRequest: IEsSearchRequest = { - params: {}, - }; - - afterEach(() => { - const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockReset(); - fetchMock.mockReset(); - }); - - test('gets session search options from session service', async () => { - const sessionId = 'sid'; - setup({ - isRestore: true, - isStored: true, - sessionId, - }); - - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); - expect(fetchMock.mock.calls[0][0]).toEqual( - expect.objectContaining({ - options: { sessionId, isStored: true, isRestore: true, strategy: 'es' }, - }) - ); - - expect( - (searchMock.session as jest.Mocked).getSearchOptions - ).toHaveBeenCalledWith(sessionId); - }); - - test("doesn't forward sessionId if search options return null", async () => { - const sessionId = 'sid'; - setup(null); - - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); - expect(fetchMock.mock.calls[0][0]).toEqual( - expect.not.objectContaining({ - options: { sessionId }, - }) - ); - - expect( - (searchMock.session as jest.Mocked).getSearchOptions - ).toHaveBeenCalledWith(sessionId); - }); - }); - - describe('Should throw typed errors', () => { - test('Observable should fail if fetch has an internal error', async () => { - const mockResponse: any = new Error('Internal Error'); - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow('Internal Error'); - }); - - describe('Should handle Timeout errors', () => { - test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow(SearchTimeoutError); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - }); - - test('Timeout error should show multiple times if not in a session', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - - await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( - SearchTimeoutError - ); - await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( - SearchTimeoutError - ); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); - }); - - test('Timeout error should show once per each session', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'def' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); - }); - - test('Timeout error should show once in a single session', async () => { - const mockResponse: any = { - statusCode: 500, - message: 'Request timed out', - }; - fetchMock.mockRejectedValue(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - await expect( - searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() - ).rejects.toThrow(SearchTimeoutError); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - }); - }); - - test('Should throw Painless error on server error with OSS format', async () => { - const mockResponse: any = { - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: searchPhaseException.error, - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow(PainlessError); - }); - - test('Should throw ES error on ES server error', async () => { - const mockResponse: any = { - statusCode: 400, - message: 'resource_not_found_exception', - attributes: resourceNotFoundException.error, - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest); - await expect(response.toPromise()).rejects.toThrow(EsError); - }); - - test('Observable should fail if user aborts (test merged signal)', async () => { - const abortController = new AbortController(); - fetchMock.mockImplementationOnce((options: any) => { - return new Promise((resolve, reject) => { - options.signal.addEventListener('abort', () => { - reject(new AbortError()); - }); - - setTimeout(resolve, 500); - }); - }); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest, { - abortSignal: abortController.signal, - }); - - const next = jest.fn(); - const error = (e: any) => { - expect(next).not.toBeCalled(); - expect(e).toBeInstanceOf(AbortError); - }; - response.subscribe({ next, error }); - setTimeout(() => abortController.abort(), 200); - jest.advanceTimersByTime(5000); - - await flushPromises(); - }); - - test('Immediately aborts if passed an aborted abort signal', async (done) => { - const abort = new AbortController(); - const mockRequest: IEsSearchRequest = { - params: {}, - }; - const response = searchInterceptor.search(mockRequest, { abortSignal: abort.signal }); - abort.abort(); - - const error = (e: any) => { - expect(e).toBeInstanceOf(AbortError); - expect(fetchMock).not.toBeCalled(); - done(); - }; - response.subscribe({ error }); - }); - }); - }); -}); diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts deleted file mode 100644 index f0df6f9216c0f..0000000000000 --- a/src/plugins/data/public/search/search_interceptor.ts +++ /dev/null @@ -1,245 +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 { memoize } from 'lodash'; -import { BehaviorSubject, throwError, defer, from, Observable } from 'rxjs'; -import { catchError, finalize } from 'rxjs/operators'; -import { PublicMethodsOf } from '@kbn/utility-types'; -import { CoreStart, CoreSetup, ToastsSetup } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; -import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; -import { - ES_SEARCH_STRATEGY, - IKibanaSearchRequest, - IKibanaSearchResponse, - ISearchOptions, - ISearchOptionsSerializable, -} from '../../common'; -import { SearchUsageCollector } from './collectors'; -import { - SearchTimeoutError, - PainlessError, - isPainlessError, - TimeoutErrorMode, - isEsError, - EsError, - getHttpError, -} from './errors'; -import { toMountPoint } from '../../../kibana_react/public'; -import { AbortError, KibanaServerError } from '../../../kibana_utils/public'; -import { ISessionService } from './session'; - -export interface SearchInterceptorDeps { - bfetch: BfetchPublicSetup; - http: CoreSetup['http']; - uiSettings: CoreSetup['uiSettings']; - startServices: Promise<[CoreStart, any, unknown]>; - toasts: ToastsSetup; - usageCollector?: SearchUsageCollector; - session: ISessionService; -} - -export class SearchInterceptor { - /** - * Observable that emits when the number of pending requests changes. - * @internal - */ - protected pendingCount$ = new BehaviorSubject(0); - - /** - * @internal - */ - protected application!: CoreStart['application']; - private batchedFetch!: BatchedFunc< - { request: IKibanaSearchRequest; options: ISearchOptionsSerializable }, - IKibanaSearchResponse - >; - - /* - * @internal - */ - constructor(protected readonly deps: SearchInterceptorDeps) { - this.deps.http.addLoadingCountSource(this.pendingCount$); - - this.deps.startServices.then(([coreStart]) => { - this.application = coreStart.application; - }); - - this.batchedFetch = deps.bfetch.batchedFunction({ - url: '/internal/bsearch', - }); - } - - /* - * @returns `TimeoutErrorMode` indicating what action should be taken in case of a request timeout based on license and permissions. - * @internal - */ - protected getTimeoutMode() { - return TimeoutErrorMode.UPGRADE; - } - - /* - * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. - * @internal - */ - protected handleSearchError( - e: KibanaServerError | AbortError, - options?: ISearchOptions, - isTimeout?: boolean - ): Error { - if (isTimeout || e.message === 'Request timed out') { - // Handle a client or a server side timeout - const err = new SearchTimeoutError(e, this.getTimeoutMode()); - - // Show the timeout error here, so that it's shown regardless of how an application chooses to handle errors. - // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. - this.showTimeoutError(err, options?.sessionId); - return err; - } else if (e instanceof AbortError) { - // In the case an application initiated abort, throw the existing AbortError. - return e; - } else if (isEsError(e)) { - if (isPainlessError(e)) { - return new PainlessError(e, options?.indexPattern); - } else { - return new EsError(e); - } - } else { - return e instanceof Error ? e : new Error(e.message); - } - } - - protected getSerializableOptions(options?: ISearchOptions) { - const { sessionId, ...requestOptions } = options || {}; - - const serializableOptions: ISearchOptionsSerializable = {}; - const combined = { - ...requestOptions, - ...this.deps.session.getSearchOptions(sessionId), - }; - - if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId; - if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore; - if (combined.legacyHitsTotal !== undefined) - serializableOptions.legacyHitsTotal = combined.legacyHitsTotal; - if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy; - if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored; - - return serializableOptions; - } - - /** - * @internal - * @throws `AbortError` | `ErrorLike` - */ - protected runSearch( - request: IKibanaSearchRequest, - options?: ISearchOptions - ): Promise { - const { abortSignal } = options || {}; - return this.batchedFetch( - { - request, - options: this.getSerializableOptions(options), - }, - abortSignal - ); - } - - private showTimeoutErrorToast = (e: SearchTimeoutError, sessionId?: string) => { - this.deps.toasts.addDanger({ - title: 'Timed out', - text: toMountPoint(e.getErrorMessage(this.application)), - }); - }; - - private showTimeoutErrorMemoized = memoize( - this.showTimeoutErrorToast, - (_: SearchTimeoutError, sessionId: string) => { - return sessionId; - } - ); - - /** - * Show one error notification per session. - * @internal - */ - private showTimeoutError = (e: SearchTimeoutError, sessionId?: string) => { - if (sessionId) { - this.showTimeoutErrorMemoized(e, sessionId); - } else { - this.showTimeoutErrorToast(e, sessionId); - } - }; - - /** - * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort - * either when the request times out, or when the original `AbortSignal` is aborted. Updates - * `pendingCount$` when the request is started/finalized. - * - * @param request - * @options - * @returns `Observable` emitting the search response or an error. - */ - public search( - request: IKibanaSearchRequest, - options: ISearchOptions = {} - ): Observable { - options = { - strategy: ES_SEARCH_STRATEGY, - ...options, - }; - - // Defer the following logic until `subscribe` is actually called - return defer(() => { - if (options.abortSignal?.aborted) { - return throwError(new AbortError()); - } - - this.pendingCount$.next(this.pendingCount$.getValue() + 1); - return from(this.runSearch(request, options)).pipe( - catchError((e: Error | AbortError) => { - return throwError(this.handleSearchError(e, options)); - }), - finalize(() => { - this.pendingCount$.next(this.pendingCount$.getValue() - 1); - }) - ); - }); - } - - /* - * - */ - public showError(e: Error) { - if (e instanceof AbortError || e instanceof SearchTimeoutError) { - // The SearchTimeoutError is shown by the interceptor in getSearchError (regardless of how the app chooses to handle errors) - return; - } else if (e instanceof EsError) { - this.deps.toasts.addDanger({ - title: i18n.translate('data.search.esErrorTitle', { - defaultMessage: 'Cannot retrieve search results', - }), - text: toMountPoint(e.getErrorMessage(this.application)), - }); - } else if (e.constructor.name === 'HttpFetchError') { - this.deps.toasts.addDanger({ - title: i18n.translate('data.search.httpErrorTitle', { - defaultMessage: 'Cannot retrieve your data', - }), - text: toMountPoint(getHttpError(e.message)), - }); - } else { - this.deps.toasts.addError(e, { - title: 'Search Error', - }); - } - } -} - -export type ISearchInterceptor = PublicMethodsOf; diff --git a/src/plugins/vis_type_timeseries/public/application/index.ts b/src/plugins/data/public/search/search_interceptor/index.ts similarity index 77% rename from src/plugins/vis_type_timeseries/public/application/index.ts rename to src/plugins/data/public/search/search_interceptor/index.ts index fcc0c592b1ef5..411c4beefe96c 100644 --- a/src/plugins/vis_type_timeseries/public/application/index.ts +++ b/src/plugins/data/public/search/search_interceptor/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { EditorController, TSVB_EDITOR_NAME } from './editor_controller'; -export * from './lib'; +export { SearchInterceptor, ISearchInterceptor, SearchInterceptorDeps } from './search_interceptor'; diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts b/src/plugins/data/public/search/search_interceptor/search_abort_controller.test.ts similarity index 94% rename from x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts rename to src/plugins/data/public/search/search_interceptor/search_abort_controller.test.ts index a52fdef9819b8..5d9d8a9903325 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_abort_controller.test.ts @@ -1,8 +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. + * 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 { SearchAbortController } from './search_abort_controller'; diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts b/src/plugins/data/public/search/search_interceptor/search_abort_controller.ts similarity index 90% rename from x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts rename to src/plugins/data/public/search/search_interceptor/search_abort_controller.ts index 7bc74b56a3903..1e2cdae1070ab 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts +++ b/src/plugins/data/public/search/search_interceptor/search_abort_controller.ts @@ -1,8 +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. + * 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 { Subscription, timer } from 'rxjs'; diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts new file mode 100644 index 0000000000000..0e81f362a030d --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -0,0 +1,1384 @@ +/* + * 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 { MockedKeys } from '@kbn/utility-types/jest'; +import { CoreSetup, CoreStart } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { IEsSearchRequest } from '../../../common/search'; +import { SearchInterceptor } from './search_interceptor'; +import { AbortError } from '../../../../kibana_utils/public'; +import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from '../errors'; +import { ISessionService, SearchSessionState } from '../'; +import { bfetchPluginMock } from '../../../../bfetch/public/mocks'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; + +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; +import * as resourceNotFoundException from '../../../common/search/test_data/resource_not_found_exception.json'; +import { BehaviorSubject } from 'rxjs'; +import { dataPluginMock } from '../../mocks'; +import { UI_SETTINGS } from '../../../common'; + +jest.mock('./utils', () => ({ + createRequestHash: jest.fn().mockImplementation((input) => { + return Promise.resolve(JSON.stringify(input)); + }), +})); + +let searchInterceptor: SearchInterceptor; +let mockCoreSetup: MockedKeys; +let bfetchSetup: jest.Mocked; +let fetchMock: jest.Mock; + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +jest.useFakeTimers(); + +const timeTravel = async (msToRun = 0) => { + await flushPromises(); + jest.advanceTimersByTime(msToRun); + return flushPromises(); +}; + +const next = jest.fn(); +const error = jest.fn(); +const complete = jest.fn(); + +function mockFetchImplementation(responses: any[]) { + let i = 0; + fetchMock.mockImplementation((r) => { + if (!r.request.id) i = 0; + const { time = 0, value = {}, isError = false } = responses[i++]; + value.meta = { + size: 10, + }; + return new Promise((resolve, reject) => + setTimeout(() => { + return (isError ? reject : resolve)(value); + }, time) + ); + }); +} + +describe('SearchInterceptor', () => { + let mockCoreStart: MockedKeys; + let sessionService: jest.Mocked; + let sessionState$: BehaviorSubject; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + mockCoreStart = coreMock.createStart(); + sessionState$ = new BehaviorSubject(SearchSessionState.None); + const dataPluginMockStart = dataPluginMock.createStartContract(); + sessionService = { + ...(dataPluginMockStart.search.session as jest.Mocked), + state$: sessionState$, + }; + + fetchMock = jest.fn(); + mockCoreSetup.uiSettings.get.mockImplementation((name: string) => { + switch (name) { + case UI_SETTINGS.SEARCH_TIMEOUT: + return 1000; + default: + return; + } + }); + + next.mockClear(); + error.mockClear(); + complete.mockClear(); + jest.clearAllTimers(); + + const bfetchMock = bfetchPluginMock.createSetupContract(); + bfetchMock.batchedFunction.mockReturnValue(fetchMock); + + bfetchSetup = bfetchPluginMock.createSetupContract(); + bfetchSetup.batchedFunction.mockReturnValue(fetchMock); + searchInterceptor = new SearchInterceptor({ + bfetch: bfetchSetup, + toasts: mockCoreSetup.notifications.toasts, + startServices: new Promise((resolve) => { + resolve([mockCoreStart, {}, {}]); + }), + uiSettings: mockCoreSetup.uiSettings, + http: mockCoreSetup.http, + session: sessionService, + }); + }); + + describe('showError', () => { + test('Ignores an AbortError', async () => { + searchInterceptor.showError(new AbortError()); + expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + + test('Ignores a SearchTimeoutError', async () => { + searchInterceptor.showError(new SearchTimeoutError(new Error(), TimeoutErrorMode.CONTACT)); + expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + + test('Renders a PainlessError', async () => { + searchInterceptor.showError( + new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }) + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + + test('Renders a general error', async () => { + searchInterceptor.showError(new Error('Oopsy')); + expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled(); + expect(mockCoreSetup.notifications.toasts.addError).toBeCalledTimes(1); + }); + }); + + describe('search', () => { + test('Observable should resolve if fetch is successful', async () => { + const mockResponse: any = { rawResponse: {} }; + fetchMock.mockResolvedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).resolves.toBe(mockResponse); + }); + + test('should resolve immediately if first call returns full result', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}); + response.subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); + expect(complete).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test('should make secondary request if first call returns partial result', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + { + time: 20, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error, complete }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); + expect(complete).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + await timeTravel(20); + + expect(next).toHaveBeenCalledTimes(2); + expect(next.mock.calls[1][0]).toStrictEqual(responses[1].value); + expect(complete).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test('should abort if request is partial and not running (ES graceful error)', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + }); + + test('should abort on user abort', async () => { + const responses = [ + { + time: 500, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const abortController = new AbortController(); + abortController.abort(); + + const response = searchInterceptor.search({}, { abortSignal: abortController.signal }); + response.subscribe({ next, error }); + + await timeTravel(500); + + expect(next).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + }); + + test('should DELETE a running async search on abort', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 300, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const abortController = new AbortController(); + setTimeout(() => abortController.abort(), 250); + + const response = searchInterceptor.search( + {}, + { abortSignal: abortController.signal, pollInterval: 0 } + ); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + await timeTravel(240); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('should not DELETE a running async search on async timeout prior to first response', async () => { + const responses = [ + { + time: 2000, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error }); + + await timeTravel(1000); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); + expect(fetchMock).toHaveBeenCalled(); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + }); + + test('should DELETE a running async search on async timeout after first response', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 2000, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + + // Long enough to reach the timeout but not long enough to reach the next response + await timeTravel(1000); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('should DELETE a running async search on async timeout on error from fetch', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 10, + value: { + statusCode: 500, + message: 'oh no', + id: 1, + }, + isError: true, + }, + ]; + mockFetchImplementation(responses); + + const response = searchInterceptor.search({}, { pollInterval: 0 }); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + + // Long enough to reach the timeout but not long enough to reach the next response + await timeTravel(10); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('should NOT DELETE a running SAVED async search on abort', async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 300, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + mockFetchImplementation(responses); + + const abortController = new AbortController(); + setTimeout(() => abortController.abort(), 250); + + const response = searchInterceptor.search( + {}, + { abortSignal: abortController.signal, pollInterval: 0, sessionId } + ); + response.subscribe({ next, error }); + + await timeTravel(10); + + expect(next).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + sessionState$.next(SearchSessionState.BackgroundLoading); + + await timeTravel(240); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); + }); + + describe('Search session', () => { + const setup = ( + opts: { + isRestore?: boolean; + isStored?: boolean; + sessionId: string; + } | null + ) => { + const sessionServiceMock = sessionService as jest.Mocked; + sessionServiceMock.getSearchOptions.mockImplementation(() => + opts + ? { + sessionId: opts.sessionId, + isRestore: opts.isRestore ?? false, + isStored: opts.isStored ?? false, + } + : null + ); + fetchMock.mockResolvedValue({ result: 200 }); + }; + + const mockRequest: IEsSearchRequest = { + params: {}, + }; + + afterEach(() => { + const sessionServiceMock = sessionService as jest.Mocked; + sessionServiceMock.getSearchOptions.mockReset(); + fetchMock.mockReset(); + }); + + test('gets session search options from session service', async () => { + const sessionId = 'sid'; + setup({ + isRestore: true, + isStored: true, + sessionId, + }); + + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ + options: { sessionId, isStored: true, isRestore: true, strategy: 'ese' }, + }) + ); + + expect( + (sessionService as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); + + test("doesn't forward sessionId if search options return null", async () => { + const sessionId = 'sid'; + setup(null); + + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.not.objectContaining({ + options: { sessionId }, + }) + ); + + expect( + (sessionService as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); + }); + + describe('Session tracking', () => { + beforeEach(() => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + { + time: 300, + value: { + isPartial: false, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + }); + + test('should track searches', async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); + response.subscribe({ next, error }); + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + await timeTravel(300); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).toBeCalledTimes(1); + }); + + test('session service should be able to cancel search', async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); + response.subscribe({ next, error }); + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(1); + + const abort = sessionService.trackSearch.mock.calls[0][0].abort; + expect(abort).toBeInstanceOf(Function); + + abort(); + + await timeTravel(10); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + }); + + test("don't track non current session searches", async () => { + const sessionId = 'sessionId'; + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response1 = searchInterceptor.search( + {}, + { pollInterval: 0, sessionId: 'something different' } + ); + response1.subscribe({ next, error }); + + const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); + response2.subscribe({ next, error }); + + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(0); + }); + + test("don't track if no current session", async () => { + sessionService.getSessionId.mockImplementation(() => undefined); + sessionService.isCurrentSession.mockImplementation((_sessionId) => false); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const response1 = searchInterceptor.search( + {}, + { pollInterval: 0, sessionId: 'something different' } + ); + response1.subscribe({ next, error }); + + const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); + response2.subscribe({ next, error }); + + await timeTravel(10); + expect(sessionService.trackSearch).toBeCalledTimes(0); + }); + }); + + describe('session client caching', () => { + const sessionId = 'sessionId'; + const basicReq = { + params: { + test: 1, + }, + }; + + const basicCompleteResponse = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + const partialCompleteResponse = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + { + time: 20, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + beforeEach(() => { + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + }); + + test('should be disabled if there is no session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch different requests in a single session', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req2 = { + params: { + test: 2, + }, + }; + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch the same request for two different sessions', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor + .search(basicReq, { sessionId: 'anotherSession' }) + .subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should track searches that come from cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).not.toBeCalled(); + await timeTravel(300); + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(4); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(2); + }); + + test('should cache partial responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + rawResponse: {}, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should not cache error responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should deliver error to all replays', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + rawResponse: {}, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(error).toBeCalledTimes(2); + expect(error.mock.calls[0][0].message).toEqual('Received partial response'); + expect(error.mock.calls[1][0].message).toEqual('Received partial response'); + }); + + test('should ignore anything outside params when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + something: 123, + params: { + test: 1, + }, + }; + + const req2 = { + something: 321, + params: { + test: 1, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should ignore preference when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + params: { + test: 1, + preference: 123, + }, + }; + + const req2 = { + params: { + test: 1, + preference: 321, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should return from cache for identical requests in the same session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('aborting a search that didnt get any response should retrigger search', async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Abort the search request before it started + abortController.abort(); + + // Time travel to make sure nothing appens + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(0); + expect(next).toBeCalledTimes(0); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + const error2 = jest.fn(); + const next2 = jest.fn(); + const complete2 = jest.fn(); + + // Search for the same thing again + searchInterceptor + .search(basicReq, { sessionId }) + .subscribe({ next: next2, error: error2, complete: complete2 }); + + // Should search again + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + }); + + test('aborting a running first search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + // Both searches should be tracked and untracked + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + // First search should error + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + // Second search should complete + expect(next2).toBeCalledTimes(2); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting a running second search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { + pollInterval: 0, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(1); + + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(0); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting both requests should cancel underlaying search only once', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + sessionService.trackSearch.mockImplementation(() => jest.fn()); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + + abortController.abort(); + + await timeTravel(300); + + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('aborting both searches should stop searching and clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation( + (_sessionId) => _sessionId === sessionId + ); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(0); + expect(fetchMock).toBeCalledTimes(1); + + abortController.abort(); + + await timeTravel(300); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(0); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError); + + // Should be called only 1 times (one partial response) + expect(fetchMock).toBeCalledTimes(1); + + // Clear mock and research + fetchMock.mockReset(); + mockFetchImplementation(partialCompleteResponse); + // Run the search again to see that we don't hit the cache + const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response3.subscribe({ next, error, complete }); + + await timeTravel(10); + await timeTravel(10); + await timeTravel(300); + + // Should be called 2 times (two partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + }); + + test("aborting a completed search shouldn't effect cache", async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Get a final response + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + // Abort the search request + abortController.abort(); + + // Search for the same thing again + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + + // Get the response from cache + expect(fetchMock).toBeCalledTimes(1); + }); + }); + + describe('Should throw typed errors', () => { + test('Observable should fail if fetch has an internal error', async () => { + const mockResponse: any = new Error('Internal Error'); + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow('Internal Error'); + }); + + describe('Should handle Timeout errors', () => { + test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(SearchTimeoutError); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + }); + + test('Timeout error should show multiple times if not in a session', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + + await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( + SearchTimeoutError + ); + await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow( + SearchTimeoutError + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); + }); + + test('Timeout error should show once per each session', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'def' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2); + }); + + test('Timeout error should show once in a single session', async () => { + const mockResponse: any = { + statusCode: 500, + message: 'Request timed out', + }; + fetchMock.mockRejectedValue(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + await expect( + searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise() + ).rejects.toThrow(SearchTimeoutError); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + }); + }); + + test('Should throw Painless error on server error with OSS format', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(PainlessError); + }); + + test('Should throw ES error on ES server error', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'resource_not_found_exception', + attributes: resourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(EsError); + }); + + test('Observable should fail if user aborts (test merged signal)', async () => { + const abortController = new AbortController(); + fetchMock.mockImplementationOnce((options: any) => { + return new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => { + reject(new AbortError()); + }); + + setTimeout(resolve, 500); + }); + }); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest, { + abortSignal: abortController.signal, + }); + + error.mockImplementation((e) => { + expect(next).not.toBeCalled(); + expect(e).toBeInstanceOf(AbortError); + }); + + response.subscribe({ next, error }); + setTimeout(() => abortController.abort(), 200); + jest.advanceTimersByTime(5000); + + await flushPromises(); + }); + + test('Immediately aborts if passed an aborted abort signal', async (done) => { + const abort = new AbortController(); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest, { abortSignal: abort.signal }); + abort.abort(); + + error.mockImplementation((e) => { + expect(e).toBeInstanceOf(AbortError); + expect(fetchMock).not.toBeCalled(); + done(); + }); + + response.subscribe({ error }); + }); + }); + }); +}); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts new file mode 100644 index 0000000000000..5fa0a5c301019 --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -0,0 +1,408 @@ +/* + * 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 { memoize, once } from 'lodash'; +import { BehaviorSubject, EMPTY, from, fromEvent, of, Subscription, throwError } from 'rxjs'; +import { + catchError, + filter, + finalize, + map, + shareReplay, + skip, + switchMap, + take, + takeUntil, + tap, +} from 'rxjs/operators'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { CoreSetup, CoreStart, ToastsSetup } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; +import { + ENHANCED_ES_SEARCH_STRATEGY, + IAsyncSearchOptions, + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, + ISearchOptionsSerializable, + pollSearch, + UI_SETTINGS, +} from '../../../common'; +import { SearchUsageCollector } from '../collectors'; +import { + EsError, + getHttpError, + isEsError, + isPainlessError, + PainlessError, + SearchTimeoutError, + TimeoutErrorMode, +} from '../errors'; +import { toMountPoint } from '../../../../kibana_react/public'; +import { AbortError, KibanaServerError } from '../../../../kibana_utils/public'; +import { ISessionService, SearchSessionState } from '../session'; +import { SearchResponseCache } from './search_response_cache'; +import { createRequestHash } from './utils'; +import { SearchAbortController } from './search_abort_controller'; + +export interface SearchInterceptorDeps { + bfetch: BfetchPublicSetup; + http: CoreSetup['http']; + uiSettings: CoreSetup['uiSettings']; + startServices: Promise<[CoreStart, any, unknown]>; + toasts: ToastsSetup; + usageCollector?: SearchUsageCollector; + session: ISessionService; +} + +const MAX_CACHE_ITEMS = 50; +const MAX_CACHE_SIZE_MB = 10; + +export class SearchInterceptor { + private uiSettingsSub: Subscription; + private searchTimeout: number; + private readonly responseCache: SearchResponseCache = new SearchResponseCache( + MAX_CACHE_ITEMS, + MAX_CACHE_SIZE_MB + ); + + /** + * Observable that emits when the number of pending requests changes. + * @internal + */ + private pendingCount$ = new BehaviorSubject(0); + + /** + * @internal + */ + private application!: CoreStart['application']; + private batchedFetch!: BatchedFunc< + { request: IKibanaSearchRequest; options: ISearchOptionsSerializable }, + IKibanaSearchResponse + >; + + /* + * @internal + */ + constructor(private readonly deps: SearchInterceptorDeps) { + this.deps.http.addLoadingCountSource(this.pendingCount$); + + this.deps.startServices.then(([coreStart]) => { + this.application = coreStart.application; + }); + + this.batchedFetch = deps.bfetch.batchedFunction({ + url: '/internal/bsearch', + }); + + this.searchTimeout = deps.uiSettings.get(UI_SETTINGS.SEARCH_TIMEOUT); + + this.uiSettingsSub = deps.uiSettings + .get$(UI_SETTINGS.SEARCH_TIMEOUT) + .subscribe((timeout: number) => { + this.searchTimeout = timeout; + }); + } + + public stop() { + this.responseCache.clear(); + this.uiSettingsSub.unsubscribe(); + } + + /* + * @returns `TimeoutErrorMode` indicating what action should be taken in case of a request timeout based on license and permissions. + * @internal + */ + private getTimeoutMode() { + return this.application.capabilities.advancedSettings?.save + ? TimeoutErrorMode.CHANGE + : TimeoutErrorMode.CONTACT; + } + + private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) { + const { sessionId, isRestore } = options; + // Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference + const { preference, ...params } = request.params || {}; + const hashOptions = { + ...params, + sessionId, + isRestore, + }; + + return from(sessionId ? createRequestHash(hashOptions) : of(undefined)); + } + + /* + * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. + * @internal + */ + private handleSearchError( + e: KibanaServerError | AbortError, + options?: ISearchOptions, + isTimeout?: boolean + ): Error { + if (isTimeout || e.message === 'Request timed out') { + // Handle a client or a server side timeout + const err = new SearchTimeoutError(e, this.getTimeoutMode()); + + // Show the timeout error here, so that it's shown regardless of how an application chooses to handle errors. + // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. + this.showTimeoutError(err, options?.sessionId); + return err; + } else if (e instanceof AbortError) { + // In the case an application initiated abort, throw the existing AbortError. + return e; + } else if (isEsError(e)) { + if (isPainlessError(e)) { + return new PainlessError(e, options?.indexPattern); + } else { + return new EsError(e); + } + } else { + return e instanceof Error ? e : new Error(e.message); + } + } + + private getSerializableOptions(options?: ISearchOptions) { + const { sessionId, ...requestOptions } = options || {}; + + const serializableOptions: ISearchOptionsSerializable = {}; + const combined = { + ...requestOptions, + ...this.deps.session.getSearchOptions(sessionId), + }; + + if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId; + if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore; + if (combined.legacyHitsTotal !== undefined) + serializableOptions.legacyHitsTotal = combined.legacyHitsTotal; + if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy; + if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored; + + return serializableOptions; + } + + /** + * @internal + * Creates a new pollSearch that share replays its results + */ + private runSearch$( + { id, ...request }: IKibanaSearchRequest, + options: IAsyncSearchOptions, + searchAbortController: SearchAbortController + ) { + const search = () => this.runSearch({ id, ...request }, options); + const { sessionId, strategy } = options; + + // track if this search's session will be send to background + // if yes, then we don't need to cancel this search when it is aborted + let isSavedToBackground = false; + const savedToBackgroundSub = + this.deps.session.isCurrentSession(sessionId) && + this.deps.session.state$ + .pipe( + skip(1), // ignore any state, we are only interested in transition x -> BackgroundLoading + filter( + (state) => + this.deps.session.isCurrentSession(sessionId) && + state === SearchSessionState.BackgroundLoading + ), + take(1) + ) + .subscribe(() => { + isSavedToBackground = true; + }); + + const cancel = once(() => { + if (id && !isSavedToBackground) this.deps.http.delete(`/internal/search/${strategy}/${id}`); + }); + + return pollSearch(search, cancel, { + ...options, + abortSignal: searchAbortController.getSignal(), + }).pipe( + tap((response) => (id = response.id)), + catchError((e: Error) => { + cancel(); + return throwError(e); + }), + finalize(() => { + searchAbortController.cleanup(); + if (savedToBackgroundSub) { + savedToBackgroundSub.unsubscribe(); + } + }), + // This observable is cached in the responseCache. + // Using shareReplay makes sure that future subscribers will get the final response + + shareReplay(1) + ); + } + + /** + * @internal + * @throws `AbortError` | `ErrorLike` + */ + private runSearch( + request: IKibanaSearchRequest, + options?: ISearchOptions + ): Promise { + const { abortSignal } = options || {}; + return this.batchedFetch( + { + request, + options: this.getSerializableOptions(options), + }, + abortSignal + ); + } + + /** + * @internal + * Creates a new search observable and a corresponding search abort controller + * If requestHash is defined, tries to return them first from cache. + */ + private getSearchResponse$( + request: IKibanaSearchRequest, + options: IAsyncSearchOptions, + requestHash?: string + ) { + const cached = requestHash ? this.responseCache.get(requestHash) : undefined; + + const searchAbortController = + cached?.searchAbortController || new SearchAbortController(this.searchTimeout); + + // Create a new abort signal if one was not passed. This fake signal will never be aborted, + // So the underlaying search will not be aborted, even if the other consumers abort. + searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal); + const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController); + + if (requestHash && !this.responseCache.has(requestHash)) { + this.responseCache.set(requestHash, { + response$, + searchAbortController, + }); + } + + return { + response$, + searchAbortController, + }; + } + + /** + * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort + * either when the request times out, or when the original `AbortSignal` is aborted. Updates + * `pendingCount$` when the request is started/finalized. + * + * @param request + * @options + * @returns `Observable` emitting the search response or an error. + */ + public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { + const searchOptions = { + strategy: ENHANCED_ES_SEARCH_STRATEGY, + ...options, + }; + const { sessionId, abortSignal } = searchOptions; + + return this.createRequestHash$(request, searchOptions).pipe( + switchMap((requestHash) => { + const { searchAbortController, response$ } = this.getSearchResponse$( + request, + searchOptions, + requestHash + ); + + this.pendingCount$.next(this.pendingCount$.getValue() + 1); + const untrackSearch = this.deps.session.isCurrentSession(sessionId) + ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) + : undefined; + + // Abort the replay if the abortSignal is aborted. + // The underlaying search will not abort unless searchAbortController fires. + const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe( + map(() => { + throw new AbortError(); + }) + ); + + return response$.pipe( + takeUntil(aborted$), + catchError((e) => { + return throwError( + this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) + ); + }), + finalize(() => { + this.pendingCount$.next(this.pendingCount$.getValue() - 1); + if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + }) + ); + }) + ); + } + + private showTimeoutErrorToast = (e: SearchTimeoutError, sessionId?: string) => { + this.deps.toasts.addDanger({ + title: 'Timed out', + text: toMountPoint(e.getErrorMessage(this.application)), + }); + }; + + private showTimeoutErrorMemoized = memoize( + this.showTimeoutErrorToast, + (_: SearchTimeoutError, sessionId: string) => { + return sessionId; + } + ); + + /** + * Show one error notification per session. + * @internal + */ + private showTimeoutError = (e: SearchTimeoutError, sessionId?: string) => { + if (sessionId) { + this.showTimeoutErrorMemoized(e, sessionId); + } else { + this.showTimeoutErrorToast(e, sessionId); + } + }; + + public showError(e: Error) { + if (e instanceof AbortError || e instanceof SearchTimeoutError) { + // The SearchTimeoutError is shown by the interceptor in getSearchError (regardless of how the app chooses to handle errors) + return; + } else if (e instanceof EsError) { + this.deps.toasts.addDanger({ + title: i18n.translate('data.search.esErrorTitle', { + defaultMessage: 'Cannot retrieve search results', + }), + text: toMountPoint(e.getErrorMessage(this.application)), + }); + } else if (e.constructor.name === 'HttpFetchError') { + this.deps.toasts.addDanger({ + title: i18n.translate('data.search.httpErrorTitle', { + defaultMessage: 'Cannot retrieve your data', + }), + text: toMountPoint(getHttpError(e.message)), + }); + } else { + this.deps.toasts.addError(e, { + title: 'Search Error', + }); + } + } +} + +export type ISearchInterceptor = PublicMethodsOf; diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts b/src/plugins/data/public/search/search_interceptor/search_response_cache.test.ts similarity index 97% rename from x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts rename to src/plugins/data/public/search/search_interceptor/search_response_cache.test.ts index e985de5e23f7d..df277c289f973 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_response_cache.test.ts @@ -1,8 +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. + * 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 { interval, Observable, of, throwError } from 'rxjs'; @@ -14,7 +15,7 @@ import { SearchResponseCache } from './search_response_cache'; describe('SearchResponseCache', () => { let cache: SearchResponseCache; let searchAbortController: SearchAbortController; - const r: Array> = [ + const r: IKibanaSearchResponse[] = [ { isPartial: true, isRunning: true, diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts b/src/plugins/data/public/search/search_interceptor/search_response_cache.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/search/search_response_cache.ts rename to src/plugins/data/public/search/search_interceptor/search_response_cache.ts index 1467e5bf234ff..1af32afdc5e90 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts +++ b/src/plugins/data/public/search/search_interceptor/search_response_cache.ts @@ -1,21 +1,22 @@ /* * 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. + * 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 { Observable, Subscription } from 'rxjs'; -import { IKibanaSearchResponse, isErrorResponse } from '../../../../../src/plugins/data/public'; import { SearchAbortController } from './search_abort_controller'; +import { IKibanaSearchResponse, isErrorResponse } from '../../../common'; interface ResponseCacheItem { - response$: Observable>; + response$: Observable; searchAbortController: SearchAbortController; } interface ResponseCacheItemInternal { - response$: Observable>; + response$: Observable; searchAbortController: SearchAbortController; size: number; subs: Subscription; diff --git a/x-pack/plugins/data_enhanced/public/search/utils.ts b/src/plugins/data/public/search/search_interceptor/utils.ts similarity index 74% rename from x-pack/plugins/data_enhanced/public/search/utils.ts rename to src/plugins/data/public/search/search_interceptor/utils.ts index c6c648dbb5488..2f2d06d3a5494 100644 --- a/x-pack/plugins/data_enhanced/public/search/utils.ts +++ b/src/plugins/data/public/search/search_interceptor/utils.ts @@ -1,8 +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. + * 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 stringify from 'json-stable-stringify'; diff --git a/src/plugins/data/public/search/search_service.test.ts b/src/plugins/data/public/search/search_service.test.ts index 7ac3a11c08446..debee42d8bf34 100644 --- a/src/plugins/data/public/search/search_service.test.ts +++ b/src/plugins/data/public/search/search_service.test.ts @@ -38,7 +38,6 @@ describe('Search service', () => { } as unknown) as SearchServiceSetupDependencies); expect(setup).toHaveProperty('aggs'); expect(setup).toHaveProperty('usageCollector'); - expect(setup).toHaveProperty('__enhance'); expect(setup).toHaveProperty('sessionsClient'); expect(setup).toHaveProperty('session'); }); diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index ec7a486445b71..28064f4736d31 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -15,7 +15,7 @@ import { } from 'src/core/public'; import { BehaviorSubject } from 'rxjs'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; -import { ISearchSetup, ISearchStart, SearchEnhancements } from './types'; +import { ISearchSetup, ISearchStart } from './types'; import { handleResponse } from './fetch'; import { @@ -147,9 +147,6 @@ export class SearchService implements Plugin { return { aggs, usageCollector: this.usageCollector!, - __enhance: (enhancements: SearchEnhancements) => { - this.searchInterceptor = enhancements.searchInterceptor; - }, session: this.sessionService, sessionsClient: this.sessionsClient, }; @@ -187,5 +184,6 @@ export class SearchService implements Plugin { public stop() { this.aggsService.stop(); this.searchSourceService.stop(); + this.searchInterceptor.stop(); } } diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 391be8e053746..8190c52b5c0c5 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -7,7 +7,6 @@ */ import { PackageInfo } from 'kibana/server'; -import { ISearchInterceptor } from './search_interceptor'; import { SearchUsageCollector } from './collectors'; import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } from './aggs'; import { ISearchGeneric, ISearchStartSearchSource } from '../../common/search'; @@ -17,10 +16,6 @@ import { ISessionsClient, ISessionService } from './session'; export { ISearchStartSearchSource, SearchUsageCollector }; -export interface SearchEnhancements { - searchInterceptor: ISearchInterceptor; -} - /** * The setup contract exposed by the Search plugin exposes the search strategy extension * point. @@ -38,10 +33,6 @@ export interface ISearchSetup { * {@link ISessionsClient} */ sessionsClient: ISessionsClient; - /** - * @internal - */ - __enhance: (enhancements: SearchEnhancements) => void; } /** diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 8686823ef0568..5ba4ba2bc48af 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -15,7 +15,7 @@ import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { FieldFormatsSetup, FieldFormatsStart } from './field_formats'; import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } from './actions'; -import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; +import { ISearchSetup, ISearchStart } from './search'; import { QuerySetup, QueryStart } from './query'; import { IndexPatternsContract } from './index_patterns'; import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui'; @@ -23,10 +23,6 @@ import { UsageCollectionSetup, UsageCollectionStart } from '../../usage_collecti import { Setup as InspectorSetup } from '../../inspector/public'; import { NowProviderPublicContract } from './now_provider'; -export interface DataPublicPluginEnhancements { - search: SearchEnhancements; -} - export interface DataSetupDependencies { bfetch: BfetchPublicSetup; expressions: ExpressionsSetup; @@ -47,10 +43,6 @@ export interface DataPublicPluginSetup { search: ISearchSetup; fieldFormats: FieldFormatsSetup; query: QuerySetup; - /** - * @internal - */ - __enhance: (enhancements: DataPublicPluginEnhancements) => void; } /** diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 383e09b4a6ebe..f52c622c48ed0 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -251,7 +251,7 @@ export class SearchService implements Plugin { private registerSearchStrategy = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, - SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse + SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse >( name: string, strategy: ISearchStrategy diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 15d3f5c403b1f..a69df282cd568 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/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 78d7c15cac5d6..ed3510c5776e0 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -715,5 +715,18 @@ export function getUiSettings(): Record> { }), schema: schema.boolean(), }, + [UI_SETTINGS.SEARCH_TIMEOUT]: { + name: i18n.translate('data.advancedSettings.searchTimeout', { + defaultMessage: 'Search Timeout', + }), + value: 600000, + description: i18n.translate('data.advancedSettings.searchTimeoutDesc', { + defaultMessage: + 'Change the maximum timeout for a search session or set to 0 to disable the timeout and allow queries to run to completion.', + }), + type: 'number', + category: ['search'], + schema: schema.number(), + }, }; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 4099d5e8ef7e2..c66ca19c96743 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -431,7 +431,7 @@ function discoverController($route, $scope) { }, }) .toPromise() - .then(onResults) + .then(({ rawResponse }) => onResults(rawResponse)) .catch((error) => { // If the request was aborted then no need to surface this error in the UI if (error instanceof Error && error.name === 'AbortError') return; diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.tsx similarity index 71% rename from src/plugins/discover/public/application/angular/helpers/row_formatter.ts rename to src/plugins/discover/public/application/angular/helpers/row_formatter.tsx index b219dda19e10a..c5e64f38a3117 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.tsx @@ -6,24 +6,29 @@ * Side Public License, v 1. */ -import { template } from 'lodash'; +import React, { Fragment } from 'react'; +import ReactDOM from 'react-dom/server'; import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../common'; import { getServices, IndexPattern } from '../../../kibana_services'; -function noWhiteSpace(html: string) { - const TAGS_WITH_WS = />\s+<'); +interface Props { + defPairs: Array<[string, unknown]>; } - -const templateHtml = ` -
- <% defPairs.forEach(function (def) { %> -
<%- def[0] %>:
-
<%= def[1] %>
- <%= ' ' %> - <% }); %> -
`; -export const doTemplate = template(noWhiteSpace(templateHtml)); +const TemplateComponent = ({ defPairs }: Props) => { + return ( +
+ {defPairs.map((pair, idx) => ( + +
{pair[0]}:
+
{' '} + + ))} +
+ ); +}; export const formatRow = (hit: Record, indexPattern: IndexPattern) => { const highlights = hit?.highlight ?? {}; @@ -38,7 +43,9 @@ export const formatRow = (hit: Record, indexPattern: IndexPattern) pairs.push([displayKey ? displayKey : key, val]); }); const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); + return ReactDOM.renderToStaticMarkup( + + ); }; export const formatTopLevelObject = ( @@ -70,5 +77,7 @@ export const formatTopLevelObject = ( pairs.push([displayKey ? displayKey : key, formatted]); }); const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); + return ReactDOM.renderToStaticMarkup( + + ); }; 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/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index dbaf07fed18c2..99ecb4c11eef2 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -325,7 +325,7 @@ export class SearchEmbeddable try { // Make the request - const resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: this.abortController.signal, sessionId: searchSessionId, 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/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_default_editor/public/components/agg_add.tsx b/src/plugins/vis_default_editor/public/components/agg_add.tsx index 37ef08ec640f0..bbe235082e13e 100644 --- a/src/plugins/vis_default_editor/public/components/agg_add.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_add.tsx @@ -52,7 +52,7 @@ function DefaultEditorAggAdd({ const addButton = ( setIsPopoverOpen(!isPopoverOpen)} @@ -88,7 +88,7 @@ function DefaultEditorAggAdd({ repositionOnScroll={true} closePopover={() => setIsPopoverOpen(false)} > - + {(groupName !== AggGroupNames.Buckets || !stats.count) && ( 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/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.scss b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.scss new file mode 100644 index 0000000000000..bd27116735dbe --- /dev/null +++ b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.scss @@ -0,0 +1,3 @@ +.aggBasedDialog__card { + background-color: $euiColorEmptyShade; +} diff --git a/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx index 56973cc05db2f..c0f55ce21cefd 100644 --- a/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx +++ b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx @@ -28,6 +28,7 @@ import { memoizeLast } from '../../legacy/memoize'; import type { BaseVisType, TypesStart } from '../../vis_types'; import { VisGroups } from '../../vis_types'; import { DialogNavigation } from '../dialog_navigation'; +import './agg_based_selection.scss'; interface VisTypeListEntry { type: BaseVisType; @@ -137,6 +138,7 @@ class AggBasedSelection extends React.Component} + className="aggBasedDialog__card" /> ); diff --git a/src/plugins/visualize/public/application/components/visualize_listing.scss b/src/plugins/visualize/public/application/components/visualize_listing.scss index c3b0df67e317d..840ebf89c129f 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.scss +++ b/src/plugins/visualize/public/application/components/visualize_listing.scss @@ -21,7 +21,12 @@ } .visListingCallout { - max-width: 1000px; + @include kbnThemeStyle('v7') { + max-width: 1000px; + } + @include kbnThemeStyle('v8') { + max-width: 1200px; + } width: 100%; margin-left: auto; 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/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/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/vars/workers.groovy b/vars/workers.groovy index 1260f74f1bdf9..83d439934cbfa 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -71,7 +71,7 @@ def base(Map params, Closure closure) { if (config.scm) { // Try to clone from Github up to 8 times, waiting 15 secs between attempts retryWithDelay(8, 15) { - checkout scm + kibanaCheckout() } dir("kibana") { 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/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 76f6a62ce6597..fbd9a8cddbdcb 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -21,6 +21,7 @@ const createActionsConfigMock = () => { maxContentLength: 1000000, timeout: 360000, }), + getCustomHostSettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 70c8b0e8185d5..925e77ca85fb2 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -13,8 +13,14 @@ import { AllowedHosts, EnabledActionTypes, } from './actions_config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; +import { Logger } from '../../../../src/core/server'; +import { loggingSystemMock } from '../../../../src/core/server/mocks'; + import moment from 'moment'; +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], @@ -355,4 +361,79 @@ describe('getProxySettings', () => { const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts)); }); + + test('getCustomHostSettings() returns undefined when no matching config', () => { + const httpsUrl = 'https://elastic.co/foo/bar'; + const smtpUrl = 'smtp://elastic.co'; + let config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://www.elastic.co:443', + }, + ], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + }); + + test('getCustomHostSettings() returns matching config', () => { + const httpsUrl = 'https://elastic.co/ignoring/paths/here'; + const smtpUrl = 'smtp://elastic.co:123'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'smtp://elastic.co:123', + tls: { + rejectUnauthorized: false, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(config.customHostSettings![0]); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(config.customHostSettings![1]); + }); + + test('getCustomHostSettings() returns undefined when bad url is passed in', () => { + const badUrl = 'https://elastic.co/foo/bar'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + const chs = getActionsConfigurationUtilities(config).getCustomHostSettings(badUrl); + expect(chs).toEqual(undefined); + }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 4c73cab76f9e8..b8cd5878a8972 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,8 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes } from './config'; +import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings } from './types'; @@ -32,6 +33,7 @@ export interface ActionsConfigurationUtilities { isRejectUnauthorizedCertificatesEnabled: () => boolean; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; + getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -107,6 +109,27 @@ function getResponseSettingsFromConfig(config: ActionsConfig): ResponseSettings }; } +function getCustomHostSettings( + config: ActionsConfig, + targetUrl: string +): CustomHostSettings | undefined { + const customHostSettings = config.customHostSettings; + if (!customHostSettings) { + return; + } + + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(targetUrl); + } catch (err) { + // presumably this bad URL is reported elsewhere + return; + } + + const canonicalUrl = getCanonicalCustomHostUrl(parsedUrl); + return customHostSettings.find((settings) => settings.url === canonicalUrl); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { @@ -119,6 +142,7 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), + // returns the global rejectUnauthorized setting isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { @@ -135,5 +159,6 @@ export function getActionsConfigurationUtilities( throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config'); } }, + getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl), }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 4596619c50940..5747b4bbb28f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -282,6 +282,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -342,6 +343,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts new file mode 100644 index 0000000000000..80bf51e19c379 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -0,0 +1,277 @@ +/* + * 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 { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import http from 'http'; +import https from 'https'; +import axios from 'axios'; +import { duration as momentDuration } from 'moment'; +import { schema } from '@kbn/config-schema'; + +import { request } from './axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { createReadySignal } from '../../../../event_log/server/lib/ready_signal'; +import { ActionsConfig } from '../../config'; +import { + ActionsConfigurationUtilities, + getActionsConfigurationUtilities, +} from '../../actions_config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +describe('axios connections', () => { + let testServer: http.Server | https.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeAll(() => { + // needed to prevent the dreaded Error: Cross origin http://localhost forbidden + // see: https://github.com/axios/axios/issues/1754#issuecomment-572778305 + savedAxiosDefaultsAdapter = axios.defaults.adapter; + axios.defaults.adapter = require('axios/lib/adapters/http'); + }); + + afterAll(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer.close(); + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer(); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + }); + + describe('https', () => { + test('it fails with self-signed cert and no overrides', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with rejectUnauthorized false config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with rejectUnauthorized custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { rejectUnauthorized: false } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with incorrect ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized false', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + rejectUnauthorized: false, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized config true', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with no matching custom host settings', async () => { + const { url, server } = await createServer(true); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, tls: { rejectUnauthorized: false } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 1', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 2', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); +}); + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(useHttps: boolean = false): Promise { + let server: http.Server | https.Server; + const readySignal = createReadySignal(); + + if (!useHttps) { + server = http.createServer((req, res) => { + res.writeHead(200); + res.end('http: just testing that a connection could be made'); + }); + } else { + const httpsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, + }; + server = https.createServer(httpsOptions, (req, res) => { + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + } + + server.listen(() => { + const addressInfo = server.address(); + if (addressInfo == null || typeof addressInfo === 'string') { + server.close(); + throw new Error('error getting address of server, closing'); + } + + const url = localUrlFromPort(useHttps, addressInfo.port, 'localhost'); + readySignal.signal({ server, url }); + }); + + // let the node process stop if for some reason this server isn't closed + server.unref(); + + return readySignal.wait(); +} + +const BaseActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + return getActionsConfigurationUtilities({ + ...BaseActionsConfig, + ...config, + }); +} + +function localUrlFromPort(useHttps: boolean, port: number, host: string): string { + return `${useHttps ? 'https' : 'http'}://${host}:${port}`; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index f6d1be9bffc6b..805c22806ce4c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -16,11 +16,16 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const targetHost = 'elastic.co'; const targetUrl = `https://${targetHost}/foo/bar/baz`; +const targetUrlCanonical = `https://${targetHost}:443`; const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; describe('getCustomAgents', () => { const configurationUtilities = actionsConfigMock.create(); + beforeEach(() => { + jest.resetAllMocks(); + }); + test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', @@ -106,4 +111,117 @@ describe('getCustomAgents', () => { expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); }); + + test('handles custom host settings', () => { + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles custom host settings with proxy', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles overriding global rejectUnauthorized false', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); + + test('handles overriding global rejectUnauthorized false with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index ff2d005f4d841..6ec926004e73e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -6,7 +6,7 @@ */ import { Agent as HttpAgent } from 'http'; -import { Agent as HttpsAgent } from 'https'; +import { Agent as HttpsAgent, AgentOptions } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; @@ -22,7 +22,8 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const proxySettings = configurationUtilities.getProxySettings(); + // the default for rejectUnauthorized is the global setting, which can + // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ @@ -30,10 +31,39 @@ export function getCustomAgents( }), }; + // Get the current proxy settings, and custom host settings for this URL. + // If there are neither of these, return the default agents + const proxySettings = configurationUtilities.getProxySettings(); + const customHostSettings = configurationUtilities.getCustomHostSettings(url); + if (!proxySettings && !customHostSettings) { + return defaultAgents; + } + + // update the defaultAgents.httpsAgent if configured + const tlsSettings = customHostSettings?.tls; + let agentOptions: AgentOptions | undefined; + if (tlsSettings) { + logger.debug(`Creating customized connection settings for: ${url}`); + agentOptions = defaultAgents.httpsAgent.options; + + if (tlsSettings.certificateAuthoritiesData) { + agentOptions.ca = tlsSettings.certificateAuthoritiesData; + } + + // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts + // This is where the global rejectUnauthorized is overridden by a custom host + if (tlsSettings.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = tlsSettings.rejectUnauthorized; + } + } + + // if there weren't any proxy settings, return the currently calculated agents if (!proxySettings) { return defaultAgents; } + // there is a proxy in use, but it's possible we won't use it via custom host + // proxyOnlyHosts and proxyBypassHosts let targetUrl: URL; try { targetUrl = new URL(url); @@ -56,6 +86,7 @@ export function getCustomAgents( return defaultAgents; } } + logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); let proxyUrl: URL; try { @@ -65,6 +96,9 @@ export function getCustomAgents( return defaultAgents; } + // At this point, we are going to use a proxy, so we need new agents. + // We will though, copy over the calculated tls options from above, into + // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ host: proxyUrl.hostname, @@ -76,5 +110,12 @@ export function getCustomAgents( }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it + if (agentOptions) { + httpsAgent.options = { + ...httpsAgent.options, + ...agentOptions, + }; + } + return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 4b45c6d787cd6..cceeefde71dc2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; +import { CustomHostSettings } from '../../config'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -356,16 +357,151 @@ describe('send_email module', () => { ] `); }); + + test('it handles custom host settings from config', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + }, + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // note in the object below, the rejectUnauthenticated got set to false, + // given the implementation allowing that for no auth and !secure. + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it allows custom host settings to override calculated values', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + requireTLS: false, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // in this case, rejectUnauthorized is true, as the custom host settings + // overrode the calculated value of false + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "ignoreTLS": true, + "port": 1025, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); + + test('it handles custom host settings with a proxy', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": undefined, + "host": "example.com", + "port": 1025, + "proxy": "https://proxy.com", + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); }); function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, @@ -392,12 +528,16 @@ function getSendEmailOptions( function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index c0a254967b4fe..005e73b1fc2f7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -11,6 +11,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { CustomHostSettings } from '../../config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -52,7 +53,10 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom const { from, to, cc, bcc } = routing; const { subject, message } = content; - const transportConfig: Record = {}; + // The transport options do not seem to be exposed as a type, and we reference + // some deep properties, so need to use any here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); @@ -73,6 +77,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom useProxy = false; } } + let customHostSettings: CustomHostSettings | undefined; if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; @@ -83,6 +88,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom transportConfig.host = host; transportConfig.port = port; transportConfig.secure = !!secure; + customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { transportConfig.tls = { @@ -99,6 +105,33 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom } else { transportConfig.tls = { rejectUnauthorized }; } + + // finally, allow customHostSettings to override some of the settings + // see: https://nodemailer.com/smtp/ + if (customHostSettings) { + const tlsConfig: Record = {}; + const tlsSettings = customHostSettings.tls; + const smtpSettings = customHostSettings.smtp; + + if (tlsSettings?.certificateAuthoritiesData) { + tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + } + if (tlsSettings?.rejectUnauthorized !== undefined) { + tlsConfig.rejectUnauthorized = tlsSettings?.rejectUnauthorized; + } + + if (!transportConfig.tls) { + transportConfig.tls = tlsConfig; + } else { + transportConfig.tls = { ...transportConfig.tls, ...tlsConfig }; + } + + if (smtpSettings?.ignoreTLS) { + transportConfig.ignoreTLS = true; + } else if (smtpSettings?.requireTLS) { + transportConfig.requireTLS = true; + } + } } const nodemailerTransport = nodemailer.createTransport(transportConfig); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index 8a185d353de02..95088fa5f7965 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -167,6 +167,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -230,6 +231,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index d3f059eede615..00e56303dbe22 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -290,6 +290,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -382,6 +383,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 092b5d2cce587..4c4fd143369e1 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -164,6 +164,19 @@ describe('config validation', () => { ] `); }); + + // Most of the customHostSettings tests are in ./lib/custom_host_settings.test.ts + // but this one seemed more relevant for this test suite, since url is the one + // required property. + test('validates customHostSettings contains a URL', () => { + const config: Record = { + customHostSettings: [{}], + }; + + expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot( + `"[customHostSettings.0.url]: expected value of type [string] but got [undefined]"` + ); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 7225c54d57596..0dc1aed68f4d0 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -23,6 +23,30 @@ const preconfiguredActionSchema = schema.object({ secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), }); +const customHostSettingsSchema = schema.object({ + url: schema.string({ minLength: 1 }), + smtp: schema.maybe( + schema.object({ + ignoreTLS: schema.maybe(schema.boolean()), + requireTLS: schema.maybe(schema.boolean()), + }) + ), + tls: schema.maybe( + schema.object({ + rejectUnauthorized: schema.maybe(schema.boolean()), + certificateAuthoritiesFiles: schema.maybe( + schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]) + ), + certificateAuthoritiesData: schema.maybe(schema.string({ minLength: 1 })), + }) + ), +}); + +export type CustomHostSettings = TypeOf; + export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), allowedHosts: schema.arrayOf( @@ -50,6 +74,7 @@ export const configSchema = schema.object({ rejectUnauthorized: schema.boolean({ defaultValue: true }), maxResponseContentLength: schema.byteSize({ defaultValue: '1mb' }), responseTimeout: schema.duration({ defaultValue: '60s' }), + customHostSettings: schema.maybe(schema.arrayOf(customHostSettingsSchema)), cleanupFailedExecutionsTask: schema.object({ enabled: schema.boolean({ defaultValue: true }), cleanupInterval: schema.duration({ defaultValue: '5m' }), diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts new file mode 100644 index 0000000000000..ad07ea21d7917 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -0,0 +1,504 @@ +/* + * 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 { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +import { ActionsConfig } from '../config'; +import { Logger } from '../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; + +import { resolveCustomHosts, getCanonicalCustomHostUrl } from './custom_host_settings'; + +const CA_DIR = '../../../../../../packages/kbn-dev-utils/certs'; +const CA_FILE1 = pathResolve(__filename, pathJoin(CA_DIR, 'ca.crt')); +const CA_CONTENTS1 = fsReadFileSync(CA_FILE1, 'utf8'); +const CA_FILE2 = pathResolve(__filename, pathJoin(CA_DIR, 'kibana.crt')); +const CA_CONTENTS2 = fsReadFileSync(CA_FILE2, 'utf8'); + +let mockLogger: Logger = loggingSystemMock.create().get(); + +function warningLogs() { + const calls = loggingSystemMock.collect(mockLogger).warn; + return calls.map((call) => `${call[0]}`); +} + +describe('custom_host_settings', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockLogger = loggingSystemMock.create().get(); + }); + + describe('getCanonicalCustomHostUrl()', () => { + test('minimal urls', () => { + expect(getCanonicalCustomHostUrl(new URL('http://elastic.com'))).toBe( + 'http://elastic.com:80' + ); + expect(getCanonicalCustomHostUrl(new URL('https://elastic.co'))).toBe( + 'https://elastic.co:443' + ); + expect(getCanonicalCustomHostUrl(new URL('smtp://mail.elastic.co'))).toBe( + 'smtp://mail.elastic.co:25' + ); + expect(warningLogs()).toEqual([]); + }); + + test('maximal urls', () => { + expect( + getCanonicalCustomHostUrl(new URL('http://user1:pass1@elastic.co:81/foo?bar#car')) + ).toBe('http://elastic.co:81'); + expect( + getCanonicalCustomHostUrl(new URL('https://user1:pass1@elastic.co:82/foo?bar#car')) + ).toBe('https://elastic.co:82'); + expect( + getCanonicalCustomHostUrl(new URL('smtp://user1:pass1@mail.elastic.co:83/foo?bar#car')) + ).toBe('smtp://mail.elastic.co:83'); + expect(warningLogs()).toEqual([]); + }); + }); + + describe('resolveCustomHosts()', () => { + const defaultActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: [], + enabledActionTypes: [], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, + maxResponseContentLength: new ByteSizeValue(1000000), + responseTimeout: moment.duration(60000), + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, + }; + + test('ensure it copies over the config parts that it does not touch', () => { + const config: ActionsConfig = { ...defaultActionsConfig }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles undefined customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: undefined }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles empty object customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: [] }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple valid settings', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co:443', + tls: { + certificateAuthoritiesData: 'xyz', + rejectUnauthorized: false, + }, + }, + { + url: 'smtp://mail.elastic.com:25', + tls: { + certificateAuthoritiesData: 'abc', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles bad url', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'this! is! not! a! url!', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, invalid URL \\"this! is! not! a! url!\\", ignoring; error: Invalid URL: this! is! not! a! url!", + ] + `); + }); + + test('handles bad port', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:0', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unable to determine port for URL \\"https://almost.purrfect.com:0\\", ignoring", + ] + `); + }); + + test('handles auth info', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://kitty:cat@almost.purrfect.com', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://kitty:cat@almost.purrfect.com\\" contains authentication information which will be ignored, but should be removed from the configuration", + ] + `); + }); + + test('handles hash', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com#important', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com#important\\" contains hash information which will be ignored", + ] + `); + }); + + test('handles path', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/about', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/about\\" contains path information which will be ignored", + ] + `); + }); + + test('handles / path same as no path, since we have no choice', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles unsupported URL protocols', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'http://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unsupported protocol used in URL \\"http://almost.purrfect.com/\\", ignoring", + ] + `); + }); + + test('handles smtp options for non-smtp urls', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/\\" contains smtp properties but does not use smtp; ignoring smtp properties", + ] + `); + }); + + test('handles ca files not found', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "error reading file \\"this-file-does-not-exist\\" specified in xpack.actions.customHosts, ignoring: ENOENT: no such file or directory, open 'this-file-does-not-exist'", + ] + `); + }); + + test('handles a single ca file', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: CA_FILE1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple ca files', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles ca files and ca data', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE2], + certificateAuthoritiesData: CA_CONTENTS1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles smtp ignoreTLS and requireTLS both used', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + requireTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com:25', + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"smtp://almost.purrfect.com/\\" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false", + ] + `); + }); + + test('handles duplicate URLs', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: false, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, multiple URLs match the canonical url \\"https://almost.purrfect.com:443\\"; only the first will be used", + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts new file mode 100644 index 0000000000000..bfc8dad48aab6 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -0,0 +1,173 @@ +/* + * 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 { readFileSync } from 'fs'; +import { cloneDeep } from 'lodash'; +import { Logger } from '../../../../../src/core/server'; +import { ActionsConfig, CustomHostSettings } from '../config'; + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +type ActionsConfigWriteable = DeepWriteable; +type CustomHostSettingsWriteable = DeepWriteable; + +export function getCanonicalCustomHostUrl(url: URL): string { + const port = getActualPort(url.protocol, url.port); + + return `${url.protocol}//${url.hostname}:${port}`; +} + +const ErrorPrefix = 'In configuration xpack.actions.customHosts,'; +const ValidProtocols = new Set(['https:', 'smtp:']); +const ProtocolsForSmtp = new Set(['smtp:']); + +// converts the custom host data in config, for ease of use, and to perform +// validation we can't do in config-schema, since the cloud validation can't +// do these sorts of validations +export function resolveCustomHosts(logger: Logger, config: ActionsConfig): ActionsConfig { + const result: ActionsConfigWriteable = cloneDeep(config); + + if (!result.customHostSettings) { + return result as ActionsConfig; + } + + const savedSettings: CustomHostSettingsWriteable[] = []; + + for (const customHostSetting of result.customHostSettings) { + const originalUrl = customHostSetting.url; + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(originalUrl); + } catch (err) { + logger.warn(`${ErrorPrefix} invalid URL "${originalUrl}", ignoring; error: ${err.message}`); + continue; + } + + customHostSetting.url = getCanonicalCustomHostUrl(parsedUrl); + + if (!ValidProtocols.has(parsedUrl.protocol)) { + logger.warn(`${ErrorPrefix} unsupported protocol used in URL "${originalUrl}", ignoring`); + continue; + } + + const port = getActualPort(parsedUrl.protocol, parsedUrl.port); + if (!port) { + logger.warn(`${ErrorPrefix} unable to determine port for URL "${originalUrl}", ignoring`); + continue; + } + + if (parsedUrl.username || parsedUrl.password) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains authentication information which will be ignored, but should be removed from the configuration` + ); + } + + if (parsedUrl.hash) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains hash information which will be ignored` + ); + } + + if (parsedUrl.pathname && parsedUrl.pathname !== '/') { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains path information which will be ignored` + ); + } + + if (!ProtocolsForSmtp.has(parsedUrl.protocol) && customHostSetting.smtp) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains smtp properties but does not use smtp; ignoring smtp properties` + ); + delete customHostSetting.smtp; + } + + // read the specified ca files, add their content to certificateAuthoritiesData + if (customHostSetting.tls) { + let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (typeof files === 'string') { + files = [files]; + } + for (const file of files) { + const contents = getFileContents(logger, file); + if (contents) { + appendToCertificateAuthoritiesData(customHostSetting, contents); + } + } + } + + const customSmtpSettings = customHostSetting.smtp; + if (customSmtpSettings) { + if (customSmtpSettings.requireTLS && customSmtpSettings.ignoreTLS) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false` + ); + customSmtpSettings.requireTLS = true; + customSmtpSettings.ignoreTLS = false; + } + } + + savedSettings.push(customHostSetting); + } + + // check to see if there are any dups on the url + const existingUrls = new Set(); + for (const customHostSetting of savedSettings) { + const url = customHostSetting.url; + if (existingUrls.has(url)) { + logger.warn( + `${ErrorPrefix} multiple URLs match the canonical url "${url}"; only the first will be used` + ); + // mark this entry to be able to delete it after processing them all + customHostSetting.url = ''; + } + existingUrls.add(url); + } + + // remove the settings we want to skip + result.customHostSettings = savedSettings.filter((setting) => setting.url !== ''); + + return result as ActionsConfig; +} + +function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { + const tls = customHost.tls; + if (tls) { + if (!tls.certificateAuthoritiesData) { + tls.certificateAuthoritiesData = cert; + } else { + tls.certificateAuthoritiesData += '\n' + cert; + } + } +} + +function getFileContents(logger: Logger, fileName: string): string | undefined { + try { + return readFileSync(fileName, 'utf8'); + } catch (err) { + logger.warn( + `error reading file "${fileName}" specified in xpack.actions.customHosts, ignoring: ${err.message}` + ); + return; + } +} + +// 0 isn't a valid port, so result can be checked as falsy +function getActualPort(protocol: string, port: string): number { + if (port !== '') { + const portNumber = parseInt(port, 10); + if (isNaN(portNumber)) { + return 0; + } + return portNumber; + } + + // from https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_port + if (protocol === 'http:') return 80; + if (protocol === 'https:') return 443; + if (protocol === 'smtp:') return 25; + return 0; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 106e41259e692..2036ed6c7d343 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -35,6 +35,7 @@ import { } from './cleanup_failed_executions'; import { ActionsConfig, getValidatedConfig } from './config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; import { createExecutionEnqueuerFunction } from './create_execute_function'; @@ -157,7 +158,10 @@ export class ActionsPlugin implements Plugin()); + this.actionsConfig = getValidatedConfig( + this.logger, + resolveCustomHosts(this.logger, initContext.config.get()) + ); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; this.kibanaIndexConfig = initContext.config.legacy.get(); 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 { ); }); + 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/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index b2e8ca5fda805..a8435beca1e4a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -88,10 +88,11 @@ export function PageLoadDistribution() { const exploratoryViewLink = createExploratoryViewUrl( { [`${serviceName}-page-views`]: { + dataType: 'ux', reportType: 'pld', time: { from: rangeFrom!, to: rangeTo! }, reportDefinitions: { - 'service.name': serviceName?.[0] as string, + 'service.name': serviceName as string[], }, ...(breakdown ? { breakdown: breakdown.fieldName } : {}), }, @@ -99,6 +100,8 @@ export function PageLoadDistribution() { http?.basePath.get() ); + const showAnalyzeButton = false; + return (
@@ -118,18 +121,20 @@ export function PageLoadDistribution() { dataTestSubj={'pldBreakdownFilter'} /> - - - - - + {showAnalyzeButton && ( + + + + + + )} @@ -89,18 +92,20 @@ export function PageViewsTrend() { dataTestSubj={'pvBreakdownFilter'} /> - - - - - + {showAnalyzeButton && ( + + + + + + )} 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 dc071fe93bbbd..9353c37b90728 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 @@ -10,7 +10,6 @@ import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import { useApmServiceContext } from '../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; import * as urlHelpers from './Links/url_helpers'; // The default transaction type (for non-RUM services) is "request". Set the @@ -21,11 +20,8 @@ const EuiSelectWithWidth = styled(EuiSelect)` `; export function TransactionTypeSelect() { - const { transactionTypes } = useApmServiceContext(); + const { transactionTypes, transactionType } = useApmServiceContext(); const history = useHistory(); - const { - urlParams: { transactionType }, - } = useUrlParams(); const handleChange = useCallback( (event: FormEvent) => { 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/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 992bceaa3390c..e77717d689f95 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -5,7 +5,7 @@ * 2.0. */ -import squel from 'squel'; +import squel from 'safe-squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; /* eslint-disable */ import { queryEsSQL } from '../../../server/lib/query_es_sql'; 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 8f5bef8668fbe..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 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/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/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 82f04d82ea2f8..ebc270e96767e 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,7 +17,6 @@ import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; -import { EnhancedSearchInterceptor } from './search/search_interceptor'; import { registerSearchSessionsMgmt } from './search/sessions_mgmt'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; import { createConnectedSearchSessionIndicator } from './search'; @@ -39,7 +38,6 @@ export type DataEnhancedStart = ReturnType; export class DataEnhancedPlugin implements Plugin { - private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; private readonly storage = new Storage(window.localStorage); private usageCollector?: SearchUsageCollector; @@ -50,22 +48,6 @@ export class DataEnhancedPlugin core: CoreSetup, { bfetch, data, management }: DataEnhancedSetupDependencies ) { - this.enhancedSearchInterceptor = new EnhancedSearchInterceptor({ - bfetch, - toasts: core.notifications.toasts, - http: core.http, - uiSettings: core.uiSettings, - startServices: core.getStartServices(), - usageCollector: data.search.usageCollector, - session: data.search.session, - }); - - data.__enhance({ - search: { - searchInterceptor: this.enhancedSearchInterceptor, - }, - }); - this.config = this.initializerContext.config.get(); if (this.config.search.sessions.enabled) { const sessionsConfig = this.config.search.sessions; @@ -96,7 +78,5 @@ export class DataEnhancedPlugin } } - public stop() { - this.enhancedSearchInterceptor.stop(); - } + public stop() {} } diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts deleted file mode 100644 index 0e511c545f3e2..0000000000000 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ /dev/null @@ -1,1111 +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 type { MockedKeys } from '@kbn/utility-types/jest'; -import { coreMock } from '../../../../../src/core/public/mocks'; -import { EnhancedSearchInterceptor } from './search_interceptor'; -import { CoreSetup, CoreStart } from 'kibana/public'; -import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { - ISessionService, - SearchTimeoutError, - SearchSessionState, - PainlessError, - DataPublicPluginSetup, -} from 'src/plugins/data/public'; -import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; -import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; -import { BehaviorSubject } from 'rxjs'; -import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; - -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - -const timeTravel = async (msToRun = 0) => { - await flushPromises(); - jest.advanceTimersByTime(msToRun); - return flushPromises(); -}; - -const next = jest.fn(); -const error = jest.fn(); -const complete = jest.fn(); - -let searchInterceptor: EnhancedSearchInterceptor; -let mockCoreSetup: MockedKeys; -let mockCoreStart: MockedKeys; -let fetchMock: jest.Mock; - -jest.useFakeTimers(); - -jest.mock('./utils', () => ({ - createRequestHash: jest.fn().mockImplementation((input) => { - return Promise.resolve(JSON.stringify(input)); - }), -})); - -function mockFetchImplementation(responses: any[]) { - let i = 0; - fetchMock.mockImplementation((r) => { - if (!r.request.id) i = 0; - const { time = 0, value = {}, isError = false } = responses[i++]; - value.meta = { - size: 10, - }; - return new Promise((resolve, reject) => - setTimeout(() => { - return (isError ? reject : resolve)(value); - }, time) - ); - }); -} - -describe('EnhancedSearchInterceptor', () => { - let sessionService: jest.Mocked; - let sessionState$: BehaviorSubject; - let dataPluginMockSetup: DataPublicPluginSetup; - - beforeEach(() => { - mockCoreSetup = coreMock.createSetup(); - mockCoreStart = coreMock.createStart(); - sessionState$ = new BehaviorSubject(SearchSessionState.None); - dataPluginMockSetup = dataPluginMock.createSetupContract(); - const dataPluginMockStart = dataPluginMock.createStartContract(); - sessionService = { - ...(dataPluginMockStart.search.session as jest.Mocked), - state$: sessionState$, - }; - fetchMock = jest.fn(); - - mockCoreSetup.uiSettings.get.mockImplementation((name: string) => { - switch (name) { - case UI_SETTINGS.SEARCH_TIMEOUT: - return 1000; - default: - return; - } - }); - - next.mockClear(); - error.mockClear(); - complete.mockClear(); - jest.clearAllTimers(); - - const mockPromise = new Promise((resolve) => { - resolve([ - { - application: mockCoreStart.application, - }, - ]); - }); - - const bfetchMock = bfetchPluginMock.createSetupContract(); - bfetchMock.batchedFunction.mockReturnValue(fetchMock); - - searchInterceptor = new EnhancedSearchInterceptor({ - bfetch: bfetchMock, - toasts: mockCoreSetup.notifications.toasts, - startServices: mockPromise as any, - http: mockCoreSetup.http, - uiSettings: mockCoreSetup.uiSettings, - usageCollector: dataPluginMockSetup.search.usageCollector, - session: sessionService, - }); - }); - - describe('errors', () => { - test('Should throw Painless error on server error with OSS format', async () => { - const mockResponse: any = { - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: xpackResourceNotFoundException.error, - }; - fetchMock.mockRejectedValueOnce(mockResponse); - const response = searchInterceptor.search({ - params: {}, - }); - await expect(response.toPromise()).rejects.toThrow(PainlessError); - }); - - test('Renders a PainlessError', async () => { - searchInterceptor.showError( - new PainlessError({ - statusCode: 400, - message: 'search_phase_execution_exception', - attributes: xpackResourceNotFoundException.error, - }) - ); - expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); - expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); - }); - }); - - describe('search', () => { - test('should resolve immediately if first call returns full result', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}); - response.subscribe({ next, error, complete }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); - expect(complete).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - }); - - test('should make secondary request if first call returns partial result', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error, complete }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); - expect(complete).not.toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - - await timeTravel(20); - - expect(next).toHaveBeenCalledTimes(2); - expect(next.mock.calls[1][0]).toStrictEqual(responses[1].value); - expect(complete).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - }); - - test('should abort if request is partial and not running (ES graceful error)', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(Error); - }); - - test('should abort on user abort', async () => { - const responses = [ - { - time: 500, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const abortController = new AbortController(); - abortController.abort(); - - const response = searchInterceptor.search({}, { abortSignal: abortController.signal }); - response.subscribe({ next, error }); - - await timeTravel(500); - - expect(next).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - }); - - test('should DELETE a running async search on abort', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 300, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 250); - - const response = searchInterceptor.search( - {}, - { abortSignal: abortController.signal, pollInterval: 0 } - ); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - - await timeTravel(240); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('should not DELETE a running async search on async timeout prior to first response', async () => { - const responses = [ - { - time: 2000, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error }); - - await timeTravel(1000); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(fetchMock).toHaveBeenCalled(); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - }); - - test('should DELETE a running async search on async timeout after first response', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 2000, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalled(); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - - // Long enough to reach the timeout but not long enough to reach the next response - await timeTravel(1000); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('should DELETE a running async search on async timeout on error from fetch', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 10, - value: { - statusCode: 500, - message: 'oh no', - id: 1, - }, - isError: true, - }, - ]; - mockFetchImplementation(responses); - - const response = searchInterceptor.search({}, { pollInterval: 0 }); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalled(); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - - // Long enough to reach the timeout but not long enough to reach the next response - await timeTravel(10); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(Error); - expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('should NOT DELETE a running SAVED async search on abort', async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 300, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - mockFetchImplementation(responses); - - const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 250); - - const response = searchInterceptor.search( - {}, - { abortSignal: abortController.signal, pollInterval: 0, sessionId } - ); - response.subscribe({ next, error }); - - await timeTravel(10); - - expect(next).toHaveBeenCalled(); - expect(error).not.toHaveBeenCalled(); - - sessionState$.next(SearchSessionState.BackgroundLoading); - - await timeTravel(240); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); - }); - }); - - describe('session tracking', () => { - beforeEach(() => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - { - time: 300, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - }); - - test('should track searches', async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); - response.subscribe({ next, error }); - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).not.toBeCalled(); - await timeTravel(300); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).toBeCalledTimes(1); - }); - - test('session service should be able to cancel search', async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response = searchInterceptor.search({}, { pollInterval: 0, sessionId }); - response.subscribe({ next, error }); - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(1); - - const abort = sessionService.trackSearch.mock.calls[0][0].abort; - expect(abort).toBeInstanceOf(Function); - - abort(); - - await timeTravel(10); - - expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - }); - - test("don't track non current session searches", async () => { - const sessionId = 'sessionId'; - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response1 = searchInterceptor.search( - {}, - { pollInterval: 0, sessionId: 'something different' } - ); - response1.subscribe({ next, error }); - - const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); - response2.subscribe({ next, error }); - - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(0); - }); - - test("don't track if no current session", async () => { - sessionService.getSessionId.mockImplementation(() => undefined); - sessionService.isCurrentSession.mockImplementation((_sessionId) => false); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const response1 = searchInterceptor.search( - {}, - { pollInterval: 0, sessionId: 'something different' } - ); - response1.subscribe({ next, error }); - - const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined }); - response2.subscribe({ next, error }); - - await timeTravel(10); - expect(sessionService.trackSearch).toBeCalledTimes(0); - }); - }); - - describe('session client caching', () => { - const sessionId = 'sessionId'; - const basicReq = { - params: { - test: 1, - }, - }; - - const basicCompleteResponse = [ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - - const partialCompleteResponse = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - rawResponse: { - took: 1, - }, - }, - }, - ]; - - beforeEach(() => { - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - }); - - test('should be disabled if there is no session', async () => { - mockFetchImplementation(basicCompleteResponse); - - searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should fetch different requests in a single session', async () => { - mockFetchImplementation(basicCompleteResponse); - - const req2 = { - params: { - test: 2, - }, - }; - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should fetch the same request for two different sessions', async () => { - mockFetchImplementation(basicCompleteResponse); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor - .search(basicReq, { sessionId: 'anotherSession' }) - .subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should track searches that come from cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response.subscribe({ next, error, complete }); - response2.subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).not.toBeCalled(); - await timeTravel(300); - // Should be called only 2 times (once per partial response) - expect(fetchMock).toBeCalledTimes(2); - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).toBeCalledTimes(2); - - expect(next).toBeCalledTimes(4); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(2); - }); - - test('should cache partial responses', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: true, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('should not cache error responses', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: false, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(2); - }); - - test('should deliver error to all replays', async () => { - const responses = [ - { - time: 10, - value: { - isPartial: true, - isRunning: false, - id: 1, - }, - }, - ]; - - mockFetchImplementation(responses); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - expect(error).toBeCalledTimes(2); - expect(error.mock.calls[0][0].message).toEqual('Received partial response'); - expect(error.mock.calls[1][0].message).toEqual('Received partial response'); - }); - - test('should ignore anything outside params when hashing', async () => { - mockFetchImplementation(basicCompleteResponse); - - const req = { - something: 123, - params: { - test: 1, - }, - }; - - const req2 = { - something: 321, - params: { - test: 1, - }, - }; - - searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('should ignore preference when hashing', async () => { - mockFetchImplementation(basicCompleteResponse); - - const req = { - params: { - test: 1, - preference: 123, - }, - }; - - const req2 = { - params: { - test: 1, - preference: 321, - }, - }; - - searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('should return from cache for identical requests in the same session', async () => { - mockFetchImplementation(basicCompleteResponse); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - }); - - test('aborting a search that didnt get any response should retrigger search', async () => { - mockFetchImplementation(basicCompleteResponse); - - const abortController = new AbortController(); - - // Start a search request - searchInterceptor - .search(basicReq, { sessionId, abortSignal: abortController.signal }) - .subscribe({ next, error, complete }); - - // Abort the search request before it started - abortController.abort(); - - // Time travel to make sure nothing appens - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(0); - expect(next).toBeCalledTimes(0); - expect(error).toBeCalledTimes(1); - expect(complete).toBeCalledTimes(0); - - const error2 = jest.fn(); - const next2 = jest.fn(); - const complete2 = jest.fn(); - - // Search for the same thing again - searchInterceptor - .search(basicReq, { sessionId }) - .subscribe({ next: next2, error: error2, complete: complete2 }); - - // Should search again - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - expect(next2).toBeCalledTimes(1); - expect(error2).toBeCalledTimes(0); - expect(complete2).toBeCalledTimes(1); - }); - - test('aborting a running first search shouldnt clear cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response.subscribe({ next, error, complete }); - await timeTravel(10); - - expect(fetchMock).toBeCalledTimes(1); - expect(next).toBeCalledTimes(1); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(0); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).not.toBeCalled(); - - const next2 = jest.fn(); - const error2 = jest.fn(); - const complete2 = jest.fn(); - const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response2.subscribe({ next: next2, error: error2, complete: complete2 }); - await timeTravel(0); - - abortController.abort(); - - await timeTravel(300); - // Both searches should be tracked and untracked - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).toBeCalledTimes(2); - - // First search should error - expect(next).toBeCalledTimes(1); - expect(error).toBeCalledTimes(1); - expect(complete).toBeCalledTimes(0); - - // Second search should complete - expect(next2).toBeCalledTimes(2); - expect(error2).toBeCalledTimes(0); - expect(complete2).toBeCalledTimes(1); - - // Should be called only 2 times (once per partial response) - expect(fetchMock).toBeCalledTimes(2); - }); - - test('aborting a running second search shouldnt clear cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response.subscribe({ next, error, complete }); - await timeTravel(10); - - expect(fetchMock).toBeCalledTimes(1); - expect(next).toBeCalledTimes(1); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(0); - expect(sessionService.trackSearch).toBeCalledTimes(1); - expect(untrack).not.toBeCalled(); - - const next2 = jest.fn(); - const error2 = jest.fn(); - const complete2 = jest.fn(); - const response2 = searchInterceptor.search(req, { - pollInterval: 0, - sessionId, - abortSignal: abortController.signal, - }); - response2.subscribe({ next: next2, error: error2, complete: complete2 }); - await timeTravel(0); - - abortController.abort(); - - await timeTravel(300); - expect(sessionService.trackSearch).toBeCalledTimes(2); - expect(untrack).toBeCalledTimes(2); - - expect(next).toBeCalledTimes(2); - expect(error).toBeCalledTimes(0); - expect(complete).toBeCalledTimes(1); - - expect(next2).toBeCalledTimes(1); - expect(error2).toBeCalledTimes(1); - expect(complete2).toBeCalledTimes(0); - - // Should be called only 2 times (once per partial response) - expect(fetchMock).toBeCalledTimes(2); - }); - - test('aborting both requests should cancel underlaying search only once', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - sessionService.trackSearch.mockImplementation(() => jest.fn()); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response.subscribe({ next, error, complete }); - - const response2 = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response2.subscribe({ next, error, complete }); - await timeTravel(10); - - abortController.abort(); - - await timeTravel(300); - - expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); - }); - - test('aborting both searches should stop searching and clear cache', async () => { - mockFetchImplementation(partialCompleteResponse); - sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); - sessionService.getSessionId.mockImplementation(() => sessionId); - - const untrack = jest.fn(); - sessionService.trackSearch.mockImplementation(() => untrack); - - const req = { - params: { - test: 200, - }, - }; - - const abortController = new AbortController(); - - const response = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response.subscribe({ next, error, complete }); - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - const response2 = searchInterceptor.search(req, { - pollInterval: 1, - sessionId, - abortSignal: abortController.signal, - }); - response2.subscribe({ next, error, complete }); - await timeTravel(0); - expect(fetchMock).toBeCalledTimes(1); - - abortController.abort(); - - await timeTravel(300); - - expect(next).toBeCalledTimes(2); - expect(error).toBeCalledTimes(2); - expect(complete).toBeCalledTimes(0); - expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError); - - // Should be called only 1 times (one partial response) - expect(fetchMock).toBeCalledTimes(1); - - // Clear mock and research - fetchMock.mockReset(); - mockFetchImplementation(partialCompleteResponse); - // Run the search again to see that we don't hit the cache - const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); - response3.subscribe({ next, error, complete }); - - await timeTravel(10); - await timeTravel(10); - await timeTravel(300); - - // Should be called 2 times (two partial response) - expect(fetchMock).toBeCalledTimes(2); - expect(complete).toBeCalledTimes(1); - }); - - test('aborting a completed search shouldnt effect cache', async () => { - mockFetchImplementation(basicCompleteResponse); - - const abortController = new AbortController(); - - // Start a search request - searchInterceptor - .search(basicReq, { sessionId, abortSignal: abortController.signal }) - .subscribe({ next, error, complete }); - - // Get a final response - await timeTravel(10); - expect(fetchMock).toBeCalledTimes(1); - - // Abort the search request - abortController.abort(); - - // Search for the same thing again - searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); - - // Get the response from cache - expect(fetchMock).toBeCalledTimes(1); - }); - }); -}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts deleted file mode 100644 index ed91b7b598488..0000000000000 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ /dev/null @@ -1,225 +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 { once } from 'lodash'; -import { throwError, Subscription, from, of, fromEvent, EMPTY } from 'rxjs'; -import { - tap, - finalize, - catchError, - filter, - take, - skip, - switchMap, - shareReplay, - map, - takeUntil, -} from 'rxjs/operators'; -import { - TimeoutErrorMode, - SearchInterceptor, - SearchInterceptorDeps, - UI_SETTINGS, - IKibanaSearchRequest, - SearchSessionState, -} from '../../../../../src/plugins/data/public'; -import { - ENHANCED_ES_SEARCH_STRATEGY, - IAsyncSearchOptions, - pollSearch, -} from '../../../../../src/plugins/data/common'; -import { createRequestHash } from './utils'; -import { SearchResponseCache } from './search_response_cache'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { SearchAbortController } from './search_abort_controller'; - -const MAX_CACHE_ITEMS = 50; -const MAX_CACHE_SIZE_MB = 10; -export class EnhancedSearchInterceptor extends SearchInterceptor { - private uiSettingsSub: Subscription; - private searchTimeout: number; - private readonly responseCache: SearchResponseCache = new SearchResponseCache( - MAX_CACHE_ITEMS, - MAX_CACHE_SIZE_MB - ); - - /** - * @internal - */ - constructor(deps: SearchInterceptorDeps) { - super(deps); - this.searchTimeout = deps.uiSettings.get(UI_SETTINGS.SEARCH_TIMEOUT); - - this.uiSettingsSub = deps.uiSettings - .get$(UI_SETTINGS.SEARCH_TIMEOUT) - .subscribe((timeout: number) => { - this.searchTimeout = timeout; - }); - } - - public stop() { - this.responseCache.clear(); - this.uiSettingsSub.unsubscribe(); - } - - protected getTimeoutMode() { - return this.application.capabilities.advancedSettings?.save - ? TimeoutErrorMode.CHANGE - : TimeoutErrorMode.CONTACT; - } - - private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) { - const { sessionId, isRestore } = options; - // Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed - // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference - const { preference, ...params } = request.params || {}; - const hashOptions = { - ...params, - sessionId, - isRestore, - }; - - return from(sessionId ? createRequestHash(hashOptions) : of(undefined)); - } - - /** - * @internal - * Creates a new pollSearch that share replays its results - */ - private runSearch$( - { id, ...request }: IKibanaSearchRequest, - options: IAsyncSearchOptions, - searchAbortController: SearchAbortController - ) { - const search = () => this.runSearch({ id, ...request }, options); - const { sessionId, strategy } = options; - - // track if this search's session will be send to background - // if yes, then we don't need to cancel this search when it is aborted - let isSavedToBackground = false; - const savedToBackgroundSub = - this.deps.session.isCurrentSession(sessionId) && - this.deps.session.state$ - .pipe( - skip(1), // ignore any state, we are only interested in transition x -> BackgroundLoading - filter( - (state) => - this.deps.session.isCurrentSession(sessionId) && - state === SearchSessionState.BackgroundLoading - ), - take(1) - ) - .subscribe(() => { - isSavedToBackground = true; - }); - - const cancel = once(() => { - if (id && !isSavedToBackground) this.deps.http.delete(`/internal/search/${strategy}/${id}`); - }); - - return pollSearch(search, cancel, { - ...options, - abortSignal: searchAbortController.getSignal(), - }).pipe( - tap((response) => (id = response.id)), - catchError((e: Error) => { - cancel(); - return throwError(e); - }), - finalize(() => { - searchAbortController.cleanup(); - if (savedToBackgroundSub) { - savedToBackgroundSub.unsubscribe(); - } - }), - // This observable is cached in the responseCache. - // Using shareReplay makes sure that future subscribers will get the final response - - shareReplay(1) - ); - } - - /** - * @internal - * Creates a new search observable and a corresponding search abort controller - * If requestHash is defined, tries to return them first from cache. - */ - private getSearchResponse$( - request: IKibanaSearchRequest, - options: IAsyncSearchOptions, - requestHash?: string - ) { - const cached = requestHash ? this.responseCache.get(requestHash) : undefined; - - const searchAbortController = - cached?.searchAbortController || new SearchAbortController(this.searchTimeout); - - // Create a new abort signal if one was not passed. This fake signal will never be aborted, - // So the underlaying search will not be aborted, even if the other consumers abort. - searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal); - const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController); - - if (requestHash && !this.responseCache.has(requestHash)) { - this.responseCache.set(requestHash, { - response$, - searchAbortController, - }); - } - - return { - response$, - searchAbortController, - }; - } - - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { - const searchOptions = { - strategy: ENHANCED_ES_SEARCH_STRATEGY, - ...options, - }; - const { sessionId, abortSignal } = searchOptions; - - return this.createRequestHash$(request, searchOptions).pipe( - switchMap((requestHash) => { - const { searchAbortController, response$ } = this.getSearchResponse$( - request, - searchOptions, - requestHash - ); - - this.pendingCount$.next(this.pendingCount$.getValue() + 1); - const untrackSearch = this.deps.session.isCurrentSession(sessionId) - ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) - : undefined; - - // Abort the replay if the abortSignal is aborted. - // The underlaying search will not abort unless searchAbortController fires. - const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe( - map(() => { - throw new AbortError(); - }) - ); - - return response$.pipe( - takeUntil(aborted$), - catchError((e) => { - return throwError( - this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) - ); - }), - finalize(() => { - this.pendingCount$.next(this.pendingCount$.getValue() - 1); - if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); - } - }) - ); - }) - ); - } -} diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 29e6eddde65aa..a1ce3709e51a7 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -8,7 +8,6 @@ import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; import { registerSessionRoutes } from './routes'; import { searchSessionSavedObjectType } from './saved_objects'; -import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext, DataEnhancedSetupDependencies as SetupDependencies, @@ -30,7 +29,6 @@ export class EnhancedDataServerPlugin } public setup(core: CoreSetup, deps: SetupDependencies) { - core.uiSettings.register(getUiSettings()); core.savedObjects.registerType(searchSessionSavedObjectType); this.sessionService = new SearchSessionService(this.logger, this.config, deps.security); diff --git a/x-pack/plugins/data_enhanced/server/ui_settings.ts b/x-pack/plugins/data_enhanced/server/ui_settings.ts deleted file mode 100644 index fff7b798d783f..0000000000000 --- a/x-pack/plugins/data_enhanced/server/ui_settings.ts +++ /dev/null @@ -1,29 +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 { schema } from '@kbn/config-schema'; -import { UiSettingsParams } from 'kibana/server'; -import { UI_SETTINGS } from '../../../../src/plugins/data/server'; - -export function getUiSettings(): Record> { - return { - [UI_SETTINGS.SEARCH_TIMEOUT]: { - name: i18n.translate('xpack.data.advancedSettings.searchTimeout', { - defaultMessage: 'Search Timeout', - }), - value: 600000, - description: i18n.translate('xpack.data.advancedSettings.searchTimeoutDesc', { - defaultMessage: - 'Change the maximum timeout for a search session or set to 0 to disable the timeout and allow queries to run to completion.', - }), - type: 'number', - category: ['search'], - schema: schema.number(), - }, - }; -} 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 87fbf58dae023..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} 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 3e001d33b9907..ba9173e54ec08 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 @@ -19,10 +19,12 @@ import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; import { CurationsRouter } from '../curations'; +import { Documents, DocumentDetail } 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 { EngineRouter } from './engine_router'; @@ -102,6 +104,14 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); + it('renders a documents view', () => { + setMockValues({ ...values, myRole: { canViewEngineDocuments: true } }); + const wrapper = shallow(); + + expect(wrapper.find(Documents)).toHaveLength(1); + expect(wrapper.find(DocumentDetail)).toHaveLength(1); + }); + it('renders a synonyms view', () => { setMockValues({ ...values, myRole: { canManageEngineSynonyms: true } }); const wrapper = shallow(); @@ -143,4 +153,11 @@ describe('EngineRouter', () => { 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 fef67880f23a8..65769446b10db 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 @@ -17,7 +17,6 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chro import { Loading } from '../../../shared/loading'; import { AppLogic } from '../../app_logic'; -// TODO: Uncomment and add more routes as we migrate them import { ENGINES_PATH, ENGINE_ANALYTICS_PATH, @@ -25,7 +24,7 @@ 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, @@ -41,6 +40,7 @@ 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 './'; @@ -49,10 +49,10 @@ export const EngineRouter: React.FC = () => { const { myRole: { canViewEngineAnalytics, - // canViewEngineDocuments, + canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, - // canViewMetaEngineSourceEngines, + canViewMetaEngineSourceEngines, canManageEngineRelevanceTuning, canManageEngineSynonyms, canManageEngineCurations, @@ -92,12 +92,16 @@ export const EngineRouter: React.FC = () => { )} - - - - - - + {canViewEngineDocuments && ( + + + + )} + {canViewEngineDocuments && ( + + + + )} {canManageEngineCurations && ( @@ -128,6 +132,11 @@ export const EngineRouter: React.FC = () => { )} + {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/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/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 62a0ccc01f29a..2a7f256398381 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,18 +151,29 @@ 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', () => { setMockValues({ myRole: { canViewSettings: true } }); const wrapper = shallow(); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/settings/account'); + expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/settings'); }); it('renders the Credentials 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/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index a04707ad48338..727312801c610 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -12,7 +12,7 @@ export const DOCS_PREFIX = docLinks.appSearchBase; export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; export const LIBRARY_PATH = '/library'; -export const SETTINGS_PATH = '/settings/account'; +export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; @@ -21,9 +21,7 @@ export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = '/engine_creation'; - export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; -export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`; export const ENGINE_ANALYTICS_PATH = `${ENGINE_PATH}/analytics`; export const ENGINE_ANALYTICS_TOP_QUERIES_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries`; @@ -48,11 +46,11 @@ export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; -export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`; +export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result_settings`; export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`; export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`; -export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; +export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/search_ui`; export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api_logs`; 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/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/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx index f3c353fd75dba..56f28ada004e2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -340,7 +340,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { onClose={onConfirmModalClose} /> )} - +

diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index 543819aca87a5..4ff5243483a3a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -7,7 +7,14 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiTabs, + EuiTab, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiPortal, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import type { Section } from '../sections'; @@ -58,11 +65,13 @@ export const DefaultLayout: React.FunctionComponent = ({ return ( <> {modal === 'settings' && ( - { - setModal(null); - }} - /> + + { + setModal(null); + }} + /> + )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 88249f7f5d5ce..70cb6cddad5fa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -481,10 +481,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return ( <> {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + + setIsEnrollmentFlyoutOpen(false)} + /> + ) : null} {agentToReassign && ( 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" > void }> = ({ - onClose, -}) => { +const MissingFleetServerHostCallout: React.FunctionComponent = () => { const { setModal } = useUrlModal(); return ( vo fill iconType="gear" onClick={() => { - onClose(); setModal('settings'); }} > @@ -89,11 +86,21 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); + const { modal } = useUrlModal(); + const [lastModal, setLastModal] = useState(modal); const settings = useGetSettings(); const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + // Refresh settings when there is a modal/flyout change + useEffect(() => { + if (modal !== lastModal) { + settings.resendRequest(); + setLastModal(modal); + } + }, [modal, lastModal, settings]); + return ( - +

@@ -130,7 +137,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ + ) : undefined } > diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 8f6a2a26a2f6f..0158af2d78470 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -67,7 +67,7 @@ export const ManagedInstructions = React.memo(({ 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/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index aa36a3a7562bf..b408d0908e768 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -91,7 +91,7 @@ export const getListHandler: RequestHandler = async (context, request, response) allDashboards[pkgSavedObject.id] = dashboards; return allDashboards; }, {}); - const allDashboardSavedObjects = await context.core.savedObjects.client.bulkGet<{ + const allDashboardSavedObjectsResponse = await context.core.savedObjects.client.bulkGet<{ title?: string; }>( Object.values(dashboardIdsByPackageName).reduce( @@ -107,8 +107,19 @@ export const getListHandler: RequestHandler = async (context, request, response) [] ) ); + // Ignore dashboards not found + const allDashboardSavedObjects = allDashboardSavedObjectsResponse.saved_objects.filter((so) => { + if (so.error) { + if (so.error.statusCode === 404) { + return false; + } + throw so.error; + } + return true; + }); + const allDashboardSavedObjectsById = keyBy( - allDashboardSavedObjects.saved_objects, + allDashboardSavedObjects, (dashboardSavedObject) => dashboardSavedObject.id ); 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 index a9f9600addc39..87b3e163c1bb3 100644 --- a/x-pack/plugins/fleet/server/services/settings.test.ts +++ b/x-pack/plugins/fleet/server/services/settings.test.ts @@ -17,7 +17,7 @@ describe('getCloudFleetServersHosts', () => { expect(getCloudFleetServersHosts()).toBeUndefined(); }); - it('should return fleet server hosts if cloud is correctly setup', () => { + it('should return fleet server hosts if cloud is correctly setup with default port == 443', () => { mockedAppContextService.getCloud.mockReturnValue({ cloudId: 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', @@ -32,4 +32,20 @@ describe('getCloudFleetServersHosts', () => { ] `); }); + + 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 4ef9a3a95cbd0..2046e2571c926 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -84,6 +84,10 @@ export function getCloudFleetServersHosts() { } // Fleet Server url are formed like this `https://.fleet. - return [`https://${cloudSetup.deploymentId}.fleet.${res.host}`]; + 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/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 51021a3e50b3f..5f116d29648c9 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -485,14 +485,18 @@ const DropsInner = memo(function DropsInner(props: DropsInnerProps) { }, [order, registerDropTarget, dropTypes, keyboardMode]); useEffect(() => { + 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/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/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 051feb331aec4..023e6ce979b94 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -1150,6 +1150,83 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + 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/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/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 c79a7e37f84d1..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' @@ -278,6 +295,8 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp toggleGridlinesVisibility={onGridlinesVisibilitySettingsChange} isAxisTitleVisible={axisTitlesVisibilitySettings.x} toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} + endzonesVisible={!state?.hideEndzones} + setEndzoneVisibility={onChangeEndzoneVisiblity} /> { @@ -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 007368f0997df..0d8930bdb75b8 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -295,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/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_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 50043772af95b..8e31ad7855197 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -167,9 +167,8 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - let resp; try { - resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, @@ -182,6 +181,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }, }) .toPromise(); + return resp; } catch (error) { if (isSearchSourceAbortError(error)) { throw new DataRequestAbortError(); @@ -194,8 +194,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }) ); } - - return resp; } async makeSearchSource( 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/mb_map/features_tooltip/__snapshots__/feature_properties.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/footer.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.tsx similarity index 68% rename from x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.tsx index 9d4cf78c98754..61732d1c268c2 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.tsx @@ -8,19 +8,42 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; +import { Filter } from 'src/plugins/data/public'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { Geometry, Polygon } from 'geojson'; +import rison, { RisonObject } from 'rison-node'; import { URL_MAX_LENGTH } from '../../../../../../../src/core/public'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; -import { createSpatialFilterWithGeometry } from '../../../../common/elasticsearch_util'; -import { GEO_JSON_TYPE } from '../../../../common/constants'; +import { + createSpatialFilterWithGeometry, + PreIndexedShape, +} from '../../../../common/elasticsearch_util'; +import { ES_SPATIAL_RELATIONS, GEO_JSON_TYPE } from '../../../../common/constants'; +// @ts-expect-error import { GeometryFilterForm } from '../../../components/geometry_filter_form'; - -import rison from 'rison-node'; +import { GeoFieldWithIndex } from '../../../components/geo_field_with_index'; // over estimated and imprecise value to ensure filter has additional room for any meta keys added when filter is mapped. const META_OVERHEAD = 100; -export class FeatureGeometryFilterForm extends Component { - state = { +interface Props { + onClose: () => 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 (
@@ -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/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/server/lib/apm/_apm_stats.js b/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js index bdcd4d07f2c67..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', @@ -101,11 +107,13 @@ export const apmAggResponseHandler = (response) => { 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_apms_for_clusters.js b/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js index ce40c52cdde25..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 @@ -13,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 = { @@ -23,6 +23,7 @@ export function handleResponse(clusterUuid, response) { apms: { total: apmTotal, }, + versions, }; return { diff --git a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx index 648b49440546b..f2d50539395bd 100644 --- a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx +++ b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx @@ -16,7 +16,9 @@ const Page = styled(EuiPage)` const Container = styled.div<{ color?: string }>` overflow-y: hidden; - min-height: calc(100vh - ${(props) => props.theme.eui.euiHeaderChildSize}); + min-height: calc( + 100vh - ${(props) => props.theme.eui.euiHeaderHeight + props.theme.eui.euiHeaderHeight} + ); background: ${(props) => props.color}; `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx index 69b8b6eb89e46..e7a6874870fb2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx @@ -6,18 +6,40 @@ */ import React from 'react'; -import { EuiImage, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; import styled from 'styled-components'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { INITIATING_VIEW } from '../series_builder/series_builder'; +import { i18n } from '@kbn/i18n'; +import { LOADING_VIEW } from '../series_builder/series_builder'; +import { SeriesUrl } from '../types'; -export function EmptyView({ loading }: { loading: boolean }) { - const { - services: { http }, - } = useKibana(); +export function EmptyView({ + loading, + height, + series, +}: { + loading: boolean; + height: string; + series: SeriesUrl; +}) { + const { dataType, reportType, reportDefinitions } = series ?? {}; + + let emptyMessage = EMPTY_LABEL; + + if (dataType) { + if (reportType) { + if (isEmpty(reportDefinitions)) { + emptyMessage = CHOOSE_REPORT_DEFINITION; + } + } else { + emptyMessage = SELECT_REPORT_TYPE_BELOW; + } + } else { + emptyMessage = SELECTED_DATA_TYPE_FOR_REPORT; + } return ( - + {loading && ( )} - - {INITIATING_VIEW} + + + {loading ? LOADING_VIEW : emptyMessage} + + ); } -const ImageWrap = styled(EuiImage)` - opacity: 0.4; -`; - -const Wrapper = styled.div` +const Wrapper = styled.div<{ height: string }>` text-align: center; - height: 550px; + height: ${(props) => props.height}; position: relative; `; + +const FlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + +export const EMPTY_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.emptyview', { + defaultMessage: 'Nothing to display.', +}); + +export const CHOOSE_REPORT_DEFINITION = i18n.translate( + 'xpack.observability.expView.seriesBuilder.emptyReportDefinition', + { + defaultMessage: 'Please choose a report definition below to visualize.', + } +); + +export const SELECT_REPORT_TYPE_BELOW = i18n.translate( + 'xpack.observability.expView.seriesBuilder.selectReportType.empty', + { + defaultMessage: 'Please Select a report type below to define visualization.', + } +); + +const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( + 'xpack.observability.expView.reportType.selectDataType', + { defaultMessage: 'Please Select a data type below to start building a series.' } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index 5c09dbcff2d91..0e1e1681373cb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -34,7 +34,7 @@ export const FieldLabels: Record = { 'monitor.name': 'Monitor name', 'monitor.type': 'Monitor Type', 'url.port': 'Port', - 'url.full': 'Url', + 'url.full': 'URL', tags: 'Tags', // custom diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts index 67d72a656744c..b5a5169216b7b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts @@ -6,6 +6,7 @@ */ export enum URL_KEYS { + DATA_TYPE = 'dt', OPERATION_TYPE = 'op', REPORT_TYPE = 'rt', SERIES_TYPE = 'st', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts index f9637dc653d2c..c6089b2316784 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts @@ -31,7 +31,7 @@ export const getDefaultConfigs = ({ reportType, seriesId, indexPattern }: Props) case 'kpi-trends': return getKPITrendsLensConfig({ seriesId, indexPattern }); case 'uptime-duration': - return getMonitorDurationConfig({ seriesId }); + return getMonitorDurationConfig({ seriesId, indexPattern }); case 'uptime-pings': return getMonitorPingsConfig({ seriesId, indexPattern }); case 'service-latency': diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 56c20289669f4..68d9afc76d51a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -79,7 +79,7 @@ describe('Lens Attribute', () => { it('should return expected field type for custom field with passed value', function () { lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', { - 'performance.metric': LCP_FIELD, + 'performance.metric': [LCP_FIELD], }); expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 146f488450f3a..3e22c4da6115a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -31,7 +31,7 @@ import { IndexPattern, } from '../../../../../../../../src/plugins/data/common'; import { FieldLabels } from './constants'; -import { DataSeries, UrlFilter } from '../types'; +import { DataSeries, UrlFilter, URLReportDefinition } from '../types'; function getLayerReferenceName(layerId: string) { return `indexpattern-datasource-layer-${layerId}`; @@ -49,7 +49,7 @@ function buildNumberColumn(sourceField: string) { export const parseCustomFieldName = ( sourceField: string, reportViewConfig: DataSeries, - selectedDefinitions: Record + selectedDefinitions: URLReportDefinition ) => { let fieldName = sourceField; let columnType; @@ -60,7 +60,7 @@ export const parseCustomFieldName = ( if (customField) { if (selectedDefinitions[fieldName]) { - fieldName = selectedDefinitions[fieldName]; + fieldName = selectedDefinitions[fieldName][0]; if (customField?.options) columnType = customField?.options?.find(({ field }) => field === fieldName)?.columnType; } else if (customField.defaultValue) { @@ -81,7 +81,7 @@ export class LensAttributes { filters: UrlFilter[]; seriesType: SeriesType; reportViewConfig: DataSeries; - reportDefinitions: Record; + reportDefinitions: URLReportDefinition; constructor( indexPattern: IndexPattern, @@ -89,7 +89,7 @@ export class LensAttributes { seriesType?: SeriesType, filters?: UrlFilter[], operationType?: OperationType, - reportDefinitions?: Record + reportDefinitions?: URLReportDefinition ) { this.indexPattern = indexPattern; this.layers = {}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts index f27fd4476bfe0..a191a6de4f89a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_duration_config.ts @@ -5,14 +5,11 @@ * 2.0. */ -import { DataSeries } from '../../types'; +import { ConfigProps, DataSeries } from '../../types'; import { FieldLabels } from '../constants'; +import { buildExistsFilter } from '../utils'; -interface Props { - seriesId: string; -} - -export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { +export function getMonitorDurationConfig({ seriesId, indexPattern }: ConfigProps): DataSeries { return { id: seriesId, reportType: 'uptime-duration', @@ -29,7 +26,7 @@ export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { }, ], hasOperationType: true, - defaultFilters: ['monitor.type', 'observer.geo.name', 'tags', 'monitor.name', 'monitor.id'], + defaultFilters: ['monitor.type', 'observer.geo.name', 'tags'], breakdowns: [ 'observer.geo.name', 'monitor.name', @@ -38,11 +35,8 @@ export function getMonitorDurationConfig({ seriesId }: Props): DataSeries { 'tags', 'url.port', ], - filters: [], + filters: [...buildExistsFilter('summary.up', indexPattern)], reportDefinitions: [ - { - field: 'monitor.id', - }, { field: 'monitor.name', }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts index 6ffc400394812..400ef960b1f68 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/monitor_pings_config.ts @@ -7,8 +7,9 @@ import { ConfigProps, DataSeries } from '../../types'; import { FieldLabels } from '../constants'; +import { buildExistsFilter } from '../utils'; -export function getMonitorPingsConfig({ seriesId }: ConfigProps): DataSeries { +export function getMonitorPingsConfig({ seriesId, indexPattern }: ConfigProps): DataSeries { return { id: seriesId, reportType: 'uptime-pings', @@ -31,17 +32,14 @@ export function getMonitorPingsConfig({ seriesId }: ConfigProps): DataSeries { ], yTitle: 'Pings', hasOperationType: false, - defaultFilters: ['observer.geo.name', 'monitor.type', 'monitor.name', 'monitor.id'], + defaultFilters: ['observer.geo.name', 'monitor.type', 'tags'], breakdowns: ['observer.geo.name', 'monitor.type'], - filters: [], + filters: [...buildExistsFilter('summary.up', indexPattern)], palette: { type: 'palette', name: 'status' }, reportDefinitions: [ { field: 'monitor.name', }, - { - field: 'monitor.id', - }, { field: 'url.full', }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 5d5cdb23d3520..0d79f76be341c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -19,6 +19,7 @@ export function convertToShortUrl(series: SeriesUrl) { breakdown, filters, reportDefinitions, + dataType, ...restSeries } = series; @@ -29,6 +30,7 @@ export function convertToShortUrl(series: SeriesUrl) { [URL_KEYS.BREAK_DOWN]: breakdown, [URL_KEYS.FILTERS]: filters, [URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions, + [URL_KEYS.DATA_TYPE]: dataType, ...restSeries, }; } @@ -48,7 +50,7 @@ export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') { ); } -export function buildPhraseFilter(field: string, value: any, indexPattern: IIndexPattern) { +export function buildPhraseFilter(field: string, value: string, indexPattern: IIndexPattern) { const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { return [esFilters.buildPhraseFilter(fieldMeta, value, indexPattern)]; @@ -56,6 +58,14 @@ export function buildPhraseFilter(field: string, value: any, indexPattern: IInde return []; } +export function buildPhrasesFilter(field: string, value: string[], indexPattern: IIndexPattern) { + const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); + if (fieldMeta) { + return [esFilters.buildPhrasesFilter(fieldMeta, value, indexPattern)]; + } + return []; +} + export function buildExistsFilter(field: string, indexPattern: IIndexPattern) { const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field); if (fieldMeta) { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index 375c021bc9b56..d95fad758565b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/dom'; +import { screen, waitFor } from '@testing-library/dom'; import { render, mockUrlStorage, mockCore, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils'; @@ -44,38 +44,17 @@ describe('ExploratoryView', () => { await waitFor(() => { screen.getByText(/open in lens/i); screen.getByRole('heading', { name: /exploratory view/i }); - screen.getByRole('img', { name: /visulization/i }); - screen.getByText(/add series/i); - screen.getByText(/no series found, please add a series\./i); - }); - }); - - it('can add, cancel new series', async () => { - render(); - - await fireEvent.click(screen.getByText(/add series/i)); - - await waitFor(() => { - screen.getByText(/open in lens/i); - }); - - await waitFor(() => { - screen.getByText(/select a data type to start building a series\./i); - }); - - await fireEvent.click(screen.getByText(/cancel/i)); - - await waitFor(() => { - screen.getByText(/add series/i); }); }); it('renders lens component when there is series', async () => { mockUrlStorage({ data: { - 'uptime-pings-histogram': { - reportType: 'upp', - breakdown: 'monitor.status', + 'ux-series': { + dataType: 'ux', + reportType: 'pld', + breakdown: 'user_agent.name', + reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, }, }, @@ -83,11 +62,11 @@ describe('ExploratoryView', () => { render(); + expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect(await screen.findByText('Performance Distribution')).toBeInTheDocument(); + expect(await screen.findByText(/Lens Embeddable Component/i)).toBeInTheDocument(); + await waitFor(() => { - screen.getByText(/open in lens/i); - screen.getByRole('heading', { name: /uptime pings/i }); - screen.getByText(/uptime-pings-histogram/i); - screen.getByText(/Lens Embeddable Component/i); screen.getByRole('table', { name: /this table contains 1 rows\./i }); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 6bc91be876cf7..bc39bf5b27daa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -5,24 +5,30 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiPanel, EuiTitle } from '@elastic/eui'; +import styled from 'styled-components'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; -import { SeriesEditor } from './series_editor/series_editor'; import { useUrlStorage } from './hooks/use_url_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; import { TypedLensByValueInput } from '../../../../../lens/public'; import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { ReportToDataTypeMap } from './configurations/constants'; +import { SeriesBuilder } from './series_builder/series_builder'; export function ExploratoryView() { const { services: { lens }, } = useKibana(); + const seriesBuilderRef = useRef(null); + const wrapperRef = useRef(null); + + const [height, setHeight] = useState('100vh'); + const [lensAttributes, setLensAttributes] = useState( null ); @@ -37,6 +43,14 @@ export function ExploratoryView() { seriesId, }); + const setHeightOffset = () => { + if (seriesBuilderRef?.current && wrapperRef.current) { + const headerOffset = wrapperRef.current.getBoundingClientRect().top; + const seriesOffset = seriesBuilderRef.current.getBoundingClientRect().height; + setHeight(`calc(100vh - ${seriesOffset + headerOffset + 40}px)`); + } + }; + useEffect(() => { if (series?.reportType || series?.dataType) { loadIndexPattern({ dataType: series?.dataType ?? ReportToDataTypeMap[series?.reportType] }); @@ -48,22 +62,27 @@ export function ExploratoryView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]); + useEffect(() => { + setHeightOffset(); + }); + return ( - + {lens ? ( <> - {lensAttributes && seriesId && series?.reportType && series?.time ? ( - - ) : ( - - )} - + + {lensAttributes && seriesId && series?.reportType && series?.time ? ( + + ) : ( + + )} + + ) : ( @@ -75,6 +94,21 @@ export function ExploratoryView() { )} - + ); } +const LensWrapper = styled.div<{ height: string }>` + min-height: 400px; + height: ${(props) => props.height}; + + &&& > div { + height: 100%; + } +`; +const Wrapper = styled(EuiPanel)` + max-width: 1800px; + min-width: 800px; + margin: 0 auto; + width: 100%; + overflow-x: auto; +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index 4c43c11521722..dec69dc0a7b33 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -25,6 +25,7 @@ describe('ExploratoryViewHeader', function () { mockUrlStorage({ data: { 'uptime-pings-histogram': { + dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index af5657a56a995..7ac0961532b65 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; @@ -34,7 +34,12 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { {DataViewLabels[series.reportType] ?? i18n.translate('xpack.observability.expView.heading.label', { defaultMessage: 'Exploratory view', + })}{' '} + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index a5fce74c4a2f6..dc6b4bd0ec879 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -6,6 +6,7 @@ */ import { useMemo } from 'react'; +import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LensAttributes } from '../configurations/lens_attributes'; import { useUrlStorage } from './use_url_storage'; @@ -25,7 +26,7 @@ export const getFiltersFromDefs = ( const rdfFilters = Object.entries(reportDefinitions ?? {}).map(([field, value]) => { return { field, - values: [value], + values: value, }; }) as UrlFilter[]; @@ -46,7 +47,7 @@ export const useLensAttributes = ({ const { indexPattern } = useAppIndexPatternContext(); return useMemo(() => { - if (!indexPattern || !reportType) { + if (!indexPattern || !reportType || isEmpty(reportDefinitions)) { return null; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 34f0a7c1a7f86..2605818ed7846 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -20,19 +20,21 @@ export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { const filters = series.filters ?? []; const removeFilter = ({ field, value, negate }: UpdateFilter) => { - const filtersN = filters.map((filter) => { - if (filter.field === field) { - if (negate) { - const notValuesN = filter.notValues?.filter((val) => val !== value); - return { ...filter, notValues: notValuesN }; - } else { - const valuesN = filter.values?.filter((val) => val !== value); - return { ...filter, values: valuesN }; + const filtersN = filters + .map((filter) => { + if (filter.field === field) { + if (negate) { + const notValuesN = filter.notValues?.filter((val) => val !== value); + return { ...filter, notValues: notValuesN }; + } else { + const valuesN = filter.values?.filter((val) => val !== value); + return { ...filter, values: valuesN }; + } } - } - return filter; - }); + return filter; + }) + .filter(({ values = [], notValues = [] }) => values.length > 0 || notValues.length > 0); setSeries(seriesId, { ...series, filters: filtersN }); }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx index 139b7faac4a37..498886cc94410 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx @@ -7,7 +7,13 @@ import React, { createContext, useContext, Context } from 'react'; import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; -import type { AppDataType, ReportViewTypeId, SeriesUrl, UrlFilter } from '../types'; +import type { + AppDataType, + ReportViewTypeId, + SeriesUrl, + UrlFilter, + URLReportDefinition, +} from '../types'; import { convertToShortUrl } from '../configurations/utils'; import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; @@ -26,7 +32,7 @@ export function UrlStorageContextProvider({ } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { - const { op, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; + const { dt, op, st, rt, bd, ft, time, rdf, ...restSeries } = newValue; return { operationType: op, reportType: rt!, @@ -35,6 +41,7 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { filters: ft!, time: time!, reportDefinitions: rdf, + dataType: dt!, ...restSeries, }; } @@ -42,15 +49,15 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { interface ShortUrlSeries { [URL_KEYS.OPERATION_TYPE]?: OperationType; [URL_KEYS.REPORT_TYPE]?: ReportViewTypeId; + [URL_KEYS.DATA_TYPE]?: AppDataType; [URL_KEYS.SERIES_TYPE]?: SeriesType; [URL_KEYS.BREAK_DOWN]?: string; [URL_KEYS.FILTERS]?: UrlFilter[]; - [URL_KEYS.REPORT_DEFINITIONS]?: Record; + [URL_KEYS.REPORT_DEFINITIONS]?: URLReportDefinition; time?: { to: string; from: string; }; - dataType?: AppDataType; } export type AllShortSeries = Record; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 9fe133098549c..a7edfdea09549 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { useHistory } from 'react-router-dom'; -import { ThemeContext } from 'styled-components'; import { ExploratoryView } from './exploratory_view'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useBreadcrumbs } from '../../../hooks/use_breadcrumbs'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; @@ -19,9 +19,12 @@ import { withNotifyOnErrors, } from '../../../../../../../src/plugins/kibana_utils/public/'; import { UrlStorageContextProvider } from './hooks/use_url_storage'; -import { WithHeaderLayout } from '../../app/layout/with_header'; +import { useTrackPageview } from '../../..'; export function ExploratoryViewPage() { + useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); + useTrackPageview({ app: 'observability-overview', path: 'exploratory-view', delay: 15000 }); + useBreadcrumbs([ { text: i18n.translate('xpack.observability.overview.exploratoryView', { @@ -30,8 +33,6 @@ export function ExploratoryViewPage() { }, ]); - const theme = useContext(ThemeContext); - const { services: { uiSettings, notifications }, } = useKibana(); @@ -45,15 +46,16 @@ export function ExploratoryViewPage() { }); return ( - + - + ); } + +const Wrapper = euiStyled.div` + padding: ${(props) => props.theme.eui.paddingSizes.l}; +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index 7763e8ad71222..beb1daafbd55f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -268,6 +268,7 @@ export const mockUrlStorage = ({ const mockDataSeries = data || { 'performance-distribution': { reportType: 'pld', + dataType: 'ux', breakdown: breakdown || 'user_agent.name', time: { from: 'now-15m', to: 'now' }, ...(filters ? { filters } : {}), diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index cf5d08b4110d7..9348fcbe15f6c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -9,13 +9,14 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; import { dataTypes, DataTypesCol } from './data_types_col'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; describe('DataTypesCol', function () { + const seriesId = 'test-series-id'; + mockAppIndexPattern(); it('should render properly', function () { - const { getByText } = render(); + const { getByText } = render(); dataTypes.forEach(({ label }) => { getByText(label); @@ -25,18 +26,18 @@ describe('DataTypesCol', function () { it('should set series on change', function () { const { setSeries } = mockUrlStorage({}); - render(); + render(); - fireEvent.click(screen.getByText(/user experience\(rum\)/i)); + fireEvent.click(screen.getByText(/user experience \(rum\)/i)); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { dataType: 'ux' }); + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'ux' }); }); it('should set series on change on already selected', function () { mockUrlStorage({ data: { - [NEW_SERIES_KEY]: { + [seriesId]: { dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', @@ -45,7 +46,7 @@ describe('DataTypesCol', function () { }, }); - render(); + render(); const button = screen.getByRole('button', { name: /Synthetic Monitoring/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index 79008d91b294d..9d15206db1e62 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -10,30 +10,31 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { AppDataType } from '../../types'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; +import { ReportToDataTypeMap } from '../../configurations/constants'; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ { id: 'synthetics', label: 'Synthetic Monitoring' }, - { id: 'ux', label: 'User Experience(RUM)' }, + { id: 'ux', label: 'User Experience (RUM)' }, // { id: 'infra_logs', label: 'Logs' }, // { id: 'infra_metrics', label: 'Metrics' }, // { id: 'apm', label: 'APM' }, ]; -export function DataTypesCol() { - const { series, setSeries, removeSeries } = useUrlStorage(NEW_SERIES_KEY); +export function DataTypesCol({ seriesId }: { seriesId: string }) { + const { series, setSeries, removeSeries } = useUrlStorage(seriesId); const { loading } = useAppIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { if (!dataType) { - removeSeries(NEW_SERIES_KEY); + removeSeries(seriesId); } else { - setSeries(NEW_SERIES_KEY, { dataType } as any); + setSeries(seriesId || `${dataType}-series`, { dataType } as any); } }; - const selectedDataType = series.dataType; + const selectedDataType = series.dataType ?? ReportToDataTypeMap[series.reportType]; return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx index a245d39cee089..175fbea9445c1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import styled from 'styled-components'; import { SeriesDatePicker } from '../../series_date_picker'; interface Props { @@ -13,8 +14,17 @@ interface Props { } export function DatePickerCol({ seriesId }: Props) { return ( -
+ -
+ ); } + +const Wrapper = styled.div` + .euiSuperDatePicker__flexWrapper { + width: 100%; + > .euiFlexItem { + margin-right: 0px; + } + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx index e05f91b4bb0bd..9550b8e98103b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -21,6 +21,7 @@ describe('OperationTypeSelect', function () { mockUrlStorage({ data: { 'performance-distribution': { + dataType: 'ux', reportType: 'kpi', operationType: 'median', time: { from: 'now-15m', to: 'now' }, @@ -37,6 +38,7 @@ describe('OperationTypeSelect', function () { const { setSeries } = mockUrlStorage({ data: { 'series-id': { + dataType: 'ux', reportType: 'kpi', operationType: 'median', time: { from: 'now-15m', to: 'now' }, @@ -50,6 +52,7 @@ describe('OperationTypeSelect', function () { expect(setSeries).toHaveBeenCalledWith('series-id', { operationType: 'median', + dataType: 'ux', reportType: 'kpi', time: { from: 'now-15m', to: 'now' }, }); @@ -57,6 +60,7 @@ describe('OperationTypeSelect', function () { fireEvent.click(screen.getByText('95th Percentile')); expect(setSeries).toHaveBeenCalledWith('series-id', { operationType: '95th', + dataType: 'ux', reportType: 'kpi', time: { from: 'now-15m', to: 'now' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 63cabeabc2ced..3363d17d81eab 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -10,21 +10,21 @@ import { fireEvent, screen } from '@testing-library/react'; import { render } from '../../../../../utils/test_helper'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { ReportBreakdowns } from './report_breakdowns'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; describe('Series Builder ReportBreakdowns', function () { + const seriesId = 'test-series-id'; const dataViewSeries = getDefaultConfigs({ + seriesId, reportType: 'pld', indexPattern: mockIndexPattern, - seriesId: NEW_SERIES_KEY, }); it('should render properly', function () { mockUrlStorage({}); - render(); + render(); screen.getByText('Select an option: , is selected'); screen.getAllByText('Browser family'); @@ -33,7 +33,7 @@ describe('Series Builder ReportBreakdowns', function () { it('should set new series breakdown on change', function () { const { setSeries } = mockUrlStorage({}); - render(); + render(); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -45,8 +45,9 @@ describe('Series Builder ReportBreakdowns', function () { fireEvent.click(screen.getByText(/operating system/i)); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: USER_AGENT_OS, + dataType: 'ux', reportType: 'pld', time: { from: 'now-15m', to: 'now' }, }); @@ -54,7 +55,7 @@ describe('Series Builder ReportBreakdowns', function () { it('should set undefined on new series on no select breakdown', function () { const { setSeries } = mockUrlStorage({}); - render(); + render(); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -66,8 +67,9 @@ describe('Series Builder ReportBreakdowns', function () { fireEvent.click(screen.getByText(/no breakdown/i)); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: undefined, + dataType: 'ux', reportType: 'pld', time: { from: 'now-15m', to: 'now' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx index 619e2ec4fe9b0..162892071dbff 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx @@ -7,9 +7,14 @@ import React from 'react'; import { Breakdowns } from '../../series_editor/columns/breakdowns'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; -export function ReportBreakdowns({ dataViewSeries }: { dataViewSeries: DataSeries }) { - return ; +export function ReportBreakdowns({ + seriesId, + dataViewSeries, +}: { + dataViewSeries: DataSeries; + seriesId: string; +}) { + return ; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 91786a2f48fab..27adcf4682c02 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -15,32 +15,34 @@ import { mockUseValuesList, render, } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { ReportDefinitionCol } from './report_definition_col'; import { SERVICE_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('Series Builder ReportDefinitionCol', function () { mockAppIndexPattern(); + const seriesId = 'test-series-id'; const dataViewSeries = getDefaultConfigs({ + seriesId, reportType: 'pld', indexPattern: mockIndexPattern, - seriesId: NEW_SERIES_KEY, }); const { setSeries } = mockUrlStorage({ data: { - 'performance-dist': { + [seriesId]: { dataType: 'ux', reportType: 'pld', time: { from: 'now-30d', to: 'now' }, - reportDefinitions: { [SERVICE_NAME]: 'elastic-co' }, + reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, }, }, }); + mockUseValuesList(['elastic-co']); + it('should render properly', async function () { - render(); + render(); screen.getByText('Web Application'); screen.getByText('Environment'); @@ -48,36 +50,33 @@ describe('Series Builder ReportDefinitionCol', function () { screen.getByText('Page load time'); }); - it('should render selected report definitions', function () { - render(); + it('should render selected report definitions', async function () { + render(); + + expect(await screen.findByText('elastic-co')).toBeInTheDocument(); - screen.getByText('elastic-co'); + expect(screen.getAllByTestId('comboBoxToggleListButton')[0]).toBeInTheDocument(); }); - it('should be able to remove selected definition', function () { - render(); + it('should be able to remove selected definition', async function () { + render(); + + expect( + await screen.findByLabelText('Remove elastic-co from selection in this group') + ).toBeInTheDocument(); - const removeBtn = screen.getByText(/elastic-co/i); + fireEvent.click(screen.getAllByTestId('comboBoxToggleListButton')[0]); + + const removeBtn = await screen.findByTitle(/Remove elastic-co from selection in this group/i); fireEvent.click(removeBtn); expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'ux', reportDefinitions: {}, reportType: 'pld', time: { from: 'now-30d', to: 'now' }, }); }); - - it('should be able to unselected selected definition', async function () { - mockUseValuesList(['elastic-co']); - render(); - - const definitionBtn = screen.getByText(/web application/i); - - fireEvent.click(definitionBtn); - - screen.getByText('Apply'); - }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index 0351508ebb59e..f7520fb64f211 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -6,19 +6,19 @@ */ import React from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; import { CustomReportField } from '../custom_report_field'; -import FieldValueSuggestions from '../../../field_value_suggestions'; -import { DataSeries } from '../../types'; +import { DataSeries, URLReportDefinition } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; import { OperationTypeSelect } from './operation_type_select'; import { DatePickerCol } from './date_picker_col'; import { parseCustomFieldName } from '../../configurations/lens_attributes'; +import { ReportDefinitionField } from './report_definition_field'; -function getColumnType(dataView: DataSeries, selectedDefinition: Record) { +function getColumnType(dataView: DataSeries, selectedDefinition: URLReportDefinition) { const { reportDefinitions } = dataView; const customColumn = reportDefinitions.find((item) => item.custom); if (customColumn?.field && selectedDefinition[customColumn?.field]) { @@ -31,92 +31,59 @@ function getColumnType(dataView: DataSeries, selectedDefinition: Record { - if (!value) { - delete rtd[field]; - setSeries(NEW_SERIES_KEY, { + const onChange = (field: string, value?: string[]) => { + if (!value?.[0]) { + delete selectedReportDefinitions[field]; + setSeries(seriesId, { ...series, - reportDefinitions: { ...rtd }, + reportDefinitions: { ...selectedReportDefinitions }, }); } else { - setSeries(NEW_SERIES_KEY, { + setSeries(seriesId, { ...series, - reportDefinitions: { ...rtd, [field]: value }, + reportDefinitions: { ...selectedReportDefinitions, [field]: value }, }); } }; - const onRemove = (field: string) => { - delete rtd[field]; - setSeries(NEW_SERIES_KEY, { - ...series, - reportDefinitions: rtd, - }); - }; - - const columnType = getColumnType(dataViewSeries, rtd); + const columnType = getColumnType(dataViewSeries, selectedReportDefinitions); return ( - + {indexPattern && reportDefinitions.map(({ field, custom, options, defaultValue }) => ( {!custom ? ( - - - onChange(field, val)} - filters={(filters ?? []).map(({ query }) => query)} - time={series.time} - fullWidth={true} - /> - - {rtd?.[field] && ( - - onRemove(field)} - iconOnClick={() => onRemove(field)} - iconOnClickAriaLabel={'Click to remove'} - onClickAriaLabel={'Click to remove'} - > - {rtd?.[field]} - - - )} - + ) : ( )} @@ -124,13 +91,13 @@ export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSe {(hasOperationType || columnType === 'operation') && ( )} - + ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx new file mode 100644 index 0000000000000..9f92bec4d1f9c --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -0,0 +1,78 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import FieldValueSuggestions from '../../../field_value_suggestions'; +import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { ESFilter } from '../../../../../../../../../typings/elasticsearch'; +import { PersistableFilter } from '../../../../../../../lens/common'; +import { ExistsFilter } from '../../../../../../../../../src/plugins/data/common/es_query/filters'; +import { buildPhrasesFilter } from '../../configurations/utils'; +import { DataSeries } from '../../types'; + +interface Props { + seriesId: string; + field: string; + dataSeries: DataSeries; + onChange: (field: string, value?: string[]) => void; +} + +export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { + const { series } = useUrlStorage(seriesId); + + const { indexPattern } = useAppIndexPatternContext(); + + const { reportDefinitions: selectedReportDefinitions = {} } = series; + + const { labels, filters, reportDefinitions } = dataSeries; + + const queryFilters = useMemo(() => { + const filtersN: ESFilter[] = []; + (filters ?? []).forEach((qFilter: PersistableFilter | ExistsFilter) => { + if (qFilter.query) { + filtersN.push(qFilter.query); + } + const existFilter = qFilter as ExistsFilter; + if (existFilter.exists) { + filtersN.push({ exists: existFilter.exists }); + } + }); + + if (!isEmpty(selectedReportDefinitions)) { + reportDefinitions.forEach(({ field: fieldT, custom }) => { + if (!custom && selectedReportDefinitions?.[fieldT] && fieldT !== field) { + const values = selectedReportDefinitions?.[fieldT]; + const valueFilter = buildPhrasesFilter(fieldT, values, indexPattern)[0]; + filtersN.push(valueFilter.query); + } + }); + } + + return filtersN; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(filters)]); + + return ( + + + onChange(field, val)} + filters={queryFilters} + time={series.time} + fullWidth={true} + /> + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index 4c9b5827e2bde..1467cb54d648a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -11,17 +11,18 @@ import { render } from '../../../../../utils/test_helper'; import { ReportFilters } from './report_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; describe('Series Builder ReportFilters', function () { + const seriesId = 'test-series-id'; + const dataViewSeries = getDefaultConfigs({ + seriesId, reportType: 'pld', indexPattern: mockIndexPattern, - seriesId: NEW_SERIES_KEY, }); mockUrlStorage({}); it('should render properly', function () { - render(); + render(); screen.getByText('Add filter'); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx index 24b24b76af1f6..9687f1bea4ec9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx @@ -7,15 +7,20 @@ import React from 'react'; import { SeriesFilter } from '../../series_editor/columns/series_filter'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; -export function ReportFilters({ dataViewSeries }: { dataViewSeries: DataSeries }) { +export function ReportFilters({ + dataViewSeries, + seriesId, +}: { + dataViewSeries: DataSeries; + seriesId: string; +}) { return ( ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index c5b8f1147af54..20c4ea98d482d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -14,27 +14,30 @@ import { DEFAULT_TIME } from '../../configurations/constants'; import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; describe('ReportTypesCol', function () { + const seriesId = 'test-series-id'; + mockAppIndexPattern(); it('should render properly', function () { - render(); + render(); screen.getByText('Performance distribution'); screen.getByText('KPI over time'); }); it('should display empty message', function () { - render(); + render(); screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT); }); it('should set series on change', function () { const { setSeries } = mockUrlStorage({}); - render(); + render(); fireEvent.click(screen.getByText(/monitor duration/i)); - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { breakdown: 'user_agent.name', + dataType: 'ux', reportDefinitions: {}, reportType: 'upd', time: { from: 'now-15m', to: 'now' }, @@ -54,7 +57,7 @@ describe('ReportTypesCol', function () { }, }); - render(); + render(); const button = screen.getByRole('button', { name: /pings histogram/i, @@ -64,7 +67,7 @@ describe('ReportTypesCol', function () { fireEvent.click(button); // undefined on click selected - expect(setSeries).toHaveBeenCalledWith(NEW_SERIES_KEY, { + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'synthetics', time: DEFAULT_TIME, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index b8ab1c80009d9..9c95b3874c242 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -11,19 +11,20 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { ReportViewTypeId, SeriesUrl } from '../../types'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; interface Props { + seriesId: string; reportTypes: Array<{ id: ReportViewTypeId; label: string }>; } -export function ReportTypesCol({ reportTypes }: Props) { +export function ReportTypesCol({ seriesId, reportTypes }: Props) { const { series: { reportType: selectedReportType, ...restSeries }, setSeries, - } = useUrlStorage(NEW_SERIES_KEY); + } = useUrlStorage(seriesId); const { loading, hasData, selectedApp } = useAppIndexPatternContext(); @@ -50,14 +51,15 @@ export function ReportTypesCol({ reportTypes }: Props) { isDisabled={loading} onClick={() => { if (reportType === selectedReportType) { - setSeries(NEW_SERIES_KEY, { + setSeries(seriesId, { dataType: restSeries.dataType, time: DEFAULT_TIME, } as SeriesUrl); } else { - setSeries(NEW_SERIES_KEY, { + setSeries(seriesId, { ...restSeries, reportType, + operationType: undefined, reportDefinitions: {}, time: restSeries?.time ?? DEFAULT_TIME, }); @@ -76,7 +78,7 @@ export function ReportTypesCol({ reportTypes }: Props) { export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( 'xpack.observability.expView.reportType.noDataType', - { defaultMessage: 'Select a data type to start building a series.' } + { defaultMessage: 'No data type selected.' } ); const FlexGroup = styled(EuiFlexGroup)` diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx index b630ee55b5780..e0d043504d50f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx @@ -23,7 +23,7 @@ export function CustomReportField({ field, seriesId, options: opts, defaultValue const { reportDefinitions: rtd = {} } = series; const onChange = (value: string) => { - setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: value } }); + setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: [value] } }); }; const { reportDefinitions } = series; @@ -35,11 +35,11 @@ export function CustomReportField({ field, seriesId, options: opts, defaultValue ({ + options={options.map(({ label, field: fd }) => ({ value: fd, inputDisplay: label, }))} - valueOfSelected={reportDefinitions?.[field] || defaultValue || options?.[0].field} + valueOfSelected={reportDefinitions?.[field]?.[0] || defaultValue || options?.[0].field} onChange={(value) => onChange(value)} /> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index db6e075cc90fb..5e270524b1880 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { RefObject } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiBasicTable, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; import { AppDataType, ReportViewTypeId, ReportViewTypes, SeriesUrl } from '../types'; import { DataTypesCol } from './columns/data_types_col'; import { ReportTypesCol } from './columns/report_types_col'; @@ -45,8 +45,14 @@ export const ReportTypes: Record; +}) { + const { series, setSeries, removeSeries } = useUrlStorage(seriesId); const { dataType, @@ -59,29 +65,23 @@ export function SeriesBuilder() { time, } = series; - const [isFlyoutVisible, setIsFlyoutVisible] = useState(!!series.dataType); - const { indexPattern, loading, hasData } = useAppIndexPatternContext(); const getDataViewSeries = () => { return getDefaultConfigs({ + seriesId, indexPattern, reportType: reportType!, - seriesId: NEW_SERIES_KEY, }); }; - useEffect(() => { - setIsFlyoutVisible(!!series.dataType); - }, [series.dataType]); - const columns = [ { name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', { defaultMessage: 'Data Type', }), width: '15%', - render: (val: string) => , + render: (val: string) => , }, { name: i18n.translate('xpack.observability.expView.seriesBuilder.report', { @@ -89,7 +89,7 @@ export function SeriesBuilder() { }), width: '15%', render: (val: string) => ( - + ), }, { @@ -100,9 +100,9 @@ export function SeriesBuilder() { render: (val: string) => { if (dataType && hasData) { return loading ? ( - INITIATING_VIEW + LOADING_VIEW ) : reportType ? ( - + ) : ( SELECT_REPORT_TYPE ); @@ -117,7 +117,9 @@ export function SeriesBuilder() { }), width: '20%', render: (val: string) => - reportType && indexPattern ? : null, + reportType && indexPattern ? ( + + ) : null, }, { name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', { @@ -127,11 +129,13 @@ export function SeriesBuilder() { field: 'id', render: (val: string) => reportType && indexPattern ? ( - + ) : null, }, ]; + // TODO: Remove this if remain unused during multiple series view + // @ts-expect-error const addSeries = () => { if (reportType) { const newSeriesId = `${ @@ -141,6 +145,7 @@ export function SeriesBuilder() { }`; const newSeriesN: SeriesUrl = { + dataType, time, filters, breakdown, @@ -152,92 +157,34 @@ export function SeriesBuilder() { setSeries(newSeriesId, newSeriesN).then(() => { removeSeries(NEW_SERIES_KEY); - setIsFlyoutVisible(false); }); } }; - const items = [{ id: NEW_SERIES_KEY }]; - - let flyout; - - if (isFlyoutVisible) { - flyout = ( - <> - - - - - - {i18n.translate('xpack.observability.expView.seriesBuilder.add', { - defaultMessage: 'Add', - })} - - - - { - removeSeries(NEW_SERIES_KEY); - setIsFlyoutVisible(false); - }} - > - {i18n.translate('xpack.observability.expView.seriesBuilder.cancel', { - defaultMessage: 'Cancel', - })} - - - - - ); - } + const items = [{ id: seriesId }]; return ( -
- {!isFlyoutVisible && ( - <> - setIsFlyoutVisible((prevState) => !prevState)} - disabled={allSeriesIds.length > 0} - > - {i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', { - defaultMessage: 'Add series', - })} - - - - )} - {flyout} +
+
); } -export const INITIATING_VIEW = i18n.translate( - 'xpack.observability.expView.seriesBuilder.initView', +export const LOADING_VIEW = i18n.translate( + 'xpack.observability.expView.seriesBuilder.loadingView', { - defaultMessage: 'Initiating view ...', + defaultMessage: 'Loading view ...', } ); -const SELECT_REPORT_TYPE = i18n.translate( +export const SELECT_REPORT_TYPE = i18n.translate( 'xpack.observability.expView.seriesBuilder.selectReportType', { - defaultMessage: 'Select a report type to define visualization.', + defaultMessage: 'No report type selected', } ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index 8fe1d5ed9f2ac..e99b701f091fe 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -16,6 +16,7 @@ describe('SeriesDatePicker', function () { mockUrlStorage({ data: { 'uptime-pings-histogram': { + dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, @@ -32,6 +33,7 @@ describe('SeriesDatePicker', function () { data: { 'uptime-pings-histogram': { reportType: 'upp', + dataType: 'synthetics', breakdown: 'monitor.status', }, }, @@ -40,6 +42,7 @@ describe('SeriesDatePicker', function () { expect(setSeries1).toHaveBeenCalledTimes(1); expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', + dataType: 'synthetics', reportType: 'upp', time: DEFAULT_TIME, }); @@ -49,6 +52,7 @@ describe('SeriesDatePicker', function () { const { setSeries } = mockUrlStorage({ data: { 'uptime-pings-histogram': { + dataType: 'synthetics', reportType: 'upp', breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, @@ -69,6 +73,7 @@ describe('SeriesDatePicker', function () { expect(setSeries).toHaveBeenCalledWith('series-id', { breakdown: 'monitor.status', + dataType: 'synthetics', reportType: 'upp', time: { from: 'now/d', to: 'now/d' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx new file mode 100644 index 0000000000000..c95d322748607 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx @@ -0,0 +1,30 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Breakdowns } from './columns/breakdowns'; +import { DataSeries } from '../types'; +import { ChartOptions } from './columns/chart_options'; + +interface Props { + series: DataSeries; + seriesId: string; + breakdowns: string[]; +} +export function ChartEditOptions({ series, seriesId, breakdowns }: Props) { + return ( + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 0824f13e6b3fe..eff62a60509e3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -41,6 +41,7 @@ describe('Breakdowns', function () { expect(setSeries).toHaveBeenCalledWith('series-id', { breakdown: 'user_agent.name', + dataType: 'ux', reportType: 'pld', time: { from: 'now-15m', to: 'now' }, }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index 849fb8ffa66ba..ccb9c90a884bb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -71,8 +71,10 @@ export function FilterValueButton({ ); - const onNestedChange = (val?: string) => { - setFilter({ field: nestedField!, value: val! }); + const onNestedChange = (valuesN?: string[]) => { + (valuesN ?? []).forEach((valN) => { + setFilter({ field: nestedField!, value: valN! }); + }); setIsNestedOpen({ value: '', negate }); }; 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 2d82aca658ec3..04f4ecb2ccb23 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 @@ -19,7 +19,7 @@ import { FilterExpanded } from './filter_expanded'; import { DataSeries } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { useUrlStorage } from '../../hooks/use_url_storage'; interface Props { seriesId: string; @@ -46,13 +46,12 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P } return { - label: FieldLabels[field.field], field: field.field, nested: field.nested, isNegated: field.isNegated, + label: FieldLabels[field.field], }; }); - const disabled = seriesId === NEW_SERIES_KEY && !isNew; const { setSeries, series: urlSeries } = useUrlStorage(seriesId); @@ -63,7 +62,6 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P onClick={() => { setIsPopoverVisible(true); }} - isDisabled={disabled} size="s" > {i18n.translate('xpack.observability.expView.seriesEditor.addFilter', { @@ -113,7 +111,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P return ( - {!disabled && } + { setSeries(seriesId, { ...urlSeries, filters: undefined }); }} - isDisabled={disabled} - size="s" + size="xs" > {i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', { defaultMessage: 'Clear filters', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index 4055a592c75d2..aabb39f88507f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { useUrlStorage } from '../hooks/use_url_storage'; import { FilterLabel } from '../components/filter_label'; import { DataSeries, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -31,7 +31,7 @@ export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions, dataSeries); // we don't want to display report definition filters in new series view - if (seriesId === NEW_SERIES_KEY && isNew) { + if (isNew) { definitionFilters = []; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index b9e68c1267c9e..d883b854c88cb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -10,15 +10,13 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; -import { Breakdowns } from './columns/breakdowns'; import { DataSeries } from '../types'; -import { SeriesBuilder } from '../series_builder/series_builder'; import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; -import { ChartOptions } from './columns/chart_options'; import { SeriesActions } from './columns/series_actions'; +import { ChartEditOptions } from './chart_edit_options'; export function SeriesEditor() { const { allSeries, firstSeriesId } = useUrlStorage(); @@ -44,7 +42,7 @@ export function SeriesEditor() { defaultMessage: 'Filters', }), field: 'defaultFilters', - width: '25%', + width: '15%', render: (defaultFilters: string[], series: DataSeries) => ( ), @@ -54,18 +52,11 @@ export function SeriesEditor() { defaultMessage: 'Breakdowns', }), field: 'breakdowns', - width: '15%', + width: '25%', render: (val: string[], item: DataSeries) => ( - + ), }, - { - name: '', - align: 'center' as const, - width: '15%', - field: 'id', - render: (val: string, item: DataSeries) => , - }, { name: (
@@ -85,7 +76,7 @@ export function SeriesEditor() { defaultMessage: 'Actions', }), align: 'center' as const, - width: '8%', + width: '10%', field: 'id', render: (val: string, item: DataSeries) => , }, @@ -128,9 +119,9 @@ export function SeriesEditor() { verticalAlign: 'top', }, }} + tableLayout="auto" /> - ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index 3878c1cde7aa5..7b9278352925e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -16,6 +16,7 @@ import { import { PersistableFilter } from '../../../../../lens/common'; import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; +import { ExistsFilter } from '../../../../../../../src/plugins/data/common/es_query/filters'; export const ReportViewTypes = { pld: 'page-load-dist', @@ -59,7 +60,7 @@ export interface DataSeries { defaultSeriesType: SeriesType; defaultFilters: Array; seriesTypes: SeriesType[]; - filters?: PersistableFilter[]; + filters?: PersistableFilter[] | ExistsFilter[]; reportDefinitions: ReportDefinition[]; labels: Record; hasOperationType: boolean; @@ -67,6 +68,8 @@ export interface DataSeries { yTitle?: string; } +export type URLReportDefinition = Record; + export interface SeriesUrl { time: { to: string; @@ -77,8 +80,8 @@ export interface SeriesUrl { seriesType?: SeriesType; reportType: ReportViewTypeId; operationType?: OperationType; - dataType?: AppDataType; - reportDefinitions?: Record; + dataType: AppDataType; + reportDefinitions?: URLReportDefinition; } export interface UrlFilter { diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx index ae91e1a439188..809937c2c4b18 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx @@ -31,7 +31,7 @@ export default { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -48,7 +48,7 @@ export function ValuesLoaded() { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -61,7 +61,7 @@ export function LoadingState() { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={true} setQuery={() => {}} /> @@ -74,7 +74,7 @@ export function EmptyState() { label="Service name" values={[]} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -94,7 +94,7 @@ export function SearchState(args: FieldValueSelectionProps) { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={setQuery} /> diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx new file mode 100644 index 0000000000000..1c0e1fdb00770 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx @@ -0,0 +1,86 @@ +/* + * 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, useState } from 'react'; +import { merge } from 'lodash'; +import { EuiComboBox, EuiFormControlLayout, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { FieldValueSelectionProps } from './types'; + +const formatOptions = (values?: string[]) => { + const uniqueValues = Array.from(new Set(values)); + + return (uniqueValues ?? []).map((val) => ({ + label: val, + })); +}; + +type ValueOption = EuiComboBoxOptionOption; + +export function FieldValueCombobox({ + label, + selectedValue, + loading, + values, + setQuery, + onChange: onSelectionChange, +}: FieldValueSelectionProps) { + const [options, setOptions] = useState( + formatOptions(merge(values ?? [], selectedValue ?? [])) + ); + + useEffect(() => { + setOptions(formatOptions(merge(values ?? [], selectedValue ?? []))); + }, [selectedValue, values]); + + const onChange = (selectedValuesN: ValueOption[]) => { + onSelectionChange(selectedValuesN.map(({ label: lbl }) => lbl)); + }; + + return ( + + + { + setQuery(searchVal); + }} + options={options} + selectedOptions={options.filter((opt) => selectedValue?.includes(opt.label))} + onChange={onChange} + /> + + + ); +} + +const ComboWrapper = styled.div` + &&& { + .euiFormControlLayout { + height: auto; + .euiFormControlLayout__prepend { + margin: auto; + } + .euiComboBoxPill { + max-width: 250px; + } + .euiComboBox__inputWrap { + border-radius: 0; + } + } + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx index 46f2ce6efa97c..ba898c7dc380f 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.test.tsx @@ -17,7 +17,7 @@ describe('FieldValueSelection', () => { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> @@ -33,7 +33,7 @@ describe('FieldValueSelection', () => { label="Service name" values={['elastic co frontend', 'apm server', 'opbean python']} onChange={() => {}} - value={''} + selectedValue={[]} loading={false} setQuery={() => {}} /> diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx index d7e075d7fc3f2..cbe70c73b2681 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx @@ -16,19 +16,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { isEqual } from 'lodash'; import { FieldValueSelectionProps } from './types'; -const formatOptions = (values?: string[], value?: string): EuiSelectableOption[] => { +const formatOptions = (values?: string[], selectedValue?: string[]): EuiSelectableOption[] => { return (values ?? []).map((val) => ({ label: val, - ...(value === val ? { checked: 'on' } : {}), + ...(selectedValue?.includes(val) ? { checked: 'on' } : {}), })); }; export function FieldValueSelection({ fullWidth, label, - value, + selectedValue, loading, values, setQuery, @@ -39,12 +40,15 @@ export function FieldValueSelection({ singleSelection, onChange: onSelectionChange, }: FieldValueSelectionProps) { - const [options, setOptions] = useState(formatOptions(values, value)); + const [options, setOptions] = useState( + formatOptions(values, selectedValue ?? []) + ); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); useEffect(() => { - setOptions(formatOptions(values, value)); - }, [values, value]); + setOptions(formatOptions(values, selectedValue)); + }, [values, selectedValue]); const onButtonClick = () => { setIsPopoverOpen(!isPopoverOpen); @@ -77,6 +81,14 @@ export function FieldValueSelection({ ); + const applyDisabled = () => { + const currSelected = (options ?? []) + .filter((opt) => opt?.checked === 'on') + .map(({ label: labelN }) => labelN); + + return isEqual(selectedValue ?? [], currSelected); + }; + return ( opt?.checked === 'on')) - } + isDisabled={applyDisabled()} onClick={() => { - const selected = options.find((opt) => opt?.checked === 'on'); - onSelectionChange(selected?.label); + const selectedValuesN = options.filter((opt) => opt?.checked === 'on'); + onSelectionChange(selectedValuesN.map(({ label: lbl }) => lbl)); setIsPopoverOpen(false); }} > diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx index a3bfd8daac7dc..359710e4b9c59 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx @@ -11,13 +11,14 @@ import { useDebounce } from 'react-use'; import { useValuesList } from '../../../hooks/use_values_list'; import { FieldValueSelection } from './field_value_selection'; import { FieldValueSuggestionsProps } from './types'; +import { FieldValueCombobox } from './field_value_combobox'; export function FieldValueSuggestions({ fullWidth, sourceField, label, indexPattern, - value, + selectedValue, filters, button, time, @@ -25,12 +26,20 @@ export function FieldValueSuggestions({ forceOpen, anchorPosition, singleSelection, + asCombobox = true, onChange: onSelectionChange, }: FieldValueSuggestionsProps) { const [query, setQuery] = useState(''); const [debouncedValue, setDebouncedValue] = useState(''); - const { values, loading } = useValuesList({ indexPattern, query, sourceField, filters, time }); + const { values, loading } = useValuesList({ + indexPattern, + query, + sourceField, + filters, + time, + keepHistory: true, + }); useDebounce( () => { @@ -40,8 +49,10 @@ export function FieldValueSuggestions({ [debouncedValue] ); + const SelectionComponent = asCombobox ? FieldValueCombobox : FieldValueSelection; + return ( - void; + asCombobox?: boolean; + onChange: (val?: string[]) => void; filters: ESFilter[]; time?: { from: string; to: string }; }; export type FieldValueSelectionProps = CommonProps & { loading?: boolean; - onChange: (val?: string) => void; + onChange: (val?: string[]) => void; values?: string[]; setQuery: Dispatch>; }; diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx index 0b8b2b5d80a17..97aa72f07b09c 100644 --- a/x-pack/plugins/observability/public/context/has_data_context.tsx +++ b/x-pack/plugins/observability/public/context/has_data_context.tsx @@ -76,7 +76,7 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode }); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [isExploratoryView] ); useEffect(() => { diff --git a/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts index a354ac8a07f05..d31b6b52744c0 100644 --- a/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts @@ -9,12 +9,9 @@ import { ChromeBreadcrumb } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { MouseEvent, useEffect } from 'react'; import { EuiBreadcrumb } from '@elastic/eui'; -import { stringify } from 'query-string'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { useQueryParams } from './use_query_params'; -const EMPTY_QUERY = '?'; - function handleBreadcrumbClick( breadcrumbs: ChromeBreadcrumb[], navigateToHref?: (url: string) => Promise @@ -34,14 +31,7 @@ function handleBreadcrumbClick( })); } -export const makeBaseBreadcrumb = (href: string, params?: any): EuiBreadcrumb => { - if (params) { - const crumbParams = { ...params }; - - delete crumbParams.statusFilter; - const query = stringify(crumbParams, { skipEmptyString: true, skipNull: true }); - href += query === EMPTY_QUERY ? '' : query; - } +export const makeBaseBreadcrumb = (href: string): EuiBreadcrumb => { return { text: i18n.translate('xpack.observability.breadcrumbs.observability', { defaultMessage: 'Observability', @@ -64,7 +54,10 @@ export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { useEffect(() => { if (setBreadcrumbs) { setBreadcrumbs( - handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate) + handleBreadcrumbClick( + [makeBaseBreadcrumb(appPath + '/overview')].concat(extraCrumbs), + navigate + ) ); } }, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]); 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 147a66f3d505e..69e889f0069ee 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -89,21 +89,21 @@ export const useValuesList = ({ }, }, }), - [debouncedQuery, from, to] + [debouncedQuery, from, to, JSON.stringify(filters)] ); useEffect(() => { const newValues = data?.aggregations?.values.buckets.map(({ key: value }) => value as string) ?? []; - if (keepHistory) { + if (keepHistory && query) { setValues((prevState) => { return merge(newValues, prevState); }); } else { setValues(newValues); } - }, [data, keepHistory, loading]); + }, [data, keepHistory, loading, query]); 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..0ff04e4f731d0 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -17,9 +17,30 @@ export const UNKNOWN_SPACE = '?'; export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; + +/** + * Reserved application privileges are always assigned to this "wildcard" application. + * This allows them to be applied to any Kibana "tenant" (`kibana.index`). Since reserved privileges are always assigned to reserved (built-in) roles, + * it's not possible to know the tenant ahead of time. + */ 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/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/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index 4c657294c965c..6f00df3a4ee7b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -742,6 +742,21 @@ describe('global base read', () => { }); }); +describe('global and reserved', () => { + it('base all, reserved_foo', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { spaces: ['*'], base: [], feature: {}, _reserved: ['foo'] }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Foo', overridden: false } }, + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + ]); + }); +}); + describe('global normal feature privilege all', () => { describe('default and marketing space', () => { it('base all', () => { diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index cac556d04031e..829c3ced9dddb 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -21,6 +21,7 @@ function createSetupMock() { } function createStartMock() { return { + authc: authenticationMock.createStart(), navControlService: navControlServiceMock.createStart(), }; } 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/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) => ( +